feat(crm): formularios Clientes/Prospectos y Proveedores por catálogo — T2026-07-081/082

Frontend de los catálogos de referencia en BD:
- Store crm-catalogs + cliente API; selects poblados desde /v1/crm/catalogs
  (fiscal: régimen, uso CFDI, forma/método de pago SAT, moneda ISO; comercial;
  país/estado/tipo de domicilio/área). Muestran la descripción, no la clave.
- "Otro → especificar" (medio de contacto y clasificación), observaciones
  generales, y datos de auditoría (ID, fechas, usuarios) en la vista de edición.
- Botón "Editar" explícito en los listados.
- Pantalla de administración de catálogos (insertar/editar/borrar): globales
  solo activar/desactivar; los de la empresa con alta/edición/borrado.
- addresses.country ampliado a 3 (país ISO alfa-3) + migración con down().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-22 10:31:59 -06:00
parent a2a474f8c8
commit de8557dec2
16 changed files with 1057 additions and 2179 deletions

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import { Database, Plus, Trash2, Pencil, Check, X, Lock } 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 { referenceCatalogsAPI, type CatalogItem, type CatalogMeta } from '$lib/api/crm/catalogs';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let metas = $state<CatalogMeta[]>([]);
let selected = $state<CatalogMeta | null>(null);
let items = $state<CatalogItem[]>([]);
let loading = $state(false);
let newCode = $state('');
let newLabel = $state('');
let adding = $state(false);
let editId = $state<number | null>(null);
let editLabel = $state('');
let editActive = $state(true);
const inputCls =
'rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
$effect(() => {
const cid = companyId;
if (cid) void loadMetas(cid);
});
async function loadMetas(cid: number) {
try {
metas = await referenceCatalogsAPI.meta(cid);
if (!selected && metas.length) void selectCatalog(metas[0]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los catálogos');
}
}
async function selectCatalog(m: CatalogMeta) {
selected = m;
editId = null;
newCode = '';
newLabel = '';
if (!companyId) return;
loading = true;
try {
items = await referenceCatalogsAPI.listAll(m.catalog, companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las opciones');
} finally {
loading = false;
}
}
async function addItem() {
if (!companyId || !selected) return;
const code = newCode.trim();
const label = newLabel.trim();
if (!code || !label) {
toast.error('Clave y descripción son obligatorias');
return;
}
adding = true;
try {
const res = await referenceCatalogsAPI.create(
selected.catalog,
companyId,
{ code, label },
selected.scope
);
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Opción agregada');
newCode = '';
newLabel = '';
crmCatalogs.invalidate(selected.catalog);
await selectCatalog(selected);
} finally {
adding = false;
}
}
function startEdit(it: CatalogItem) {
editId = it.id;
editLabel = it.label;
editActive = it.is_active;
}
async function saveEdit(it: CatalogItem) {
if (!companyId || !selected) return;
const res = await referenceCatalogsAPI.update(selected.catalog, it.id, companyId, {
label: editLabel.trim() || it.label,
is_active: editActive
});
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Opción actualizada');
editId = null;
crmCatalogs.invalidate(selected.catalog);
await selectCatalog(selected);
}
async function del(it: CatalogItem) {
if (!companyId || !selected) return;
if (!confirm(`¿Eliminar la opción "${it.label}"?`)) return;
const res = await referenceCatalogsAPI.remove(selected.catalog, it.id, companyId);
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Opción eliminada');
crmCatalogs.invalidate(selected.catalog);
await selectCatalog(selected);
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Database class="h-6 w-6" /> Catálogos</h1>
<p class="mt-1 text-sm text-muted-foreground">
Administra las opciones de los catálogos del CRM. Los catálogos base del sistema (SAT/ISO) solo se pueden activar/desactivar; los de tu empresa se pueden insertar, editar y borrar.
</p>
</div>
{#if !companyId}
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
{:else}
<div class="grid gap-6 lg:grid-cols-[260px_1fr]">
<Card.Root>
<Card.Header><Card.Title class="text-base">Catálogos</Card.Title></Card.Header>
<Card.Content class="space-y-1">
{#each metas as m (m.catalog)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm {selected?.catalog === m.catalog ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}"
onclick={() => selectCatalog(m)}
>
<span class="flex items-center gap-1">{#if m.is_system}<Lock class="h-3 w-3 opacity-60" />{/if}{m.label}</span>
<span class="text-xs text-muted-foreground">{m.count}</span>
</button>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">{selected?.label ?? 'Opciones'}</Card.Title>
<Card.Description>
{#if selected}
{selected.scope === 'global' ? 'Catálogo global (Aduanasoft)' : 'Catálogo de tu empresa'} · {selected.count} opciones
{#if selected.is_system} · base del sistema (no se borra){/if}
{/if}
</Card.Description>
</Card.Header>
<Card.Content>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Clave</span><input class="font-mono {inputCls}" bind:value={newCode} placeholder="clave" /></label>
<label class="flex flex-1 flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newLabel} placeholder="Descripción visible" /></label>
<Button size="sm" onclick={addItem} disabled={adding}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</div>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if items.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin opciones. Agrega la primera arriba.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Clave</Table.Head><Table.Head>Descripción</Table.Head><Table.Head>Origen</Table.Head><Table.Head>Activo</Table.Head><Table.Head class="text-right">Acciones</Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each items as it (it.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{it.code}</Table.Cell>
<Table.Cell>
{#if editId === it.id}
<input class="{inputCls} w-full" bind:value={editLabel} />
{:else}
{it.label}
{/if}
</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{it.tenant_id === null ? 'Global' : 'Empresa'}{#if it.is_system} · base{/if}</Table.Cell>
<Table.Cell>
{#if editId === it.id}
<input type="checkbox" class="h-4 w-4 rounded border" bind:checked={editActive} />
{:else}
<span class="inline-flex rounded-full px-2 py-0.5 text-xs {it.is_active ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}">{it.is_active ? 'Sí' : 'No'}</span>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
{#if editId === it.id}
<Button variant="ghost" size="sm" onclick={() => saveEdit(it)} aria-label="Guardar"><Check class="h-4 w-4 text-emerald-600" /></Button>
<Button variant="ghost" size="sm" onclick={() => (editId = null)} aria-label="Cancelar"><X class="h-4 w-4" /></Button>
{:else}
<Button variant="ghost" size="sm" onclick={() => startEdit(it)} aria-label="Editar"><Pencil class="h-4 w-4" /></Button>
{#if !it.is_system}
<Button variant="ghost" size="sm" onclick={() => del(it)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
{/if}
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
{/if}
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Building2, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import { Building2, Plus, Trash2, Search, Pencil } 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';
@@ -120,8 +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="Abrir">
<ChevronRight class="h-4 w-4" />
<Button variant="ghost" size="sm" href={`/dashboard/crm/cuentas/${a.id}`}>
<Pencil class="mr-1 h-4 w-4" /> Editar
</Button>
<Button variant="ghost" size="sm" onclick={() => remove(a)} aria-label="Eliminar">
<Trash2 class="h-4 w-4 text-destructive" />

View File

@@ -7,7 +7,7 @@
import RelatedManager from '$lib/components/crm/RelatedManager.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { accountsAPI, type Account, type AccountInput } from '$lib/api/crm';
import { RECORD_TYPES, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format';
import { RECORD_TYPES, ACCOUNT_STATUS, labelOf, formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
type TabDef = { id: string; label: string; kind: 'info' | 'related'; section?: 'addresses' | 'contacts' | 'documents' };
@@ -86,6 +86,10 @@
{labelOf(RECORD_TYPES, account.record_type)} · {labelOf(ACCOUNT_STATUS, account.status)}
{#if account.rfc}· <span class="font-mono">{account.rfc}</span>{/if}
</p>
<p class="mt-1 text-xs text-muted-foreground">
ID #{account.id} · Creado {formatDate(account.created_at)}{#if account.created_by} por <span class="font-mono">{account.created_by}</span>{/if}
· Últ. actualización {formatDate(account.updated_at)}{#if account.updated_by} por <span class="font-mono">{account.updated_by}</span>{/if}
</p>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Truck, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import { Truck, Plus, Trash2, Search, Pencil } 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';
@@ -106,8 +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="Abrir">
<ChevronRight class="h-4 w-4" />
<Button variant="ghost" size="sm" href={`/dashboard/crm/proveedores/${s.id}`}>
<Pencil class="mr-1 h-4 w-4" /> Editar
</Button>
<Button variant="ghost" size="sm" onclick={() => remove(s)} aria-label="Eliminar">
<Trash2 class="h-4 w-4 text-destructive" />

View File

@@ -7,7 +7,7 @@
import RelatedManager from '$lib/components/crm/RelatedManager.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { suppliersAPI, type Supplier, type SupplierInput } from '$lib/api/crm';
import { COVERAGE, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format';
import { COVERAGE, ACCOUNT_STATUS, labelOf, formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
type TabDef = { id: string; label: string; kind: 'info' | 'related'; section?: 'addresses' | 'contacts' | 'documents' };
@@ -104,7 +104,7 @@
</h1>
<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}
{#if supplier.rfc}· <span class="font-mono">{supplier.rfc}</span>{/if}<br /><span class="text-xs">ID #{supplier.id} · Alta {formatDate(supplier.created_at)}{#if supplier.created_by} por <span class="font-mono">{supplier.created_by}</span>{/if} · Últ. modificación {formatDate(supplier.updated_at)}{#if supplier.updated_by} por <span class="font-mono">{supplier.updated_by}</span>{/if}</span>
</p>
</div>