feat(crm): frontend del CRM (clientes API, navegación, dashboard + Kanban) y rebrand

- Clientes API tipados por entidad en src/lib/api/crm
- Navegación CRM en el sidebar
- Páginas: panel (KPIs + embudo), cuentas, contactos, prospectos, actividades
- Kanban de oportunidades con drag & drop (mueve entre etapas) y siembra de embudo
- Rebrand plantilla → CRM (.env, docker-compose name, package.json, README)
- Fix: CORE_DB_HOST=postgres (coincide con el servicio del compose)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-14 09:53:56 -06:00
parent 088a8fc4df
commit af02145332
22 changed files with 2081 additions and 124 deletions

View File

@@ -0,0 +1,42 @@
/**
* Cliente API — Cuentas CRM
*/
import { api } from '$lib/api';
import type { Account, AccountInput } from './types';
export const accountsAPI = {
async list(
companyId: number,
params?: { search?: string; status?: string }
): Promise<Account[]> {
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<Account[]>(`/v1/crm/accounts?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async get(id: number, companyId: number): Promise<Account> {
const res = await api.get<Account>(`/v1/crm/accounts/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: AccountInput, companyId: number): Promise<Account> {
const res = await api.post<Account>(`/v1/crm/accounts?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<AccountInput>, companyId: number): Promise<Account> {
const res = await api.patch<Account>(`/v1/crm/accounts/${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/accounts/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -0,0 +1,62 @@
/**
* Cliente API — Actividades CRM
*/
import { api } from '$lib/api';
import type { Activity, ActivityInput } from './types';
export const activitiesAPI = {
async list(
companyId: number,
params?: {
activity_type?: string;
status?: string;
account_id?: number;
contact_id?: number;
lead_id?: number;
opportunity_id?: number;
}
): Promise<Activity[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.activity_type) qs.set('activity_type', params.activity_type);
if (params?.status) qs.set('status', params.status);
if (params?.account_id) qs.set('account_id', String(params.account_id));
if (params?.contact_id) qs.set('contact_id', String(params.contact_id));
if (params?.lead_id) qs.set('lead_id', String(params.lead_id));
if (params?.opportunity_id) qs.set('opportunity_id', String(params.opportunity_id));
const res = await api.get<Activity[]>(`/v1/crm/activities?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async get(id: number, companyId: number): Promise<Activity> {
const res = await api.get<Activity>(`/v1/crm/activities/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: ActivityInput, companyId: number): Promise<Activity> {
const res = await api.post<Activity>(`/v1/crm/activities?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<ActivityInput>, companyId: number): Promise<Activity> {
const res = await api.patch<Activity>(`/v1/crm/activities/${id}?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async complete(id: number, companyId: number): Promise<Activity> {
const res = await api.patch<Activity>(
`/v1/crm/activities/${id}/complete?company_id=${companyId}`,
{}
);
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/activities/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -0,0 +1,42 @@
/**
* Cliente API — Contactos CRM
*/
import { api } from '$lib/api';
import type { Contact, ContactInput } from './types';
export const contactsAPI = {
async list(
companyId: number,
params?: { search?: string; account_id?: number }
): Promise<Contact[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.search) qs.set('search', params.search);
if (params?.account_id) qs.set('account_id', String(params.account_id));
const res = await api.get<Contact[]>(`/v1/crm/contacts?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async get(id: number, companyId: number): Promise<Contact> {
const res = await api.get<Contact>(`/v1/crm/contacts/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: ContactInput, companyId: number): Promise<Contact> {
const res = await api.post<Contact>(`/v1/crm/contacts?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<ContactInput>, companyId: number): Promise<Contact> {
const res = await api.patch<Contact>(`/v1/crm/contacts/${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/contacts/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -0,0 +1,11 @@
/**
* Punto de entrada del cliente API del CRM.
*/
export * from './types';
export { accountsAPI } from './accounts';
export { contactsAPI } from './contacts';
export { leadsAPI } from './leads';
export { pipelinesAPI, stagesAPI, type StageInput } from './pipelines';
export { opportunitiesAPI } from './opportunities';
export { activitiesAPI } from './activities';
export { metricsAPI } from './metrics';

View File

@@ -0,0 +1,48 @@
/**
* Cliente API — Prospectos CRM
*/
import { api } from '$lib/api';
import type { Lead, LeadConvertInput, LeadConvertResult, LeadInput } from './types';
export const leadsAPI = {
async list(companyId: number, params?: { search?: string; status?: string }): Promise<Lead[]> {
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<Lead[]>(`/v1/crm/leads?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async get(id: number, companyId: number): Promise<Lead> {
const res = await api.get<Lead>(`/v1/crm/leads/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: LeadInput, companyId: number): Promise<Lead> {
const res = await api.post<Lead>(`/v1/crm/leads?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<LeadInput>, companyId: number): Promise<Lead> {
const res = await api.patch<Lead>(`/v1/crm/leads/${id}?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async convert(id: number, data: LeadConvertInput, companyId: number): Promise<LeadConvertResult> {
const res = await api.post<LeadConvertResult>(
`/v1/crm/leads/${id}/convert?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/leads/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -0,0 +1,15 @@
/**
* Cliente API — Métricas CRM (dashboard)
*/
import { api } from '$lib/api';
import type { CrmMetrics } from './types';
export const metricsAPI = {
async get(companyId: number, pipelineId?: number): Promise<CrmMetrics> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (pipelineId) qs.set('pipeline_id', String(pipelineId));
const res = await api.get<CrmMetrics>(`/v1/crm/metrics?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
}
};

View File

@@ -0,0 +1,57 @@
/**
* Cliente API — Oportunidades CRM
*/
import { api } from '$lib/api';
import type { Opportunity, OpportunityInput } from './types';
export const opportunitiesAPI = {
async list(
companyId: number,
params?: { pipeline_id?: number; stage_id?: number; status?: string; search?: string }
): Promise<Opportunity[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.pipeline_id) qs.set('pipeline_id', String(params.pipeline_id));
if (params?.stage_id) qs.set('stage_id', String(params.stage_id));
if (params?.status) qs.set('status', params.status);
if (params?.search) qs.set('search', params.search);
const res = await api.get<Opportunity[]>(`/v1/crm/opportunities?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async get(id: number, companyId: number): Promise<Opportunity> {
const res = await api.get<Opportunity>(`/v1/crm/opportunities/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: OpportunityInput, companyId: number): Promise<Opportunity> {
const res = await api.post<Opportunity>(`/v1/crm/opportunities?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<OpportunityInput>, companyId: number): Promise<Opportunity> {
const res = await api.patch<Opportunity>(
`/v1/crm/opportunities/${id}?company_id=${companyId}`,
data
);
if (res.error) throw new Error(res.error);
return res.data!;
},
/** Mueve la oportunidad a otra etapa (drag & drop del Kanban). */
async move(id: number, stageId: number, companyId: number): Promise<Opportunity> {
const res = await api.patch<Opportunity>(
`/v1/crm/opportunities/${id}/move?company_id=${companyId}`,
{ stage_id: stageId }
);
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/opportunities/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -0,0 +1,70 @@
/**
* Cliente API — Embudos y etapas CRM
*/
import { api } from '$lib/api';
import type { Pipeline, Stage } from './types';
export const pipelinesAPI = {
async list(companyId: number): Promise<Pipeline[]> {
const res = await api.get<Pipeline[]>(`/v1/crm/pipelines?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: { name: string; is_default?: boolean }, companyId: number): Promise<Pipeline> {
const res = await api.post<Pipeline>(`/v1/crm/pipelines?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(
id: number,
data: { name?: string; is_default?: boolean },
companyId: number
): Promise<Pipeline> {
const res = await api.patch<Pipeline>(`/v1/crm/pipelines/${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/pipelines/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};
export interface StageInput {
pipeline_id: number;
name: string;
position?: number;
probability?: number;
is_won?: boolean;
is_lost?: boolean;
}
export const stagesAPI = {
async list(companyId: number, pipelineId?: number): Promise<Stage[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (pipelineId) qs.set('pipeline_id', String(pipelineId));
const res = await api.get<Stage[]>(`/v1/crm/stages?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: StageInput, companyId: number): Promise<Stage> {
const res = await api.post<Stage>(`/v1/crm/stages?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<StageInput>, companyId: number): Promise<Stage> {
const res = await api.patch<Stage>(`/v1/crm/stages/${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/stages/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -0,0 +1,196 @@
/**
* Tipos del módulo CRM — reflejan los DTOs del backend (api/v1/modules/crm).
*/
export type AccountStatus = 'active' | 'inactive' | 'prospect';
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';
export interface Account {
id: number;
name: string;
trade_name: string | null;
rfc: string | null;
account_type: string | null;
industry: string | null;
email: string | null;
phone: string | null;
website: 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;
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'>> & {
name: string;
};
export interface Contact {
id: number;
account_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;
is_primary: boolean;
owner_user_id: string | null;
notes: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type ContactInput = Partial<Omit<Contact, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
first_name: string;
};
export interface Lead {
id: number;
name: string;
contact_name: string | null;
email: string | null;
phone: string | null;
company_name: string | null;
source: string | null;
status: LeadStatus;
estimated_value: number | null;
owner_user_id: string | null;
converted_account_id: number | null;
converted_contact_id: number | null;
converted_opportunity_id: number | null;
notes: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type LeadInput = Partial<Omit<Lead, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'converted_account_id' | 'converted_contact_id' | 'converted_opportunity_id'>> & {
name: string;
};
export interface LeadConvertInput {
create_opportunity?: boolean;
opportunity_name?: string | null;
pipeline_id?: number | null;
stage_id?: number | null;
amount?: number | null;
}
export interface LeadConvertResult {
lead: Lead;
account_id: number;
contact_id: number | null;
opportunity_id: number | null;
}
export interface Pipeline {
id: number;
name: string;
is_default: boolean;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export interface Stage {
id: number;
pipeline_id: number;
name: string;
position: number;
probability: number;
is_won: boolean;
is_lost: boolean;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export interface Opportunity {
id: number;
name: string;
account_id: number | null;
contact_id: number | null;
pipeline_id: number | null;
stage_id: number | null;
amount: number | null;
currency: string;
probability: number | null;
status: OpportunityStatus;
expected_close_date: string | null;
closed_at: string | null;
lost_reason: string | null;
source: string | null;
owner_user_id: string | null;
notes: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type OpportunityInput = Partial<Omit<Opportunity, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'closed_at'>> & {
name: string;
};
export interface Activity {
id: number;
activity_type: ActivityType;
subject: string;
description: string | null;
status: ActivityStatus;
due_date: string | null;
completed_at: string | null;
account_id: number | null;
contact_id: number | null;
lead_id: number | null;
opportunity_id: number | null;
owner_user_id: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type ActivityInput = Partial<Omit<Activity, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'completed_at'>> & {
activity_type: ActivityType;
subject: string;
};
export interface StageMetric {
stage_id: number;
stage_name: string;
position: number;
count: number;
value: number;
}
export interface CrmMetrics {
total_accounts: number;
total_contacts: number;
total_leads: number;
open_leads: number;
open_opportunities: number;
open_pipeline_value: number;
won_opportunities: number;
won_value: number;
pending_activities: number;
by_stage: StageMetric[];
}

View File

@@ -0,0 +1,66 @@
/**
* Utilidades de formato y etiquetas legibles para el CRM.
*/
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));
}
export function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' });
}
export const ACCOUNT_TYPES: { value: string; label: string }[] = [
{ value: 'importador', label: 'Importador' },
{ value: 'exportador', label: 'Exportador' },
{ value: 'immex', label: 'IMMEX / Maquila' },
{ value: 'agencia_aduanal', label: 'Agencia aduanal' },
{ value: 'transportista', label: 'Transportista' },
{ 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 LEAD_SOURCES: { value: string; label: string }[] = [
{ value: 'web', label: 'Web' },
{ value: 'referido', label: 'Referido' },
{ value: 'evento', label: 'Evento' },
{ value: 'llamada', label: 'Llamada' },
{ value: 'email', label: 'Email' },
{ value: 'otro', label: 'Otro' }
];
export const LEAD_STATUS: { value: string; label: string }[] = [
{ value: 'new', label: 'Nuevo' },
{ value: 'contacted', label: 'Contactado' },
{ value: 'qualified', label: 'Calificado' },
{ value: 'unqualified', label: 'No calificado' },
{ value: 'converted', label: 'Convertido' }
];
export const ACTIVITY_TYPES: { value: string; label: string }[] = [
{ value: 'call', label: 'Llamada' },
{ value: 'meeting', label: 'Reunión' },
{ value: 'task', label: 'Tarea' },
{ value: 'email', label: 'Correo' },
{ value: 'note', label: 'Nota' }
];
export const ACTIVITY_STATUS: { value: string; label: string }[] = [
{ value: 'pending', label: 'Pendiente' },
{ value: 'completed', label: 'Completada' },
{ value: 'canceled', label: 'Cancelada' }
];
export function labelOf(list: { value: string; label: string }[], value: string | null): string {
if (!value) return '—';
return list.find((x) => x.value === value)?.label ?? value;
}

View File

@@ -3,6 +3,7 @@ import {
Settings2,
Users,
Shield,
Briefcase,
} from '@lucide/svelte';
export type SystemContext = 'fixed_asset' | 'inventory';
@@ -34,6 +35,19 @@ export function getNavMain(): NavMainItem[] {
url: '/dashboard',
icon: LayoutDashboard,
},
{
title: 'CRM',
url: '/dashboard/crm',
icon: Briefcase,
items: [
{ title: 'Panel', url: '/dashboard/crm' },
{ title: 'Cuentas', url: '/dashboard/crm/cuentas' },
{ title: 'Contactos', url: '/dashboard/crm/contactos' },
{ title: 'Prospectos', url: '/dashboard/crm/prospectos' },
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
{ title: 'Actividades', url: '/dashboard/crm/actividades' },
],
},
{
title: 'Usuarios',
url: '/dashboard/users',

View File

@@ -174,7 +174,7 @@
<Sidebar.Group>
<Sidebar.GroupLabel class="flex items-center gap-2">
<span>Anexo-76</span>
<span>CRM</span>
{#if $permissionsRefreshing}
<span
class="size-1.5 shrink-0 animate-pulse rounded-full bg-primary"