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

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

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

View File

@@ -0,0 +1,36 @@
/**
* Cliente API — Direcciones CRM (de un cliente o proveedor)
*/
import { api } from '$lib/api';
import type { Address, AddressInput } from './types';
export const addressesAPI = {
async list(
companyId: number,
params?: { account_id?: number; supplier_id?: number }
): Promise<Address[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.account_id) qs.set('account_id', String(params.account_id));
if (params?.supplier_id) qs.set('supplier_id', String(params.supplier_id));
const res = await api.get<Address[]>(`/v1/crm/addresses?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: AddressInput, companyId: number): Promise<Address> {
const res = await api.post<Address>(`/v1/crm/addresses?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<AddressInput>, companyId: number): Promise<Address> {
const res = await api.patch<Address>(`/v1/crm/addresses/${id}?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async remove(id: number, companyId: number): Promise<void> {
const res = await api.delete(`/v1/crm/addresses/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -0,0 +1,36 @@
/**
* Cliente API — Documentos CRM (de un cliente o proveedor)
*/
import { api } from '$lib/api';
import type { Document, DocumentInput } from './types';
export const documentsAPI = {
async list(
companyId: number,
params?: { account_id?: number; supplier_id?: number }
): Promise<Document[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.account_id) qs.set('account_id', String(params.account_id));
if (params?.supplier_id) qs.set('supplier_id', String(params.supplier_id));
const res = await api.get<Document[]>(`/v1/crm/documents?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: DocumentInput, companyId: number): Promise<Document> {
const res = await api.post<Document>(`/v1/crm/documents?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<DocumentInput>, companyId: number): Promise<Document> {
const res = await api.patch<Document>(`/v1/crm/documents/${id}?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async remove(id: number, companyId: number): Promise<void> {
const res = await api.delete(`/v1/crm/documents/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -3,7 +3,10 @@
*/
export * from './types';
export { accountsAPI } from './accounts';
export { suppliersAPI } from './suppliers';
export { contactsAPI } from './contacts';
export { addressesAPI } from './addresses';
export { documentsAPI } from './documents';
export { leadsAPI } from './leads';
export { pipelinesAPI, stagesAPI, type StageInput } from './pipelines';
export { opportunitiesAPI } from './opportunities';

View File

@@ -0,0 +1,39 @@
/**
* Cliente API — Proveedores CRM
*/
import { api } from '$lib/api';
import type { Supplier, SupplierInput } from './types';
export const suppliersAPI = {
async list(companyId: number, params?: { search?: string; status?: string }): Promise<Supplier[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.search) qs.set('search', params.search);
if (params?.status) qs.set('status', params.status);
const res = await api.get<Supplier[]>(`/v1/crm/suppliers?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async get(id: number, companyId: number): Promise<Supplier> {
const res = await api.get<Supplier>(`/v1/crm/suppliers/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: SupplierInput, companyId: number): Promise<Supplier> {
const res = await api.post<Supplier>(`/v1/crm/suppliers?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<SupplierInput>, companyId: number): Promise<Supplier> {
const res = await api.patch<Supplier>(`/v1/crm/suppliers/${id}?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async remove(id: number, companyId: number): Promise<void> {
const res = await api.delete(`/v1/crm/suppliers/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -2,51 +2,124 @@
* Tipos del módulo CRM — reflejan los DTOs del backend (api/v1/modules/crm).
*/
export type AccountStatus = 'active' | 'inactive' | 'prospect';
export type AccountStatus = 'active' | 'inactive';
export type RecordType = 'cliente' | 'prospecto';
export type PersonType = 'fisica' | 'moral';
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'unqualified' | 'converted';
export type OpportunityStatus = 'open' | 'won' | 'lost';
export type ActivityType = 'call' | 'meeting' | 'task' | 'email' | 'note';
export type ActivityStatus = 'pending' | 'completed' | 'canceled';
// ---------- Clientes / Prospectos ----------
export interface Account {
id: number;
name: string;
trade_name: string | null;
rfc: string | null;
account_type: string | null;
curp: string | null;
record_type: RecordType;
person_type: string | null;
industry: string | null;
account_type: string | null;
status: AccountStatus;
commercial_classification: string | null;
preferred_contact_method: string | null;
language: string | null;
email: string | null;
phone: string | null;
website: string | null;
tax_regime: string | null;
cfdi_use: string | null;
payment_method: string | null;
payment_form: string | null;
currency: string | null;
credit_limit: number | null;
credit_days: number | null;
commercial_terms: string | null;
patente_aduanal: string | null;
address: string | null;
city: string | null;
state: string | null;
country: string | null;
patente_aduanal: string | null;
status: AccountStatus;
owner_user_id: string | null;
notes: string | null;
internal_notes: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type AccountInput = Partial<Omit<Account, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
export type AccountInput = Partial<Omit<Account, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>> & {
name: string;
};
// ---------- Proveedores ----------
export interface Supplier {
id: number;
name: string;
trade_name: string | null;
rfc: string | null;
curp: string | null;
person_type: string | null;
status: AccountStatus;
classifications: string[];
services_offered: string | null;
coverage: string | null;
countries: string[];
ports: string[];
airports: string[];
customs: string[];
business_hours: string | null;
quote_currency: string | null;
avg_response_time: string | null;
commercial_notes: string | null;
email: string | null;
phone: string | null;
website: string | null;
tax_regime: string | null;
payment_method: string | null;
payment_form: string | null;
credit_limit: number | null;
credit_days: number | null;
commercial_terms: string | null;
notes: string | null;
internal_notes: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type SupplierInput = Partial<Omit<Supplier, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>> & {
name: string;
};
// ---------- Contactos ----------
export interface Contact {
id: number;
account_id: number | null;
supplier_id: number | null;
first_name: string;
last_name: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
job_title: string | null;
department: string | null;
area: string | null;
email: string | null;
phone: string | null;
extension: string | null;
mobile: string | null;
whatsapp: string | null;
is_primary: boolean;
receives_quotes: boolean;
receives_invoices: boolean;
receives_commercial_info: boolean;
status: string;
owner_user_id: string | null;
notes: string | null;
tenant_id: number;
@@ -59,6 +132,54 @@ export type ContactInput = Partial<Omit<Contact, 'id' | 'tenant_id' | 'company_i
first_name: string;
};
// ---------- Direcciones ----------
export interface Address {
id: number;
account_id: number | null;
supplier_id: number | null;
address_type: string;
street: string | null;
ext_number: string | null;
int_number: string | null;
neighborhood: string | null;
postal_code: string | null;
city: string | null;
state: string | null;
country: string | null;
reference_notes: string | null;
is_primary: boolean;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type AddressInput = Partial<Omit<Address, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>>;
// ---------- Documentos ----------
export interface Document {
id: number;
account_id: number | null;
supplier_id: number | null;
doc_type: string;
name: string;
file_key: string | null;
file_url: string | null;
content_type: string | null;
size_bytes: number | null;
uploaded_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type DocumentInput = Partial<Omit<Document, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'uploaded_by'>> & {
doc_type: string;
name: string;
};
// ---------- Prospectos (leads / funnel) ----------
export interface Lead {
id: number;
name: string;
@@ -99,6 +220,7 @@ export interface LeadConvertResult {
opportunity_id: number | null;
}
// ---------- Embudo / Oportunidades ----------
export interface Pipeline {
id: number;
name: string;

View File

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

View File

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

View File

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

View File

@@ -1,31 +1,36 @@
<script lang="ts">
import { Building2, Plus, Pencil, Trash2, Search } from '@lucide/svelte';
import { Building2, Plus, Pencil, Trash2, Search, SlidersHorizontal } 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, labelOf } from '$lib/components/crm/format';
import {
ACCOUNT_TYPES, ACCOUNT_STATUS, RECORD_TYPES, PERSON_TYPES, COMMERCIAL_CLASSIFICATION,
CONTACT_METHODS, 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: '', status: 'active', country: 'MX' });
let form = $state<AccountInput>({ name: '', record_type: 'cliente', status: 'active', country: 'MX' });
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const filtered = $derived(
search.trim()
? items.filter((a) =>
`${a.name} ${a.trade_name ?? ''} ${a.rfc ?? ''}`
.toLowerCase()
.includes(search.trim().toLowerCase())
)
: items
items.filter((a) => {
if (recordFilter && a.record_type !== recordFilter) return false;
if (search.trim()) {
const q = search.trim().toLowerCase();
return `${a.name} ${a.trade_name ?? ''} ${a.rfc ?? ''}`.toLowerCase().includes(q);
}
return true;
})
);
$effect(() => {
@@ -39,7 +44,7 @@
try {
items = await accountsAPI.list(cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las cuentas');
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los clientes');
} finally {
loading = false;
}
@@ -47,7 +52,7 @@
function openCreate() {
editingId = null;
form = { name: '', status: 'active', country: 'MX' };
form = { name: '', record_type: 'cliente', status: 'active', country: 'MX' };
modalOpen = true;
}
@@ -61,22 +66,22 @@
event.preventDefault();
if (!companyId) return;
if (!form.name?.trim()) {
toast.error('El nombre es obligatorio');
toast.error('La razón social es obligatoria');
return;
}
saving = true;
try {
if (editingId) {
await accountsAPI.update(editingId, form, companyId);
toast.success('Cuenta actualizada');
toast.success('Cliente actualizado');
} else {
await accountsAPI.create(form, companyId);
toast.success('Cuenta creada');
toast.success('Cliente creado');
}
modalOpen = false;
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar la cuenta');
toast.error(e instanceof Error ? e.message : 'No se pudo guardar el cliente');
} finally {
saving = false;
}
@@ -84,21 +89,18 @@
async function remove(a: Account) {
if (!companyId) return;
if (!confirm(`¿Eliminar la cuenta "${a.name}"?`)) return;
if (!confirm(`¿Eliminar "${a.name}"?`)) return;
try {
await accountsAPI.remove(a.id, companyId);
toast.success('Cuenta eliminada');
toast.success('Cliente eliminado');
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar la cuenta');
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
}
}
const statusClass: Record<string, string> = {
active: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
prospect: 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400',
inactive: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
};
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="space-y-6">
@@ -106,41 +108,43 @@
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Building2 class="h-6 w-6" />
Cuentas
Clientes / Prospectos
</h1>
<p class="mt-1 text-sm text-muted-foreground">Empresas cliente y prospectos.</p>
<p class="mt-1 text-sm text-muted-foreground">Catálogo de clientes y prospectos.</p>
</div>
<Button onclick={openCreate} disabled={!companyId}>
<Plus class="mr-1 h-4 w-4" /> Nueva cuenta
<Plus class="mr-1 h-4 w-4" /> Nuevo registro
</Button>
</div>
<Card.Root>
<Card.Header>
<div class="relative max-w-sm">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input
class="w-full rounded-md border bg-transparent py-2 pl-8 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Buscar por nombre o RFC…"
bind:value={search}
/>
<div class="flex flex-wrap items-center gap-3">
<div class="relative max-w-sm flex-1">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por nombre o RFC…" bind:value={search} />
</div>
<select class={inputCls} bind:value={recordFilter}>
<option value="">Todos</option>
{#each RECORD_TYPES as r (r.value)}<option value={r.value}>{r.label}</option>{/each}
</select>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin cuentas registradas.</p>
<p class="py-6 text-center text-sm text-muted-foreground">Sin registros.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Nombre</Table.Head>
<Table.Head>RFC</Table.Head>
<Table.Head>Razón social</Table.Head>
<Table.Head>Tipo</Table.Head>
<Table.Head>Estado</Table.Head>
<Table.Head>Teléfono</Table.Head>
<Table.Head>RFC</Table.Head>
<Table.Head>Clasificación</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
@@ -148,18 +152,21 @@
{#each filtered as a (a.id)}
<Table.Row>
<Table.Cell class="font-medium">
{a.name}
<a class="hover:underline" href={`/dashboard/crm/cuentas/${a.id}`}>{a.name}</a>
{#if a.trade_name}<span class="block text-xs text-muted-foreground">{a.trade_name}</span>{/if}
</Table.Cell>
<Table.Cell class="font-mono text-xs">{a.rfc ?? '—'}</Table.Cell>
<Table.Cell>{labelOf(ACCOUNT_TYPES, a.account_type)}</Table.Cell>
<Table.Cell>
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[a.status] ?? ''}">
{labelOf(ACCOUNT_STATUS, a.status)}
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {a.record_type === 'cliente' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400'}">
{labelOf(RECORD_TYPES, a.record_type)}
</span>
</Table.Cell>
<Table.Cell>{a.phone ?? '—'}</Table.Cell>
<Table.Cell class="font-mono text-xs">{a.rfc ?? '—'}</Table.Cell>
<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>
@@ -178,69 +185,53 @@
</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-[90vh] w-full max-w-2xl 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 cuenta' : 'Nueva cuenta'}</h2>
<form class="grid gap-4 sm:grid-cols-2" onsubmit={save}>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Razón social *</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.name} required />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Nombre comercial</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.trade_name} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">RFC</span>
<input class="rounded-md border bg-transparent px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" maxlength="13" bind:value={form.rfc} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Tipo</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" 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">Estado</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.status}>
{#each ACCOUNT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Patente aduanal</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" maxlength="20" bind:value={form.patente_aduanal} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Email</span>
<input type="email" class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.email} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Teléfono</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.phone} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Ciudad</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.city} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Estado (entidad)</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.state} />
</label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Notas</span>
<textarea rows="3" class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.notes}></textarea>
</label>
<div class="flex justify-end gap-2 sm:col-span-2">
<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>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import { ArrowLeft, Building2 } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
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 { toast } from 'svelte-sonner';
const accountId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let account = $state<Account | null>(null);
let loading = $state(false);
$effect(() => {
const cid = companyId;
const id = accountId;
if (!cid || !id) return;
void load(cid, id);
});
async function load(cid: number, id: number) {
loading = true;
try {
account = await accountsAPI.get(id, cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el cliente');
} finally {
loading = 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>
{#if loading && !account}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if account}
<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}
</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>
</Card.Root>
<RelatedManager ownerType="account" ownerId={account.id} />
{/if}
</div>

View File

@@ -0,0 +1,257 @@
<script lang="ts">
import { Truck, Plus, Pencil, Trash2, Search, SlidersHorizontal } 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 { 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);
const filtered = $derived(
search.trim()
? items.filter((s) => `${s.name} ${s.trade_name ?? ''} ${s.rfc ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()))
: 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;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
items = await suppliersAPI.list(cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los proveedores');
} finally {
loading = false;
}
}
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;
try {
await suppliersAPI.remove(s.id, companyId);
toast.success('Proveedor eliminado');
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
}
}
function classNames(s: Supplier): string {
return (s.classifications ?? []).map((c) => labelOf(SUPPLIER_CLASSIFICATIONS, c)).join(', ') || '—';
}
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="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Truck class="h-6 w-6" />
Proveedores
</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}>
<Plus class="mr-1 h-4 w-4" /> Nuevo proveedor
</Button>
</div>
<Card.Root>
<Card.Header>
<div class="relative max-w-sm">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por nombre o RFC…" bind:value={search} />
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin proveedores registrados.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Razón social</Table.Head>
<Table.Head>Clasificación</Table.Head>
<Table.Head>Cobertura</Table.Head>
<Table.Head>RFC</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as s (s.id)}
<Table.Row>
<Table.Cell class="font-medium">
<a class="hover:underline" href={`/dashboard/crm/proveedores/${s.id}`}>{s.name}</a>
{#if s.trade_name}<span class="block text-xs text-muted-foreground">{s.trade_name}</span>{/if}
</Table.Cell>
<Table.Cell class="text-xs">{classNames(s)}</Table.Cell>
<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>
<Button variant="ghost" size="sm" onclick={() => remove(s)} aria-label="Eliminar">
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</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}

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import { ArrowLeft, Truck } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
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 { toast } from 'svelte-sonner';
const supplierId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let supplier = $state<Supplier | null>(null);
let loading = $state(false);
$effect(() => {
const cid = companyId;
const id = supplierId;
if (!cid || !id) return;
void load(cid, id);
});
async function load(cid: number, id: number) {
loading = true;
try {
supplier = await suppliersAPI.get(id, cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el proveedor');
} finally {
loading = false;
}
}
const classText = $derived(
supplier ? (supplier.classifications ?? []).map((c) => labelOf(SUPPLIER_CLASSIFICATIONS, c)).join(', ') || '—' : '—'
);
</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>
{#if loading && !supplier}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if supplier}
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<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}
</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>
</Card.Root>
<RelatedManager ownerType="supplier" ownerId={supplier.id} />
{/if}
</div>