- 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>
272 lines
15 KiB
Svelte
272 lines
15 KiB
Svelte
<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 y qué sección mostrar
|
|
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 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">
|
|
{#if show('addresses')}
|
|
<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>
|
|
{/if}
|
|
|
|
{#if show('contacts')}
|
|
<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>
|
|
{/if}
|
|
|
|
{#if show('documents')}
|
|
<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>
|
|
{/if}
|
|
</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" tabindex="-1" 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}
|