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:
36
frontend/src/lib/api/crm/addresses.ts
Normal file
36
frontend/src/lib/api/crm/addresses.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
36
frontend/src/lib/api/crm/documents.ts
Normal file
36
frontend/src/lib/api/crm/documents.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
39
frontend/src/lib/api/crm/suppliers.ts
Normal file
39
frontend/src/lib/api/crm/suppliers.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
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' },
|
||||
],
|
||||
|
||||
@@ -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">
|
||||
<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 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}
|
||||
/>
|
||||
<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>
|
||||
|
||||
71
frontend/src/routes/dashboard/crm/cuentas/[id]/+page.svelte
Normal file
71
frontend/src/routes/dashboard/crm/cuentas/[id]/+page.svelte
Normal 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>
|
||||
257
frontend/src/routes/dashboard/crm/proveedores/+page.svelte
Normal file
257
frontend/src/routes/dashboard/crm/proveedores/+page.svelte
Normal 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}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user