feat(crm): Expediente — referencia única de trazabilidad del trámite (Fase D)

- Tabla crm.cases (expediente) con folio EXP2026-08-001 (next_folio entidad EXP,
  sin dirección). Nace al crear la Oportunidad y se hereda vía case_id a
  solicitud → cotización → operación → factura. advance_stage solo avanza.
- case_id (FK a crm.cases) en crm.opportunities/service_requests/quotes,
  ops.shipments y fin.invoices; propagación en sus create_*. Migración
  d4e5f6a7b8c9 reversible.
- Endpoints GET /v1/crm/cases, /cases/{id}, /cases/by-ref/{ref} con timeline
  (historia completa para UI y otros sistemas).
- Frontend: casesAPI, ruta /dashboard/crm/expedientes (lista + timeline vertical),
  chip "📁 Expediente" en solicitud/cotización, "Expedientes" en el sidebar.
- Consecutivo de folios sin tope (soporta >10,000,000/mes).
- 4 pruebas de expediente (minteo, propagación, timeline, no-retroceso). Suite en verde (113).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-08-07 07:58:51 -06:00
parent b47dc542f2
commit afe659e56a
33 changed files with 637 additions and 3 deletions

View File

@@ -0,0 +1,50 @@
/**
* Cliente API — Expedientes (referencia única de trazabilidad del trámite).
*/
import { api } from '$lib/api';
export interface Case {
id: number;
reference: string | null;
account_id: number | null;
title: string | null;
stage: string;
status: string;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export interface CaseTimelineEvent {
kind: string; // oportunidad | solicitud | cotizacion | operacion | factura
id: number;
reference: string | null;
status: string | null;
created_at: string;
url: string;
}
export interface CaseWithTimeline extends Case {
timeline: CaseTimelineEvent[];
}
function qp(companyId: number, extra?: Record<string, string | number | undefined>) {
const qs = new URLSearchParams({ company_id: String(companyId) });
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v));
return qs.toString();
}
async function unwrap<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
const res = await p;
if (res.error) throw new Error(res.error);
return res.data as T;
}
export const casesAPI = {
list: (companyId: number, params?: { search?: string; account_id?: number; stage?: string }) =>
unwrap<Case[]>(api.get(`/v1/crm/cases?${qp(companyId, params)}`)),
get: (id: number, companyId: number) =>
unwrap<CaseWithTimeline>(api.get(`/v1/crm/cases/${id}?${qp(companyId)}`)),
byRef: (reference: string, companyId: number) =>
unwrap<CaseWithTimeline>(api.get(`/v1/crm/cases/by-ref/${encodeURIComponent(reference)}?${qp(companyId)}`))
};

View File

@@ -10,6 +10,7 @@ export type QuoteStatus = 'borrador' | 'enviada' | 'aceptada' | 'rechazada';
export interface ServiceRequest {
id: number;
reference: string | null;
case_id: number | null;
account_id: number | null;
contact_id: number | null;
opportunity_id: number | null;
@@ -107,6 +108,7 @@ export interface Quote {
reference: string | null;
service_request_id: number | null;
service_request_reference: string | null;
case_id: number | null;
account_id: number | null;
currency: string;
load_type: string | null;

View File

@@ -13,3 +13,4 @@ export { opportunitiesAPI } from './opportunities';
export { activitiesAPI } from './activities';
export { metricsAPI } from './metrics';
export * from './commercial';
export * from './cases';

View File

@@ -45,6 +45,7 @@ export function getNavMain(): NavMainItem[] {
// Orden por flujo comercial: captación → embudo → solicitud → cotización → apoyo
items: [
{ title: 'Panel', url: '/dashboard/crm' },
{ title: 'Expedientes', url: '/dashboard/crm/expedientes' },
{ title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' },
{ title: 'Contactos', url: '/dashboard/crm/contactos' },
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },

View File

@@ -220,8 +220,9 @@
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> {quote.reference ?? `Cotización #${quote.id}`}</h1>
<p class="mt-1 text-sm">
<p class="mt-1 flex items-center gap-2 text-sm">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[quote.status] ?? ''}">{labelOf(QUOTE_STATUS, quote.status)}</span>
{#if quote.case_id}<a class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 font-mono text-[11px] hover:underline" href={`/dashboard/crm/expedientes/${quote.case_id}`}>📁 Expediente</a>{/if}
</p>
</div>
<div class="flex flex-wrap gap-2">

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { FolderKanban, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { casesAPI, accountsAPI, type Case, type Account } from '$lib/api/crm';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const STAGE_LABEL: Record<string, string> = {
oportunidad: 'Oportunidad', solicitud: 'Solicitud', cotizacion: 'Cotización',
operacion: 'Operación', facturacion: 'Facturación', cerrado: 'Cerrado'
};
let items = $state<Case[]>([]);
let accounts = $state<Account[]>([]);
let loading = $state(false);
let search = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
function accountName(id: number | null): string {
return accounts.find((a) => a.id === id)?.name ?? '—';
}
const filtered = $derived(
search.trim()
? items.filter((c) => `${c.reference ?? ''} ${c.title ?? ''} ${accountName(c.account_id)}`.toLowerCase().includes(search.trim().toLowerCase()))
: items
);
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
[items, accounts] = await Promise.all([casesAPI.list(cid), accountsAPI.list(cid)]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los expedientes');
} finally {
loading = false;
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FolderKanban class="h-6 w-6" /> Expedientes</h1>
<p class="mt-1 text-sm text-muted-foreground">Referencia única que hila todo el trámite (oportunidad → solicitud → cotización → operación → factura).</p>
</div>
<Card.Root>
<Card.Header>
<div class="relative max-w-sm">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio o cliente…" bind:value={search} />
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin expedientes. Se crean automáticamente al generar una oportunidad.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Expediente</Table.Head>
<Table.Head>Cliente</Table.Head>
<Table.Head>Etapa</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head>Creado</Table.Head>
<Table.Head></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as c (c.id)}
<Table.Row>
<Table.Cell class="font-mono font-medium"><a class="hover:underline" href={`/dashboard/crm/expedientes/${c.id}`}>{c.reference ?? `#${c.id}`}</a></Table.Cell>
<Table.Cell>{accountName(c.account_id)}</Table.Cell>
<Table.Cell>{STAGE_LABEL[c.stage] ?? c.stage}</Table.Cell>
<Table.Cell>{c.status}</Table.Cell>
<Table.Cell>{formatDate(c.created_at)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" href={`/dashboard/crm/expedientes/${c.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { ArrowLeft, FolderKanban, Target, FileText, Receipt, Ship, DollarSign } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { casesAPI, type CaseWithTimeline } from '$lib/api/crm';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const caseId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let data = $state<CaseWithTimeline | null>(null);
let loading = $state(false);
const STAGE_LABEL: Record<string, string> = {
oportunidad: 'Oportunidad', solicitud: 'Solicitud', cotizacion: 'Cotización',
operacion: 'Operación', facturacion: 'Facturación', cerrado: 'Cerrado'
};
const KIND: Record<string, { label: string; icon: any }> = {
oportunidad: { label: 'Oportunidad', icon: Target },
solicitud: { label: 'Solicitud', icon: FileText },
cotizacion: { label: 'Cotización', icon: Receipt },
operacion: { label: 'Operación / Embarque', icon: Ship },
factura: { label: 'Factura', icon: DollarSign }
};
$effect(() => {
const cid = companyId;
const id = caseId;
if (!cid || !id) return;
void (async () => {
loading = true;
try { data = await casesAPI.get(id, cid); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el expediente'); }
finally { loading = false; }
})();
});
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/expedientes"><ArrowLeft class="mr-1 h-4 w-4" /> Expedientes</Button>
{#if loading && !data}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if data}
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FolderKanban class="h-6 w-6" /> <span class="font-mono">{data.reference ?? `Expediente #${data.id}`}</span></h1>
<p class="mt-1 text-sm text-muted-foreground">Etapa: <b>{STAGE_LABEL[data.stage] ?? data.stage}</b> · {data.status}{#if data.title} · {data.title}{/if}</p>
</div>
<Card.Root>
<Card.Header><Card.Title class="text-base">Historia del trámite</Card.Title>
<Card.Description>Todos los documentos ligados a este expediente, en orden cronológico.</Card.Description>
</Card.Header>
<Card.Content>
{#if data.timeline.length === 0}
<p class="text-sm text-muted-foreground">Sin movimientos aún.</p>
{:else}
<ol class="relative ml-3 border-l pl-6">
{#each data.timeline as ev (ev.kind + '-' + ev.id)}
{@const K = KIND[ev.kind] ?? { label: ev.kind, icon: FileText }}
<li class="mb-5">
<span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border bg-background">
<K.icon class="h-3.5 w-3.5" />
</span>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs uppercase text-muted-foreground">{K.label}</span>
<a class="font-mono text-sm font-medium hover:underline" href={ev.url}>{ev.reference ?? `#${ev.id}`}</a>
{#if ev.status}<span class="rounded-full bg-muted px-2 py-0.5 text-[10px]">{ev.status}</span>{/if}
<span class="text-xs text-muted-foreground">{formatDate(ev.created_at)}</span>
</div>
</li>
{/each}
</ol>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -160,6 +160,7 @@
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> {sr.reference ?? `Solicitud #${sr.id}`}</h1>
<p class="mt-1 text-sm text-muted-foreground">{labelOf(OPERATION_TYPES, sr.operation_type)} · {labelOf(SR_STATUS, sr.status)}</p>
{#if sr.case_id}<a class="mt-1 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 font-mono text-[11px] hover:underline" href={`/dashboard/crm/expedientes/${sr.case_id}`}>📁 Expediente</a>{/if}
</div>
<div class="flex flex-wrap gap-2">
{#if sr.status === 'nueva' || sr.status === 'contacto'}<Button size="sm" variant="outline" onclick={registerContact} disabled={busy}>Registrar contacto</Button>{/if}