refactor(crm): catálogos como páginas completas con pestañas (sin modales)

- Clientes/Prospectos y Proveedores: alta y edición en páginas dedicadas
  (/nuevo y /[id]) respetando el shell del dashboard, en vez de modales
- Info segmentada en pestañas: Generales · Comercial · Fiscal · Observaciones
  · Direcciones · Contactos · Documentos
- Componentes reutilizables AccountFields/SupplierFields; RelatedManager
  ahora renderiza por sección
- svelte-check: 0 errores de tipo en archivos del CRM

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-14 17:44:21 -06:00
parent 79135d9b5a
commit c26e04bcff
9 changed files with 551 additions and 375 deletions

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import type { AccountInput } from '$lib/api/crm';
import {
ACCOUNT_TYPES, ACCOUNT_STATUS, RECORD_TYPES, PERSON_TYPES,
COMMERCIAL_CLASSIFICATION, CONTACT_METHODS
} from '$lib/components/crm/format';
// `form` es un objeto reactivo del padre; se mutan sus propiedades vía bind:value.
let { form = $bindable(), tab }: { form: AccountInput; tab: string } = $props();
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
{#if tab === 'generales'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Razón social *</span><input class={inputCls} bind:value={form.name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre comercial</span><input class={inputCls} bind:value={form.trade_name} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">RFC</span><input class="font-mono {inputCls}" maxlength="13" bind:value={form.rfc} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">CURP</span><input class="font-mono {inputCls}" maxlength="18" bind:value={form.curp} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de registro</span><select class={inputCls} bind:value={form.record_type}>{#each RECORD_TYPES as r (r.value)}<option value={r.value}>{r.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de persona</span><select class={inputCls} bind:value={form.person_type}><option value={undefined}>—</option>{#each PERSON_TYPES as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Giro / Industria</span><input class={inputCls} bind:value={form.industry} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo operativo</span><select class={inputCls} bind:value={form.account_type}><option value={undefined}>—</option>{#each ACCOUNT_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">Estatus</span><select class={inputCls} bind:value={form.status}>{#each ACCOUNT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
</div>
{:else if tab === 'comercial'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Clasificación</span><select class={inputCls} bind:value={form.commercial_classification}><option value={undefined}>—</option>{#each COMMERCIAL_CLASSIFICATION as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de contacto preferido</span><select class={inputCls} bind:value={form.preferred_contact_method}><option value={undefined}>—</option>{#each CONTACT_METHODS as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Idioma</span><input class={inputCls} bind:value={form.language} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Email</span><input type="email" class={inputCls} bind:value={form.email} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Teléfono</span><input class={inputCls} bind:value={form.phone} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Sitio web</span><input class={inputCls} bind:value={form.website} /></label>
</div>
{:else if tab === 'fiscal'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Régimen fiscal</span><input class={inputCls} bind:value={form.tax_regime} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Uso de CFDI</span><input class={inputCls} bind:value={form.cfdi_use} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><input class={inputCls} bind:value={form.payment_method} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><input class={inputCls} bind:value={form.payment_form} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Límite de crédito</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.credit_limit} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Días de crédito</span><input type="number" min="0" class={inputCls} bind:value={form.credit_days} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Patente aduanal</span><input class={inputCls} maxlength="20" bind:value={form.patente_aduanal} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Condiciones comerciales</span><textarea rows="3" class={inputCls} bind:value={form.commercial_terms}></textarea></label>
</div>
{:else if tab === 'observaciones'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Comentarios</span><textarea rows="4" class={inputCls} bind:value={form.notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas internas</span><textarea rows="4" class={inputCls} bind:value={form.internal_notes}></textarea></label>
</div>
{/if}

View File

@@ -11,8 +11,18 @@
import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format'; import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
// Dueño de los registros relacionados: cliente (account) o proveedor (supplier) // Dueño de los registros relacionados y qué sección mostrar
let { ownerType, ownerId }: { ownerType: 'account' | 'supplier'; ownerId: number } = $props(); let {
ownerType,
ownerId,
section = 'all'
}: {
ownerType: 'account' | 'supplier';
ownerId: number;
section?: 'addresses' | 'contacts' | 'documents' | 'all';
} = $props();
const show = (s: 'addresses' | 'contacts' | 'documents') => section === 'all' || section === s;
let addresses = $state<Address[]>([]); let addresses = $state<Address[]>([]);
let contacts = $state<Contact[]>([]); let contacts = $state<Contact[]>([]);
@@ -120,93 +130,96 @@
</script> </script>
<div class="grid gap-4"> <div class="grid gap-4">
<!-- Direcciones --> {#if show('addresses')}
<Card.Root> <Card.Root>
<Card.Header class="flex flex-row items-center justify-between"> <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> <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> <Button size="sm" variant="outline" onclick={() => openModal('address')}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
{#if addresses.length === 0} {#if addresses.length === 0}
<p class="text-sm text-muted-foreground">Sin direcciones.</p> <p class="text-sm text-muted-foreground">Sin direcciones.</p>
{:else} {:else}
<Table.Root> <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.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> <Table.Body>
{#each addresses as a (a.id)} {#each addresses as a (a.id)}
<Table.Row> <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>{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 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.postal_code ?? '—'}</Table.Cell>
<Table.Cell>{[a.city, a.state].filter(Boolean).join(', ') || '—'}</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.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> </Table.Row>
{/each} {/each}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
{/if} {/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{/if}
<!-- Contactos --> {#if show('contacts')}
<Card.Root> <Card.Root>
<Card.Header class="flex flex-row items-center justify-between"> <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> <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> <Button size="sm" variant="outline" onclick={() => openModal('contact')}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
{#if contacts.length === 0} {#if contacts.length === 0}
<p class="text-sm text-muted-foreground">Sin contactos.</p> <p class="text-sm text-muted-foreground">Sin contactos.</p>
{:else} {:else}
<Table.Root> <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.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> <Table.Body>
{#each contacts as c (c.id)} {#each contacts as c (c.id)}
<Table.Row> <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="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 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.email ?? '—'}</Table.Cell>
<Table.Cell>{c.phone ?? c.mobile ?? '—'}</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.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> </Table.Row>
{/each} {/each}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
{/if} {/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{/if}
<!-- Documentos --> {#if show('documents')}
<Card.Root> <Card.Root>
<Card.Header class="flex flex-row items-center justify-between"> <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> <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> <Button size="sm" variant="outline" onclick={() => openModal('document')}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
{#if documents.length === 0} {#if documents.length === 0}
<p class="text-sm text-muted-foreground">Sin documentos.</p> <p class="text-sm text-muted-foreground">Sin documentos.</p>
{:else} {:else}
<Table.Root> <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.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> <Table.Body>
{#each documents as d (d.id)} {#each documents as d (d.id)}
<Table.Row> <Table.Row>
<Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell> <Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell>
<Table.Cell class="font-medium">{d.name}</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>{#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.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> </Table.Row>
{/each} {/each}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
{/if} {/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{/if}
</div> </div>
{#if activeModal} {#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="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()}> <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" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if activeModal === 'address'} {#if activeModal === 'address'}
<h3 class="mb-4 text-base font-semibold">Nueva dirección</h3> <h3 class="mb-4 text-base font-semibold">Nueva dirección</h3>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={saveAddress}> <form class="grid gap-3 sm:grid-cols-2" onsubmit={saveAddress}>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import type { SupplierInput } from '$lib/api/crm';
import { SUPPLIER_CLASSIFICATIONS, COVERAGE, ACCOUNT_STATUS, PERSON_TYPES } from '$lib/components/crm/format';
// listas separadas por coma también son bindables (el padre las convierte a arreglo)
let {
form = $bindable(),
tab,
countriesStr = $bindable(),
portsStr = $bindable(),
airportsStr = $bindable(),
customsStr = $bindable()
}: {
form: SupplierInput;
tab: string;
countriesStr: string;
portsStr: string;
airportsStr: string;
customsStr: string;
} = $props();
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
{#if tab === 'generales'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Razón social *</span><input class={inputCls} bind:value={form.name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre comercial</span><input class={inputCls} bind:value={form.trade_name} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">RFC</span><input class="font-mono {inputCls}" maxlength="13" bind:value={form.rfc} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de persona</span><select class={inputCls} bind:value={form.person_type}><option value={undefined}>—</option>{#each PERSON_TYPES as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each ACCOUNT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
</div>
<div class="mt-4">
<p class="mb-2 text-sm font-medium">Clasificación (una o varias)</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each SUPPLIER_CLASSIFICATIONS as c (c.value)}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" value={c.value} bind:group={form.classifications} /><span>{c.label}</span></label>
{/each}
</div>
</div>
{:else if tab === 'comercial'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cobertura</span><select class={inputCls} bind:value={form.coverage}><option value={undefined}>—</option>{#each COVERAGE as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda de cotización</span><input class={inputCls} maxlength="3" bind:value={form.quote_currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Países (separados por coma)</span><input class={inputCls} bind:value={countriesStr} placeholder="MX, US, PA" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puertos</span><input class={inputCls} bind:value={portsStr} placeholder="Veracruz, Manzanillo" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aeropuertos</span><input class={inputCls} bind:value={airportsStr} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aduanas</span><input class={inputCls} bind:value={customsStr} placeholder="Nuevo Laredo, Colombia" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Horario de atención</span><input class={inputCls} bind:value={form.business_hours} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tiempo prom. de respuesta</span><input class={inputCls} bind:value={form.avg_response_time} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Email</span><input type="email" class={inputCls} bind:value={form.email} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Teléfono</span><input class={inputCls} bind:value={form.phone} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Servicios que ofrece</span><textarea rows="2" class={inputCls} bind:value={form.services_offered}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Observaciones comerciales</span><textarea rows="2" class={inputCls} bind:value={form.commercial_notes}></textarea></label>
</div>
{:else if tab === 'fiscal'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Régimen fiscal</span><input class={inputCls} bind:value={form.tax_regime} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><input class={inputCls} bind:value={form.payment_method} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><input class={inputCls} bind:value={form.payment_form} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Límite de crédito</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.credit_limit} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Días de crédito</span><input type="number" min="0" class={inputCls} bind:value={form.credit_days} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Condiciones comerciales</span><textarea rows="3" class={inputCls} bind:value={form.commercial_terms}></textarea></label>
</div>
{:else if tab === 'observaciones'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Comentarios</span><textarea rows="4" class={inputCls} bind:value={form.notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas internas</span><textarea rows="4" class={inputCls} bind:value={form.internal_notes}></textarea></label>
</div>
{/if}

View File

@@ -1,24 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Building2, Plus, Pencil, Trash2, Search, SlidersHorizontal } from '@lucide/svelte'; import { Building2, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte'; import { companyStore } from '$lib/stores/company.svelte';
import { accountsAPI, type Account, type AccountInput } from '$lib/api/crm'; import { accountsAPI, type Account } from '$lib/api/crm';
import { import { ACCOUNT_STATUS, RECORD_TYPES, COMMERCIAL_CLASSIFICATION, labelOf } from '$lib/components/crm/format';
ACCOUNT_TYPES, ACCOUNT_STATUS, RECORD_TYPES, PERSON_TYPES, COMMERCIAL_CLASSIFICATION,
CONTACT_METHODS, labelOf
} from '$lib/components/crm/format';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
let items = $state<Account[]>([]); let items = $state<Account[]>([]);
let loading = $state(false); let loading = $state(false);
let search = $state(''); let search = $state('');
let recordFilter = $state(''); let recordFilter = $state('');
let modalOpen = $state(false);
let saving = $state(false);
let editingId = $state<number | null>(null);
let form = $state<AccountInput>({ name: '', record_type: 'cliente', status: 'active', country: 'MX' });
const companyId = $derived(companyStore.activeCompany?.id ?? null); const companyId = $derived(companyStore.activeCompany?.id ?? null);
@@ -50,43 +43,6 @@
} }
} }
function openCreate() {
editingId = null;
form = { name: '', record_type: 'cliente', status: 'active', country: 'MX' };
modalOpen = true;
}
function openEdit(a: Account) {
editingId = a.id;
form = { ...a };
modalOpen = true;
}
async function save(event: SubmitEvent) {
event.preventDefault();
if (!companyId) return;
if (!form.name?.trim()) {
toast.error('La razón social es obligatoria');
return;
}
saving = true;
try {
if (editingId) {
await accountsAPI.update(editingId, form, companyId);
toast.success('Cliente actualizado');
} else {
await accountsAPI.create(form, companyId);
toast.success('Cliente creado');
}
modalOpen = false;
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar el cliente');
} finally {
saving = false;
}
}
async function remove(a: Account) { async function remove(a: Account) {
if (!companyId) return; if (!companyId) return;
if (!confirm(`¿Eliminar "${a.name}"?`)) return; if (!confirm(`¿Eliminar "${a.name}"?`)) return;
@@ -112,7 +68,7 @@
</h1> </h1>
<p class="mt-1 text-sm text-muted-foreground">Catálogo de clientes y prospectos.</p> <p class="mt-1 text-sm text-muted-foreground">Catálogo de clientes y prospectos.</p>
</div> </div>
<Button onclick={openCreate} disabled={!companyId}> <Button href="/dashboard/crm/cuentas/nuevo" disabled={!companyId}>
<Plus class="mr-1 h-4 w-4" /> Nuevo registro <Plus class="mr-1 h-4 w-4" /> Nuevo registro
</Button> </Button>
</div> </div>
@@ -164,11 +120,8 @@
<Table.Cell>{labelOf(COMMERCIAL_CLASSIFICATION, a.commercial_classification)}</Table.Cell> <Table.Cell>{labelOf(COMMERCIAL_CLASSIFICATION, a.commercial_classification)}</Table.Cell>
<Table.Cell>{labelOf(ACCOUNT_STATUS, a.status)}</Table.Cell> <Table.Cell>{labelOf(ACCOUNT_STATUS, a.status)}</Table.Cell>
<Table.Cell class="text-right"> <Table.Cell class="text-right">
<Button variant="ghost" size="sm" href={`/dashboard/crm/cuentas/${a.id}`} aria-label="Detalle"> <Button variant="ghost" size="sm" href={`/dashboard/crm/cuentas/${a.id}`} aria-label="Abrir">
<SlidersHorizontal class="h-4 w-4" /> <ChevronRight class="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onclick={() => openEdit(a)} aria-label="Editar">
<Pencil class="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="sm" onclick={() => remove(a)} aria-label="Eliminar"> <Button variant="ghost" size="sm" onclick={() => remove(a)} aria-label="Eliminar">
<Trash2 class="h-4 w-4 text-destructive" /> <Trash2 class="h-4 w-4 text-destructive" />
@@ -183,59 +136,3 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
</div> </div>
{#if modalOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (modalOpen = false)}>
<div class="max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<h2 class="mb-4 text-lg font-semibold">{editingId ? 'Editar registro' : 'Nuevo registro'}</h2>
<form class="space-y-5" onsubmit={save}>
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Datos generales</legend>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Razón social *</span><input class={inputCls} bind:value={form.name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre comercial</span><input class={inputCls} bind:value={form.trade_name} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">RFC</span><input class="font-mono {inputCls}" maxlength="13" bind:value={form.rfc} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de registro</span><select class={inputCls} bind:value={form.record_type}>{#each RECORD_TYPES as r (r.value)}<option value={r.value}>{r.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de persona</span><select class={inputCls} bind:value={form.person_type}><option value={undefined}>—</option>{#each PERSON_TYPES as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">CURP</span><input class="font-mono {inputCls}" maxlength="18" bind:value={form.curp} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Giro / Industria</span><input class={inputCls} bind:value={form.industry} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo operativo</span><select class={inputCls} bind:value={form.account_type}><option value={undefined}>—</option>{#each ACCOUNT_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">Estatus</span><select class={inputCls} bind:value={form.status}>{#each ACCOUNT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
</fieldset>
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Información comercial</legend>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Clasificación</span><select class={inputCls} bind:value={form.commercial_classification}><option value={undefined}>—</option>{#each COMMERCIAL_CLASSIFICATION as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de contacto preferido</span><select class={inputCls} bind:value={form.preferred_contact_method}><option value={undefined}>—</option>{#each CONTACT_METHODS as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Idioma</span><input class={inputCls} bind:value={form.language} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Email</span><input type="email" class={inputCls} bind:value={form.email} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Teléfono</span><input class={inputCls} bind:value={form.phone} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Sitio web</span><input class={inputCls} bind:value={form.website} /></label>
</fieldset>
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Información fiscal</legend>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Régimen fiscal</span><input class={inputCls} bind:value={form.tax_regime} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Uso de CFDI</span><input class={inputCls} bind:value={form.cfdi_use} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><input class={inputCls} bind:value={form.payment_method} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><input class={inputCls} bind:value={form.payment_form} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Límite de crédito</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.credit_limit} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Días de crédito</span><input type="number" min="0" class={inputCls} bind:value={form.credit_days} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Patente aduanal</span><input class={inputCls} maxlength="20" bind:value={form.patente_aduanal} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Condiciones comerciales</span><textarea rows="2" class={inputCls} bind:value={form.commercial_terms}></textarea></label>
</fieldset>
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Observaciones</legend>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Comentarios</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas internas</span><textarea rows="2" class={inputCls} bind:value={form.internal_notes}></textarea></label>
</fieldset>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (modalOpen = false)}>Cancelar</Button>
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -3,18 +3,34 @@
import { page } from '$app/state'; import { page } from '$app/state';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import AccountFields from '$lib/components/crm/AccountFields.svelte';
import RelatedManager from '$lib/components/crm/RelatedManager.svelte'; import RelatedManager from '$lib/components/crm/RelatedManager.svelte';
import { companyStore } from '$lib/stores/company.svelte'; import { companyStore } from '$lib/stores/company.svelte';
import { accountsAPI, type Account } from '$lib/api/crm'; import { accountsAPI, type Account, type AccountInput } from '$lib/api/crm';
import { import { RECORD_TYPES, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format';
RECORD_TYPES, ACCOUNT_STATUS, COMMERCIAL_CLASSIFICATION, PERSON_TYPES, labelOf, formatMoney
} from '$lib/components/crm/format';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
type TabDef = { id: string; label: string; kind: 'info' | 'related'; section?: 'addresses' | 'contacts' | 'documents' };
const TABS: TabDef[] = [
{ id: 'generales', label: 'Datos generales', kind: 'info' },
{ id: 'comercial', label: 'Comercial', kind: 'info' },
{ id: 'fiscal', label: 'Fiscal', kind: 'info' },
{ id: 'observaciones', label: 'Observaciones', kind: 'info' },
{ id: 'direcciones', label: 'Direcciones', kind: 'related', section: 'addresses' },
{ id: 'contactos', label: 'Contactos', kind: 'related', section: 'contacts' },
{ id: 'documentos', label: 'Documentos', kind: 'related', section: 'documents' }
];
const accountId = $derived(Number(page.params.id)); const accountId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null); const companyId = $derived(companyStore.activeCompany?.id ?? null);
let account = $state<Account | null>(null); let account = $state<Account | null>(null);
let form = $state<AccountInput>({ name: '' });
let tab = $state('generales');
let loading = $state(false); let loading = $state(false);
let saving = $state(false);
const activeTab = $derived(TABS.find((t) => t.id === tab) ?? TABS[0]);
$effect(() => { $effect(() => {
const cid = companyId; const cid = companyId;
@@ -27,12 +43,31 @@
loading = true; loading = true;
try { try {
account = await accountsAPI.get(id, cid); account = await accountsAPI.get(id, cid);
form = { ...account };
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el cliente'); toast.error(e instanceof Error ? e.message : 'No se pudo cargar el cliente');
} finally { } finally {
loading = false; loading = false;
} }
} }
async function save() {
if (!companyId || !account) return;
if (!form.name?.trim()) {
toast.error('La razón social es obligatoria');
return;
}
saving = true;
try {
account = await accountsAPI.update(account.id, form, companyId);
form = { ...account };
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar los cambios');
} finally {
saving = false;
}
}
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
@@ -41,31 +76,42 @@
{#if loading && !account} {#if loading && !account}
<p class="text-sm text-muted-foreground">Cargando…</p> <p class="text-sm text-muted-foreground">Cargando…</p>
{:else if account} {:else if account}
<div> <div class="flex flex-wrap items-center justify-between gap-2">
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"> <div>
<Building2 class="h-6 w-6" /> <h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
{account.name} <Building2 class="h-6 w-6" />
</h1> {account.name}
{#if account.trade_name}<p class="text-sm text-muted-foreground">{account.trade_name}</p>{/if} </h1>
<p class="mt-1 text-sm text-muted-foreground">
{labelOf(RECORD_TYPES, account.record_type)} · {labelOf(ACCOUNT_STATUS, account.status)}
{#if account.rfc}· <span class="font-mono">{account.rfc}</span>{/if}
</p>
</div>
</div> </div>
<Card.Root> <Card.Root>
<Card.Header><Card.Title class="text-base">Datos generales</Card.Title></Card.Header> <Card.Content class="pt-6">
<Card.Content> <div class="mb-5 flex flex-wrap gap-1 border-b">
<dl class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3"> {#each TABS as t (t.id)}
<div><dt class="text-muted-foreground">Tipo</dt><dd>{labelOf(RECORD_TYPES, account.record_type)}</dd></div> <button
<div><dt class="text-muted-foreground">Estatus</dt><dd>{labelOf(ACCOUNT_STATUS, account.status)}</dd></div> type="button"
<div><dt class="text-muted-foreground">RFC</dt><dd class="font-mono">{account.rfc ?? '—'}</dd></div> class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
<div><dt class="text-muted-foreground">Persona</dt><dd>{labelOf(PERSON_TYPES, account.person_type)}</dd></div> onclick={() => (tab = t.id)}
<div><dt class="text-muted-foreground">Clasificación</dt><dd>{labelOf(COMMERCIAL_CLASSIFICATION, account.commercial_classification)}</dd></div> >
<div><dt class="text-muted-foreground">Giro</dt><dd>{account.industry ?? '—'}</dd></div> {t.label}
<div><dt class="text-muted-foreground">Régimen fiscal</dt><dd>{account.tax_regime ?? '—'}</dd></div> </button>
<div><dt class="text-muted-foreground">Límite de crédito</dt><dd>{formatMoney(account.credit_limit)}</dd></div> {/each}
<div><dt class="text-muted-foreground">Días de crédito</dt><dd>{account.credit_days ?? '—'}</dd></div> </div>
</dl>
{#if activeTab.kind === 'info'}
<AccountFields bind:form tab={tab} />
<div class="mt-6 flex justify-end border-t pt-4">
<Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button>
</div>
{:else if activeTab.section}
<RelatedManager ownerType="account" ownerId={account.id} section={activeTab.section} />
{/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<RelatedManager ownerType="account" ownerId={account.id} />
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { ArrowLeft, Building2 } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import AccountFields from '$lib/components/crm/AccountFields.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { accountsAPI, type AccountInput } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
const TABS = [
{ id: 'generales', label: 'Datos generales' },
{ id: 'comercial', label: 'Comercial' },
{ id: 'fiscal', label: 'Fiscal' },
{ id: 'observaciones', label: 'Observaciones' }
];
let form = $state<AccountInput>({ name: '', record_type: 'cliente', status: 'active', country: 'MX' });
let tab = $state('generales');
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
async function save() {
if (!companyId) return;
if (!form.name?.trim()) {
toast.error('La razón social es obligatoria');
tab = 'generales';
return;
}
saving = true;
try {
const created = await accountsAPI.create(form, companyId);
toast.success('Cliente creado');
await goto(`/dashboard/crm/cuentas/${created.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear el cliente');
} finally {
saving = false;
}
}
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/cuentas"><ArrowLeft class="mr-1 h-4 w-4" /> Clientes</Button>
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Building2 class="h-6 w-6" /> Nuevo cliente / prospecto
</h1>
<p class="mt-1 text-sm text-muted-foreground">Al guardar podrás agregar direcciones, contactos y documentos.</p>
</div>
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each TABS as t (t.id)}
<button
type="button"
class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (tab = t.id)}
>
{t.label}
</button>
{/each}
</div>
<AccountFields bind:form {tab} />
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/crm/cuentas">Cancelar</Button>
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear'}</Button>
</div>
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,27 +1,16 @@
<script lang="ts"> <script lang="ts">
import { Truck, Plus, Pencil, Trash2, Search, SlidersHorizontal } from '@lucide/svelte'; import { Truck, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte'; import { companyStore } from '$lib/stores/company.svelte';
import { suppliersAPI, type Supplier, type SupplierInput } from '$lib/api/crm'; import { suppliersAPI, type Supplier } from '$lib/api/crm';
import { import { SUPPLIER_CLASSIFICATIONS, COVERAGE, labelOf } from '$lib/components/crm/format';
SUPPLIER_CLASSIFICATIONS, COVERAGE, ACCOUNT_STATUS, PERSON_TYPES, labelOf
} from '$lib/components/crm/format';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
let items = $state<Supplier[]>([]); let items = $state<Supplier[]>([]);
let loading = $state(false); let loading = $state(false);
let search = $state(''); let search = $state('');
let modalOpen = $state(false);
let saving = $state(false);
let editingId = $state<number | null>(null);
let form = $state<SupplierInput>({ name: '', status: 'active', classifications: [] });
// listas separadas por coma (se convierten a arreglo al guardar)
let countriesStr = $state('');
let portsStr = $state('');
let airportsStr = $state('');
let customsStr = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null); const companyId = $derived(companyStore.activeCompany?.id ?? null);
@@ -31,9 +20,6 @@
: items : items
); );
const toArr = (s: string): string[] => s.split(',').map((t) => t.trim()).filter(Boolean);
const toStr = (a: string[] | null | undefined): string => (a ?? []).join(', ');
$effect(() => { $effect(() => {
const cid = companyId; const cid = companyId;
if (!cid) return; if (!cid) return;
@@ -51,55 +37,6 @@
} }
} }
function openCreate() {
editingId = null;
form = { name: '', status: 'active', classifications: [] };
countriesStr = portsStr = airportsStr = customsStr = '';
modalOpen = true;
}
function openEdit(s: Supplier) {
editingId = s.id;
form = { ...s, classifications: [...(s.classifications ?? [])] };
countriesStr = toStr(s.countries);
portsStr = toStr(s.ports);
airportsStr = toStr(s.airports);
customsStr = toStr(s.customs);
modalOpen = true;
}
async function save(event: SubmitEvent) {
event.preventDefault();
if (!companyId) return;
if (!form.name?.trim()) {
toast.error('La razón social es obligatoria');
return;
}
saving = true;
try {
const payload: SupplierInput = {
...form,
countries: toArr(countriesStr),
ports: toArr(portsStr),
airports: toArr(airportsStr),
customs: toArr(customsStr)
};
if (editingId) {
await suppliersAPI.update(editingId, payload, companyId);
toast.success('Proveedor actualizado');
} else {
await suppliersAPI.create(payload, companyId);
toast.success('Proveedor creado');
}
modalOpen = false;
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar el proveedor');
} finally {
saving = false;
}
}
async function remove(s: Supplier) { async function remove(s: Supplier) {
if (!companyId) return; if (!companyId) return;
if (!confirm(`¿Eliminar "${s.name}"?`)) return; if (!confirm(`¿Eliminar "${s.name}"?`)) return;
@@ -129,7 +66,7 @@
</h1> </h1>
<p class="mt-1 text-sm text-muted-foreground">Navieras, aerolíneas, transportistas, agentes y más.</p> <p class="mt-1 text-sm text-muted-foreground">Navieras, aerolíneas, transportistas, agentes y más.</p>
</div> </div>
<Button onclick={openCreate} disabled={!companyId}> <Button href="/dashboard/crm/proveedores/nuevo" disabled={!companyId}>
<Plus class="mr-1 h-4 w-4" /> Nuevo proveedor <Plus class="mr-1 h-4 w-4" /> Nuevo proveedor
</Button> </Button>
</div> </div>
@@ -169,11 +106,8 @@
<Table.Cell>{labelOf(COVERAGE, s.coverage)}</Table.Cell> <Table.Cell>{labelOf(COVERAGE, s.coverage)}</Table.Cell>
<Table.Cell class="font-mono text-xs">{s.rfc ?? '—'}</Table.Cell> <Table.Cell class="font-mono text-xs">{s.rfc ?? '—'}</Table.Cell>
<Table.Cell class="text-right"> <Table.Cell class="text-right">
<Button variant="ghost" size="sm" href={`/dashboard/crm/proveedores/${s.id}`} aria-label="Detalle"> <Button variant="ghost" size="sm" href={`/dashboard/crm/proveedores/${s.id}`} aria-label="Abrir">
<SlidersHorizontal class="h-4 w-4" /> <ChevronRight class="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onclick={() => openEdit(s)} aria-label="Editar">
<Pencil class="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="sm" onclick={() => remove(s)} aria-label="Eliminar"> <Button variant="ghost" size="sm" onclick={() => remove(s)} aria-label="Eliminar">
<Trash2 class="h-4 w-4 text-destructive" /> <Trash2 class="h-4 w-4 text-destructive" />
@@ -188,70 +122,3 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
</div> </div>
{#if modalOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (modalOpen = false)}>
<div class="max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<h2 class="mb-4 text-lg font-semibold">{editingId ? 'Editar proveedor' : 'Nuevo proveedor'}</h2>
<form class="space-y-5" onsubmit={save}>
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Datos generales</legend>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Razón social *</span><input class={inputCls} bind:value={form.name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre comercial</span><input class={inputCls} bind:value={form.trade_name} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">RFC</span><input class="font-mono {inputCls}" maxlength="13" bind:value={form.rfc} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de persona</span><select class={inputCls} bind:value={form.person_type}><option value={undefined}>—</option>{#each PERSON_TYPES as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each ACCOUNT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
</fieldset>
<fieldset>
<legend class="mb-2 text-sm font-semibold text-muted-foreground">Clasificación (una o varias)</legend>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each SUPPLIER_CLASSIFICATIONS as c (c.value)}
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" class="h-4 w-4 rounded border" value={c.value} bind:group={form.classifications} />
<span>{c.label}</span>
</label>
{/each}
</div>
</fieldset>
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Información comercial</legend>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cobertura</span><select class={inputCls} bind:value={form.coverage}><option value={undefined}>—</option>{#each COVERAGE as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda de cotización</span><input class={inputCls} maxlength="3" bind:value={form.quote_currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Países (separados por coma)</span><input class={inputCls} bind:value={countriesStr} placeholder="MX, US, PA" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puertos</span><input class={inputCls} bind:value={portsStr} placeholder="Veracruz, Manzanillo" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aeropuertos</span><input class={inputCls} bind:value={airportsStr} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aduanas</span><input class={inputCls} bind:value={customsStr} placeholder="Nuevo Laredo, Colombia" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Horario de atención</span><input class={inputCls} bind:value={form.business_hours} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tiempo prom. de respuesta</span><input class={inputCls} bind:value={form.avg_response_time} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Email</span><input type="email" class={inputCls} bind:value={form.email} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Teléfono</span><input class={inputCls} bind:value={form.phone} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Servicios que ofrece</span><textarea rows="2" class={inputCls} bind:value={form.services_offered}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Observaciones comerciales</span><textarea rows="2" class={inputCls} bind:value={form.commercial_notes}></textarea></label>
</fieldset>
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Información fiscal</legend>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Régimen fiscal</span><input class={inputCls} bind:value={form.tax_regime} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><input class={inputCls} bind:value={form.payment_method} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><input class={inputCls} bind:value={form.payment_form} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Límite de crédito</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.credit_limit} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Días de crédito</span><input type="number" min="0" class={inputCls} bind:value={form.credit_days} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Condiciones comerciales</span><textarea rows="2" class={inputCls} bind:value={form.commercial_terms}></textarea></label>
</fieldset>
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Observaciones</legend>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Comentarios</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas internas</span><textarea rows="2" class={inputCls} bind:value={form.internal_notes}></textarea></label>
</fieldset>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (modalOpen = false)}>Cancelar</Button>
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -3,16 +3,40 @@
import { page } from '$app/state'; import { page } from '$app/state';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import SupplierFields from '$lib/components/crm/SupplierFields.svelte';
import RelatedManager from '$lib/components/crm/RelatedManager.svelte'; import RelatedManager from '$lib/components/crm/RelatedManager.svelte';
import { companyStore } from '$lib/stores/company.svelte'; import { companyStore } from '$lib/stores/company.svelte';
import { suppliersAPI, type Supplier } from '$lib/api/crm'; import { suppliersAPI, type Supplier, type SupplierInput } from '$lib/api/crm';
import { SUPPLIER_CLASSIFICATIONS, COVERAGE, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format'; import { COVERAGE, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
type TabDef = { id: string; label: string; kind: 'info' | 'related'; section?: 'addresses' | 'contacts' | 'documents' };
const TABS: TabDef[] = [
{ id: 'generales', label: 'Datos generales', kind: 'info' },
{ id: 'comercial', label: 'Comercial', kind: 'info' },
{ id: 'fiscal', label: 'Fiscal', kind: 'info' },
{ id: 'observaciones', label: 'Observaciones', kind: 'info' },
{ id: 'direcciones', label: 'Direcciones', kind: 'related', section: 'addresses' },
{ id: 'contactos', label: 'Contactos', kind: 'related', section: 'contacts' },
{ id: 'documentos', label: 'Documentos', kind: 'related', section: 'documents' }
];
const supplierId = $derived(Number(page.params.id)); const supplierId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null); const companyId = $derived(companyStore.activeCompany?.id ?? null);
let supplier = $state<Supplier | null>(null); let supplier = $state<Supplier | null>(null);
let form = $state<SupplierInput>({ name: '', classifications: [] });
let countriesStr = $state('');
let portsStr = $state('');
let airportsStr = $state('');
let customsStr = $state('');
let tab = $state('generales');
let loading = $state(false); let loading = $state(false);
let saving = $state(false);
const activeTab = $derived(TABS.find((t) => t.id === tab) ?? TABS[0]);
const toArr = (s: string): string[] => s.split(',').map((t) => t.trim()).filter(Boolean);
const toStr = (a: string[] | null | undefined): string => (a ?? []).join(', ');
$effect(() => { $effect(() => {
const cid = companyId; const cid = companyId;
@@ -21,10 +45,19 @@
void load(cid, id); void load(cid, id);
}); });
function hydrate(s: Supplier) {
form = { ...s, classifications: [...(s.classifications ?? [])] };
countriesStr = toStr(s.countries);
portsStr = toStr(s.ports);
airportsStr = toStr(s.airports);
customsStr = toStr(s.customs);
}
async function load(cid: number, id: number) { async function load(cid: number, id: number) {
loading = true; loading = true;
try { try {
supplier = await suppliersAPI.get(id, cid); supplier = await suppliersAPI.get(id, cid);
hydrate(supplier);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el proveedor'); toast.error(e instanceof Error ? e.message : 'No se pudo cargar el proveedor');
} finally { } finally {
@@ -32,9 +65,30 @@
} }
} }
const classText = $derived( async function save() {
supplier ? (supplier.classifications ?? []).map((c) => labelOf(SUPPLIER_CLASSIFICATIONS, c)).join(', ') || '—' : '—' if (!companyId || !supplier) return;
); if (!form.name?.trim()) {
toast.error('La razón social es obligatoria');
return;
}
saving = true;
try {
const payload: SupplierInput = {
...form,
countries: toArr(countriesStr),
ports: toArr(portsStr),
airports: toArr(airportsStr),
customs: toArr(customsStr)
};
supplier = await suppliersAPI.update(supplier.id, payload, companyId);
hydrate(supplier);
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar los cambios');
} finally {
saving = false;
}
}
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
@@ -48,24 +102,35 @@
<Truck class="h-6 w-6" /> <Truck class="h-6 w-6" />
{supplier.name} {supplier.name}
</h1> </h1>
{#if supplier.trade_name}<p class="text-sm text-muted-foreground">{supplier.trade_name}</p>{/if} <p class="mt-1 text-sm text-muted-foreground">
{labelOf(COVERAGE, supplier.coverage)} · {labelOf(ACCOUNT_STATUS, supplier.status)}
{#if supplier.rfc}· <span class="font-mono">{supplier.rfc}</span>{/if}
</p>
</div> </div>
<Card.Root> <Card.Root>
<Card.Header><Card.Title class="text-base">Datos generales</Card.Title></Card.Header> <Card.Content class="pt-6">
<Card.Content> <div class="mb-5 flex flex-wrap gap-1 border-b">
<dl class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3"> {#each TABS as t (t.id)}
<div class="col-span-2 sm:col-span-3"><dt class="text-muted-foreground">Clasificación</dt><dd>{classText}</dd></div> <button
<div><dt class="text-muted-foreground">Cobertura</dt><dd>{labelOf(COVERAGE, supplier.coverage)}</dd></div> type="button"
<div><dt class="text-muted-foreground">Estatus</dt><dd>{labelOf(ACCOUNT_STATUS, supplier.status)}</dd></div> class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
<div><dt class="text-muted-foreground">RFC</dt><dd class="font-mono">{supplier.rfc ?? '—'}</dd></div> onclick={() => (tab = t.id)}
<div><dt class="text-muted-foreground">Países</dt><dd>{(supplier.countries ?? []).join(', ') || '—'}</dd></div> >
<div><dt class="text-muted-foreground">Puertos</dt><dd>{(supplier.ports ?? []).join(', ') || '—'}</dd></div> {t.label}
<div><dt class="text-muted-foreground">Aduanas</dt><dd>{(supplier.customs ?? []).join(', ') || '—'}</dd></div> </button>
</dl> {/each}
</div>
{#if activeTab.kind === 'info'}
<SupplierFields bind:form {tab} bind:countriesStr bind:portsStr bind:airportsStr bind:customsStr />
<div class="mt-6 flex justify-end border-t pt-4">
<Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button>
</div>
{:else if activeTab.section}
<RelatedManager ownerType="supplier" ownerId={supplier.id} section={activeTab.section} />
{/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<RelatedManager ownerType="supplier" ownerId={supplier.id} />
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { ArrowLeft, Truck } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import SupplierFields from '$lib/components/crm/SupplierFields.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { suppliersAPI, type SupplierInput } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
const TABS = [
{ id: 'generales', label: 'Datos generales' },
{ id: 'comercial', label: 'Comercial' },
{ id: 'fiscal', label: 'Fiscal' },
{ id: 'observaciones', label: 'Observaciones' }
];
let form = $state<SupplierInput>({ name: '', status: 'active', classifications: [] });
let countriesStr = $state('');
let portsStr = $state('');
let airportsStr = $state('');
let customsStr = $state('');
let tab = $state('generales');
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const toArr = (s: string): string[] => s.split(',').map((t) => t.trim()).filter(Boolean);
async function save() {
if (!companyId) return;
if (!form.name?.trim()) {
toast.error('La razón social es obligatoria');
tab = 'generales';
return;
}
saving = true;
try {
const payload: SupplierInput = {
...form,
countries: toArr(countriesStr),
ports: toArr(portsStr),
airports: toArr(airportsStr),
customs: toArr(customsStr)
};
const created = await suppliersAPI.create(payload, companyId);
toast.success('Proveedor creado');
await goto(`/dashboard/crm/proveedores/${created.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear el proveedor');
} finally {
saving = false;
}
}
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/proveedores"><ArrowLeft class="mr-1 h-4 w-4" /> Proveedores</Button>
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Truck class="h-6 w-6" /> Nuevo proveedor
</h1>
<p class="mt-1 text-sm text-muted-foreground">Al guardar podrás agregar direcciones, contactos y documentos.</p>
</div>
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each TABS as t (t.id)}
<button
type="button"
class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (tab = t.id)}
>
{t.label}
</button>
{/each}
</div>
<SupplierFields bind:form {tab} bind:countriesStr bind:portsStr bind:airportsStr bind:customsStr />
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/crm/proveedores">Cancelar</Button>
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear'}</Button>
</div>
</Card.Content>
</Card.Root>
</div>