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:
258
frontend/src/lib/components/crm/RelatedManager.svelte
Normal file
258
frontend/src/lib/components/crm/RelatedManager.svelte
Normal 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}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user