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:
50
frontend/src/lib/api/crm/cases.ts
Normal file
50
frontend/src/lib/api/crm/cases.ts
Normal 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)}`))
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -13,3 +13,4 @@ export { opportunitiesAPI } from './opportunities';
|
||||
export { activitiesAPI } from './activities';
|
||||
export { metricsAPI } from './metrics';
|
||||
export * from './commercial';
|
||||
export * from './cases';
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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">
|
||||
|
||||
98
frontend/src/routes/dashboard/crm/expedientes/+page.svelte
Normal file
98
frontend/src/routes/dashboard/crm/expedientes/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user