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:
42
frontend/src/lib/api/crm/accounts.ts
Normal file
42
frontend/src/lib/api/crm/accounts.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
62
frontend/src/lib/api/crm/activities.ts
Normal file
62
frontend/src/lib/api/crm/activities.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
42
frontend/src/lib/api/crm/contacts.ts
Normal file
42
frontend/src/lib/api/crm/contacts.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
11
frontend/src/lib/api/crm/index.ts
Normal file
11
frontend/src/lib/api/crm/index.ts
Normal 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';
|
||||
48
frontend/src/lib/api/crm/leads.ts
Normal file
48
frontend/src/lib/api/crm/leads.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
15
frontend/src/lib/api/crm/metrics.ts
Normal file
15
frontend/src/lib/api/crm/metrics.ts
Normal 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!;
|
||||
}
|
||||
};
|
||||
57
frontend/src/lib/api/crm/opportunities.ts
Normal file
57
frontend/src/lib/api/crm/opportunities.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
70
frontend/src/lib/api/crm/pipelines.ts
Normal file
70
frontend/src/lib/api/crm/pipelines.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
196
frontend/src/lib/api/crm/types.ts
Normal file
196
frontend/src/lib/api/crm/types.ts
Normal 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[];
|
||||
}
|
||||
66
frontend/src/lib/components/crm/format.ts
Normal file
66
frontend/src/lib/components/crm/format.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user