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:
53
frontend/src/lib/components/crm/AccountFields.svelte
Normal file
53
frontend/src/lib/components/crm/AccountFields.svelte
Normal 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}
|
||||
@@ -11,8 +11,18 @@
|
||||
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();
|
||||
// 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[]>([]);
|
||||
@@ -120,7 +130,7 @@
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<!-- Direcciones -->
|
||||
{#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>
|
||||
@@ -147,8 +157,9 @@
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Contactos -->
|
||||
{#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>
|
||||
@@ -175,8 +186,9 @@
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Documentos -->
|
||||
{#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>
|
||||
@@ -202,11 +214,12 @@
|
||||
{/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" 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'}
|
||||
<h3 class="mb-4 text-base font-semibold">Nueva dirección</h3>
|
||||
<form class="grid gap-3 sm:grid-cols-2" onsubmit={saveAddress}>
|
||||
|
||||
71
frontend/src/lib/components/crm/SupplierFields.svelte
Normal file
71
frontend/src/lib/components/crm/SupplierFields.svelte
Normal 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}
|
||||
@@ -1,24 +1,17 @@
|
||||
<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 Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { accountsAPI, type Account, type AccountInput } from '$lib/api/crm';
|
||||
import {
|
||||
ACCOUNT_TYPES, ACCOUNT_STATUS, RECORD_TYPES, PERSON_TYPES, COMMERCIAL_CLASSIFICATION,
|
||||
CONTACT_METHODS, labelOf
|
||||
} from '$lib/components/crm/format';
|
||||
import { accountsAPI, type Account } from '$lib/api/crm';
|
||||
import { ACCOUNT_STATUS, RECORD_TYPES, COMMERCIAL_CLASSIFICATION, labelOf } from '$lib/components/crm/format';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let items = $state<Account[]>([]);
|
||||
let loading = $state(false);
|
||||
let search = $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);
|
||||
|
||||
@@ -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) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Eliminar "${a.name}"?`)) return;
|
||||
@@ -112,7 +68,7 @@
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Catálogo de clientes y prospectos.</p>
|
||||
</div>
|
||||
<Button onclick={openCreate} disabled={!companyId}>
|
||||
<Button href="/dashboard/crm/cuentas/nuevo" disabled={!companyId}>
|
||||
<Plus class="mr-1 h-4 w-4" /> Nuevo registro
|
||||
</Button>
|
||||
</div>
|
||||
@@ -164,11 +120,8 @@
|
||||
<Table.Cell>{labelOf(COMMERCIAL_CLASSIFICATION, a.commercial_classification)}</Table.Cell>
|
||||
<Table.Cell>{labelOf(ACCOUNT_STATUS, a.status)}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<Button variant="ghost" size="sm" href={`/dashboard/crm/cuentas/${a.id}`} aria-label="Detalle">
|
||||
<SlidersHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => openEdit(a)} aria-label="Editar">
|
||||
<Pencil class="h-4 w-4" />
|
||||
<Button variant="ghost" size="sm" href={`/dashboard/crm/cuentas/${a.id}`} aria-label="Abrir">
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => remove(a)} aria-label="Eliminar">
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
@@ -183,59 +136,3 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</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}
|
||||
|
||||
@@ -3,18 +3,34 @@
|
||||
import { page } from '$app/state';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import AccountFields from '$lib/components/crm/AccountFields.svelte';
|
||||
import RelatedManager from '$lib/components/crm/RelatedManager.svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { accountsAPI, type Account } from '$lib/api/crm';
|
||||
import {
|
||||
RECORD_TYPES, ACCOUNT_STATUS, COMMERCIAL_CLASSIFICATION, PERSON_TYPES, labelOf, formatMoney
|
||||
} from '$lib/components/crm/format';
|
||||
import { accountsAPI, type Account, type AccountInput } from '$lib/api/crm';
|
||||
import { RECORD_TYPES, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format';
|
||||
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 companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
let account = $state<Account | null>(null);
|
||||
let form = $state<AccountInput>({ name: '' });
|
||||
let tab = $state('generales');
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
const activeTab = $derived(TABS.find((t) => t.id === tab) ?? TABS[0]);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
@@ -27,12 +43,31 @@
|
||||
loading = true;
|
||||
try {
|
||||
account = await accountsAPI.get(id, cid);
|
||||
form = { ...account };
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el cliente');
|
||||
} finally {
|
||||
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>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -41,31 +76,42 @@
|
||||
{#if loading && !account}
|
||||
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if account}
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Building2 class="h-6 w-6" />
|
||||
{account.name}
|
||||
</h1>
|
||||
{#if account.trade_name}<p class="text-sm text-muted-foreground">{account.trade_name}</p>{/if}
|
||||
<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>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header><Card.Title class="text-base">Datos generales</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
|
||||
<div><dt class="text-muted-foreground">Tipo</dt><dd>{labelOf(RECORD_TYPES, account.record_type)}</dd></div>
|
||||
<div><dt class="text-muted-foreground">Estatus</dt><dd>{labelOf(ACCOUNT_STATUS, account.status)}</dd></div>
|
||||
<div><dt class="text-muted-foreground">RFC</dt><dd class="font-mono">{account.rfc ?? '—'}</dd></div>
|
||||
<div><dt class="text-muted-foreground">Persona</dt><dd>{labelOf(PERSON_TYPES, account.person_type)}</dd></div>
|
||||
<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>
|
||||
<div><dt class="text-muted-foreground">Régimen fiscal</dt><dd>{account.tax_regime ?? '—'}</dd></div>
|
||||
<div><dt class="text-muted-foreground">Límite de crédito</dt><dd>{formatMoney(account.credit_limit)}</dd></div>
|
||||
<div><dt class="text-muted-foreground">Días de crédito</dt><dd>{account.credit_days ?? '—'}</dd></div>
|
||||
</dl>
|
||||
<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>
|
||||
|
||||
{#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.Root>
|
||||
|
||||
<RelatedManager ownerType="account" ownerId={account.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
76
frontend/src/routes/dashboard/crm/cuentas/nuevo/+page.svelte
Normal file
76
frontend/src/routes/dashboard/crm/cuentas/nuevo/+page.svelte
Normal 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>
|
||||
@@ -1,27 +1,16 @@
|
||||
<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 Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { suppliersAPI, type Supplier, type SupplierInput } from '$lib/api/crm';
|
||||
import {
|
||||
SUPPLIER_CLASSIFICATIONS, COVERAGE, ACCOUNT_STATUS, PERSON_TYPES, labelOf
|
||||
} from '$lib/components/crm/format';
|
||||
import { suppliersAPI, type Supplier } from '$lib/api/crm';
|
||||
import { SUPPLIER_CLASSIFICATIONS, COVERAGE, labelOf } from '$lib/components/crm/format';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let items = $state<Supplier[]>([]);
|
||||
let loading = $state(false);
|
||||
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);
|
||||
|
||||
@@ -31,9 +20,6 @@
|
||||
: items
|
||||
);
|
||||
|
||||
const toArr = (s: string): string[] => s.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
const toStr = (a: string[] | null | undefined): string => (a ?? []).join(', ');
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
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) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Eliminar "${s.name}"?`)) return;
|
||||
@@ -129,7 +66,7 @@
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Navieras, aerolíneas, transportistas, agentes y más.</p>
|
||||
</div>
|
||||
<Button onclick={openCreate} disabled={!companyId}>
|
||||
<Button href="/dashboard/crm/proveedores/nuevo" disabled={!companyId}>
|
||||
<Plus class="mr-1 h-4 w-4" /> Nuevo proveedor
|
||||
</Button>
|
||||
</div>
|
||||
@@ -169,11 +106,8 @@
|
||||
<Table.Cell>{labelOf(COVERAGE, s.coverage)}</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">{s.rfc ?? '—'}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<Button variant="ghost" size="sm" href={`/dashboard/crm/proveedores/${s.id}`} aria-label="Detalle">
|
||||
<SlidersHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => openEdit(s)} aria-label="Editar">
|
||||
<Pencil class="h-4 w-4" />
|
||||
<Button variant="ghost" size="sm" href={`/dashboard/crm/proveedores/${s.id}`} aria-label="Abrir">
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => remove(s)} aria-label="Eliminar">
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
@@ -188,70 +122,3 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</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}
|
||||
|
||||
@@ -3,16 +3,40 @@
|
||||
import { page } from '$app/state';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import SupplierFields from '$lib/components/crm/SupplierFields.svelte';
|
||||
import RelatedManager from '$lib/components/crm/RelatedManager.svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { suppliersAPI, type Supplier } from '$lib/api/crm';
|
||||
import { SUPPLIER_CLASSIFICATIONS, COVERAGE, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format';
|
||||
import { suppliersAPI, type Supplier, type SupplierInput } from '$lib/api/crm';
|
||||
import { COVERAGE, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format';
|
||||
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 companyId = $derived(companyStore.activeCompany?.id ?? 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 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(() => {
|
||||
const cid = companyId;
|
||||
@@ -21,10 +45,19 @@
|
||||
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) {
|
||||
loading = true;
|
||||
try {
|
||||
supplier = await suppliersAPI.get(id, cid);
|
||||
hydrate(supplier);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el proveedor');
|
||||
} finally {
|
||||
@@ -32,9 +65,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
const classText = $derived(
|
||||
supplier ? (supplier.classifications ?? []).map((c) => labelOf(SUPPLIER_CLASSIFICATIONS, c)).join(', ') || '—' : '—'
|
||||
);
|
||||
async function save() {
|
||||
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>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -48,24 +102,35 @@
|
||||
<Truck class="h-6 w-6" />
|
||||
{supplier.name}
|
||||
</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>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header><Card.Title class="text-base">Datos generales</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
|
||||
<div class="col-span-2 sm:col-span-3"><dt class="text-muted-foreground">Clasificación</dt><dd>{classText}</dd></div>
|
||||
<div><dt class="text-muted-foreground">Cobertura</dt><dd>{labelOf(COVERAGE, supplier.coverage)}</dd></div>
|
||||
<div><dt class="text-muted-foreground">Estatus</dt><dd>{labelOf(ACCOUNT_STATUS, supplier.status)}</dd></div>
|
||||
<div><dt class="text-muted-foreground">RFC</dt><dd class="font-mono">{supplier.rfc ?? '—'}</dd></div>
|
||||
<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>
|
||||
<div><dt class="text-muted-foreground">Aduanas</dt><dd>{(supplier.customs ?? []).join(', ') || '—'}</dd></div>
|
||||
</dl>
|
||||
<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>
|
||||
|
||||
{#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.Root>
|
||||
|
||||
<RelatedManager ownerType="supplier" ownerId={supplier.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user