feat(crm): frontend de catálogos (Proveedores, Clientes enriquecido, detalle)

- Clientes/Prospectos: formulario por secciones (generales, comercial, fiscal)
- Proveedores: nueva página con clasificación múltiple y coberturas
- Páginas de detalle cliente/proveedor con gestión de múltiples direcciones,
  contactos y documentos (componente RelatedManager reutilizable)
- Clientes API tipados (suppliers, addresses, documents) + sidebar actualizado
- svelte-check sin errores de tipo en los archivos del CRM

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-14 16:19:14 -06:00
parent b12af1a561
commit 79135d9b5a
12 changed files with 1112 additions and 134 deletions

View File

@@ -0,0 +1,258 @@
<script lang="ts">
import { MapPin, Users, FileText, Plus, Trash2 } 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 {
addressesAPI, contactsAPI, documentsAPI,
type Address, type Contact, type Document, type AddressInput, type ContactInput, type DocumentInput
} from '$lib/api/crm';
import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
// Dueño de los registros relacionados: cliente (account) o proveedor (supplier)
let { ownerType, ownerId }: { ownerType: 'account' | 'supplier'; ownerId: number } = $props();
let addresses = $state<Address[]>([]);
let contacts = $state<Contact[]>([]);
let documents = $state<Document[]>([]);
let activeModal = $state<'address' | 'contact' | 'document' | null>(null);
let saving = $state(false);
let addressForm = $state<AddressInput>({ address_type: 'fiscal', country: 'MX', is_primary: false });
let contactForm = $state<ContactInput>({ first_name: '' });
let documentForm = $state<DocumentInput>({ doc_type: 'constancia_fiscal', name: '' });
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const ownerParam = $derived(ownerType === 'account' ? { account_id: ownerId } : { supplier_id: ownerId });
$effect(() => {
if (companyId && ownerId) void load(companyId);
});
async function load(cid: number) {
try {
[addresses, contacts, documents] = await Promise.all([
addressesAPI.list(cid, ownerParam),
contactsAPI.list(cid, ownerParam),
documentsAPI.list(cid, ownerParam)
]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los datos relacionados');
}
}
function openModal(kind: 'address' | 'contact' | 'document') {
if (kind === 'address') addressForm = { address_type: 'fiscal', country: 'MX', is_primary: false };
if (kind === 'contact') contactForm = { first_name: '' };
if (kind === 'document') documentForm = { doc_type: 'constancia_fiscal', name: '' };
activeModal = kind;
}
async function saveAddress(e: SubmitEvent) {
e.preventDefault();
if (!companyId) return;
saving = true;
try {
await addressesAPI.create({ ...addressForm, ...ownerParam }, companyId);
toast.success('Dirección agregada');
activeModal = null;
await load(companyId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
async function saveContact(e: SubmitEvent) {
e.preventDefault();
if (!companyId) return;
if (!contactForm.first_name?.trim()) { toast.error('El nombre es obligatorio'); return; }
saving = true;
try {
await contactsAPI.create({ ...contactForm, ...ownerParam }, companyId);
toast.success('Contacto agregado');
activeModal = null;
await load(companyId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
async function saveDocument(e: SubmitEvent) {
e.preventDefault();
if (!companyId) return;
if (!documentForm.name?.trim()) { toast.error('El nombre es obligatorio'); return; }
saving = true;
try {
await documentsAPI.create({ ...documentForm, ...ownerParam }, companyId);
toast.success('Documento agregado');
activeModal = null;
await load(companyId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
async function removeAddress(a: Address) {
if (!companyId || !confirm('¿Eliminar dirección?')) return;
await addressesAPI.remove(a.id, companyId);
await load(companyId);
}
async function removeContact(c: Contact) {
if (!companyId || !confirm('¿Eliminar contacto?')) return;
await contactsAPI.remove(c.id, companyId);
await load(companyId);
}
async function removeDocument(d: Document) {
if (!companyId || !confirm('¿Eliminar documento?')) return;
await documentsAPI.remove(d.id, companyId);
await load(companyId);
}
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="grid gap-4">
<!-- Direcciones -->
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<Card.Title class="flex items-center gap-2 text-base"><MapPin class="h-4 w-4" /> Direcciones</Card.Title>
<Button size="sm" variant="outline" onclick={() => openModal('address')}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</Card.Header>
<Card.Content>
{#if addresses.length === 0}
<p class="text-sm text-muted-foreground">Sin direcciones.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Tipo</Table.Head><Table.Head>Domicilio</Table.Head><Table.Head>CP</Table.Head><Table.Head>Ciudad</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each addresses as a (a.id)}
<Table.Row>
<Table.Cell>{labelOf(ADDRESS_TYPES, a.address_type)}{#if a.is_primary}<span class="ml-1 text-[10px] text-primary">(principal)</span>{/if}</Table.Cell>
<Table.Cell class="text-sm">{[a.street, a.ext_number, a.neighborhood].filter(Boolean).join(' ') || '—'}</Table.Cell>
<Table.Cell>{a.postal_code ?? '—'}</Table.Cell>
<Table.Cell>{[a.city, a.state].filter(Boolean).join(', ') || '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeAddress(a)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
<!-- Contactos -->
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<Card.Title class="flex items-center gap-2 text-base"><Users class="h-4 w-4" /> Contactos</Card.Title>
<Button size="sm" variant="outline" onclick={() => openModal('contact')}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</Card.Header>
<Card.Content>
{#if contacts.length === 0}
<p class="text-sm text-muted-foreground">Sin contactos.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Nombre</Table.Head><Table.Head>Puesto / Área</Table.Head><Table.Head>Email</Table.Head><Table.Head>Teléfono</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each contacts as c (c.id)}
<Table.Row>
<Table.Cell class="font-medium">{c.first_name} {c.last_name ?? ''}{#if c.is_primary}<span class="ml-1 text-[10px] text-primary">(principal)</span>{/if}</Table.Cell>
<Table.Cell class="text-sm">{[c.job_title, labelOf(CONTACT_AREAS, c.area) !== '—' ? labelOf(CONTACT_AREAS, c.area) : null].filter(Boolean).join(' · ') || '—'}</Table.Cell>
<Table.Cell>{c.email ?? '—'}</Table.Cell>
<Table.Cell>{c.phone ?? c.mobile ?? '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeContact(c)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
<!-- Documentos -->
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<Card.Title class="flex items-center gap-2 text-base"><FileText class="h-4 w-4" /> Documentos</Card.Title>
<Button size="sm" variant="outline" onclick={() => openModal('document')}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</Card.Header>
<Card.Content>
{#if documents.length === 0}
<p class="text-sm text-muted-foreground">Sin documentos.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Tipo</Table.Head><Table.Head>Nombre</Table.Head><Table.Head>Archivo</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each documents as d (d.id)}
<Table.Row>
<Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell>
<Table.Cell class="font-medium">{d.name}</Table.Cell>
<Table.Cell>{#if d.file_url}<a class="text-primary hover:underline" href={d.file_url} target="_blank" rel="noopener">Ver</a>{:else}{/if}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDocument(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
</div>
{#if activeModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (activeModal = null)}>
<div class="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
{#if activeModal === 'address'}
<h3 class="mb-4 text-base font-semibold">Nueva dirección</h3>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={saveAddress}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo</span><select class={inputCls} bind:value={addressForm.address_type}>{#each ADDRESS_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Código Postal</span><input class={inputCls} maxlength="10" bind:value={addressForm.postal_code} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Calle</span><input class={inputCls} bind:value={addressForm.street} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Núm. exterior</span><input class={inputCls} bind:value={addressForm.ext_number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Núm. interior</span><input class={inputCls} bind:value={addressForm.int_number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Colonia</span><input class={inputCls} bind:value={addressForm.neighborhood} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Municipio</span><input class={inputCls} bind:value={addressForm.city} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estado</span><input class={inputCls} bind:value={addressForm.state} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País</span><input class={inputCls} maxlength="2" bind:value={addressForm.country} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Referencias</span><textarea rows="2" class={inputCls} bind:value={addressForm.reference_notes}></textarea></label>
<label class="flex items-center gap-2 text-sm sm:col-span-2"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={addressForm.is_primary} /><span>Domicilio principal</span></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{:else if activeModal === 'contact'}
<h3 class="mb-4 text-base font-semibold">Nuevo contacto</h3>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={saveContact}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={contactForm.first_name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Apellidos</span><input class={inputCls} bind:value={contactForm.last_name} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puesto</span><input class={inputCls} bind:value={contactForm.job_title} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Área</span><select class={inputCls} bind:value={contactForm.area}><option value={undefined}>—</option>{#each CONTACT_AREAS as a (a.value)}<option value={a.value}>{a.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Email</span><input type="email" class={inputCls} bind:value={contactForm.email} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Teléfono</span><input class={inputCls} bind:value={contactForm.phone} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Extensión</span><input class={inputCls} bind:value={contactForm.extension} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Celular</span><input class={inputCls} bind:value={contactForm.mobile} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">WhatsApp</span><input class={inputCls} bind:value={contactForm.whatsapp} /></label>
<div class="flex flex-col gap-1 sm:col-span-2">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={contactForm.is_primary} /><span>Contacto principal</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={contactForm.receives_quotes} /><span>Recibe cotizaciones</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={contactForm.receives_invoices} /><span>Recibe facturas</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={contactForm.receives_commercial_info} /><span>Recibe información comercial</span></label>
</div>
<div class="flex justify-end gap-2 sm:col-span-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{:else if activeModal === 'document'}
<h3 class="mb-4 text-base font-semibold">Nuevo documento</h3>
<form class="grid gap-3" onsubmit={saveDocument}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo</span><select class={inputCls} bind:value={documentForm.doc_type}>{#each DOC_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={documentForm.name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">URL del archivo</span><input class={inputCls} bind:value={documentForm.file_url} placeholder="https://…" /></label>
<p class="text-xs text-muted-foreground">La subida de archivos a MinIO se conectará en una siguiente iteración; por ahora se registra la referencia (URL).</p>
<div class="flex justify-end gap-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{/if}
</div>
</div>
{/if}

View File

@@ -1,7 +1,12 @@
/**
* Utilidades de formato y etiquetas legibles para el CRM.
* Utilidades de formato y catálogos de etiquetas para el CRM.
*/
export interface Option {
value: string;
label: string;
}
export function formatMoney(value: number | null | undefined, currency = 'MXN'): string {
if (value === null || value === undefined) return '—';
return new Intl.NumberFormat('es-MX', { style: 'currency', currency }).format(Number(value));
@@ -14,7 +19,34 @@ export function formatDate(value: string | null | undefined): string {
return d.toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' });
}
export const ACCOUNT_TYPES: { value: string; label: string }[] = [
export function labelOf(list: Option[], value: string | null | undefined): string {
if (!value) return '—';
return list.find((x) => x.value === value)?.label ?? value;
}
// ----- Clientes / Prospectos -----
export const RECORD_TYPES: Option[] = [
{ value: 'cliente', label: 'Cliente' },
{ value: 'prospecto', label: 'Prospecto' }
];
export const PERSON_TYPES: Option[] = [
{ value: 'fisica', label: 'Física' },
{ value: 'moral', label: 'Moral' }
];
export const ACCOUNT_STATUS: Option[] = [
{ value: 'active', label: 'Activo' },
{ value: 'inactive', label: 'Inactivo' }
];
export const COMMERCIAL_CLASSIFICATION: Option[] = [
{ value: 'importador', label: 'Importador' },
{ value: 'exportador', label: 'Exportador' },
{ value: 'ambos', label: 'Importador / Exportador' }
];
export const ACCOUNT_TYPES: Option[] = [
{ value: 'importador', label: 'Importador' },
{ value: 'exportador', label: 'Exportador' },
{ value: 'immex', label: 'IMMEX / Maquila' },
@@ -23,13 +55,78 @@ export const ACCOUNT_TYPES: { value: string; label: string }[] = [
{ value: 'otro', label: 'Otro' }
];
export const ACCOUNT_STATUS: { value: string; label: string }[] = [
{ value: 'active', label: 'Activa' },
{ value: 'prospect', label: 'Prospecto' },
{ value: 'inactive', label: 'Inactiva' }
export const CONTACT_METHODS: Option[] = [
{ value: 'llamada', label: 'Llamada telefónica' },
{ value: 'correo', label: 'Correo electrónico' },
{ value: 'videollamada', label: 'Videoconferencia' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'otro', label: 'Otro' }
];
export const LEAD_SOURCES: { value: string; label: string }[] = [
// ----- Proveedores -----
export const SUPPLIER_CLASSIFICATIONS: Option[] = [
{ value: 'naviera', label: 'Naviera' },
{ value: 'aerolinea', label: 'Aerolínea' },
{ value: 'transportista_terrestre', label: 'Transportista terrestre' },
{ value: 'ferrocarril', label: 'Ferrocarril' },
{ value: 'agente_aduanal', label: 'Agente aduanal' },
{ value: 'agente_carga', label: 'Agente de carga' },
{ value: 'agente_corresponsal', label: 'Agente corresponsal' },
{ value: 'almacen', label: 'Almacén' },
{ value: 'aseguradora', label: 'Aseguradora' },
{ value: 'paqueteria', label: 'Paquetería' },
{ value: 'otro', label: 'Otro' }
];
export const COVERAGE: Option[] = [
{ value: 'nacional', label: 'Nacional' },
{ value: 'internacional', label: 'Internacional' },
{ value: 'ambos', label: 'Nacional e internacional' }
];
// ----- Direcciones -----
export const ADDRESS_TYPES: Option[] = [
{ value: 'fiscal', label: 'Fiscal' },
{ value: 'oficina', label: 'Oficina' },
{ value: 'sucursal', label: 'Sucursal' },
{ value: 'bodega', label: 'Bodega' },
{ value: 'patio', label: 'Patio' },
{ value: 'terminal', label: 'Terminal' },
{ value: 'almacen', label: 'Almacén' }
];
// ----- Documentos -----
export const DOC_TYPES: Option[] = [
{ value: 'constancia_fiscal', label: 'Constancia de Situación Fiscal' },
{ value: 'acta_constitutiva', label: 'Acta Constitutiva' },
{ value: 'identificacion', label: 'Identificación Oficial' },
{ value: 'comprobante_domicilio', label: 'Comprobante de Domicilio' },
{ value: 'contrato', label: 'Contrato' },
{ value: 'presentacion', label: 'Presentación Comercial' },
{ value: 'certificacion', label: 'Certificación' },
{ value: 'licencia', label: 'Licencia' },
{ value: 'convenio', label: 'Convenio' },
{ value: 'tarifario', label: 'Tarifario' },
{ value: 'otro', label: 'Otro' }
];
// ----- Contactos -----
export const CONTACT_AREAS: Option[] = [
{ value: 'ventas', label: 'Ventas' },
{ value: 'operaciones', label: 'Operaciones' },
{ value: 'facturacion', label: 'Facturación' },
{ value: 'cobranza', label: 'Cobranza' },
{ value: 'servicio_cliente', label: 'Servicio al Cliente' },
{ value: 'otro', label: 'Otro' }
];
export const CONTACT_STATUS: Option[] = [
{ value: 'active', label: 'Activo' },
{ value: 'inactive', label: 'Inactivo' }
];
// ----- Prospectos (leads / funnel) -----
export const LEAD_SOURCES: Option[] = [
{ value: 'web', label: 'Web' },
{ value: 'referido', label: 'Referido' },
{ value: 'evento', label: 'Evento' },
@@ -38,7 +135,7 @@ export const LEAD_SOURCES: { value: string; label: string }[] = [
{ value: 'otro', label: 'Otro' }
];
export const LEAD_STATUS: { value: string; label: string }[] = [
export const LEAD_STATUS: Option[] = [
{ value: 'new', label: 'Nuevo' },
{ value: 'contacted', label: 'Contactado' },
{ value: 'qualified', label: 'Calificado' },
@@ -46,7 +143,8 @@ export const LEAD_STATUS: { value: string; label: string }[] = [
{ value: 'converted', label: 'Convertido' }
];
export const ACTIVITY_TYPES: { value: string; label: string }[] = [
// ----- Actividades -----
export const ACTIVITY_TYPES: Option[] = [
{ value: 'call', label: 'Llamada' },
{ value: 'meeting', label: 'Reunión' },
{ value: 'task', label: 'Tarea' },
@@ -54,13 +152,8 @@ export const ACTIVITY_TYPES: { value: string; label: string }[] = [
{ value: 'note', label: 'Nota' }
];
export const ACTIVITY_STATUS: { value: string; label: string }[] = [
export const ACTIVITY_STATUS: Option[] = [
{ value: 'pending', label: 'Pendiente' },
{ value: 'completed', label: 'Completada' },
{ value: 'canceled', label: 'Cancelada' }
];
export function labelOf(list: { value: string; label: string }[], value: string | null): string {
if (!value) return '—';
return list.find((x) => x.value === value)?.label ?? value;
}

View File

@@ -41,9 +41,10 @@ export function getNavMain(): NavMainItem[] {
icon: Briefcase,
items: [
{ title: 'Panel', url: '/dashboard/crm' },
{ title: 'Cuentas', url: '/dashboard/crm/cuentas' },
{ title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' },
{ title: 'Proveedores', url: '/dashboard/crm/proveedores' },
{ title: 'Contactos', url: '/dashboard/crm/contactos' },
{ title: 'Prospectos', url: '/dashboard/crm/prospectos' },
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
{ title: 'Actividades', url: '/dashboard/crm/actividades' },
],