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:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"name": "crm-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
|
||||
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"
|
||||
|
||||
113
frontend/src/routes/dashboard/crm/+page.svelte
Normal file
113
frontend/src/routes/dashboard/crm/+page.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { Briefcase, Building2, Users, UserPlus, Target, CalendarClock } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import KpiCard from '$lib/components/dashboard/kpi-card.svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { metricsAPI, type CrmMetrics } from '$lib/api/crm';
|
||||
import { formatMoney } from '$lib/components/crm/format';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let metrics = $state<CrmMetrics | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
void load(cid);
|
||||
});
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
metrics = await metricsAPI.get(cid);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las métricas');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const maxStageValue = $derived(
|
||||
metrics && metrics.by_stage.length
|
||||
? Math.max(1, ...metrics.by_stage.map((s) => Number(s.value)))
|
||||
: 1
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Briefcase class="h-6 w-6" />
|
||||
CRM
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Panel comercial: cuentas, prospectos y pipeline de ventas.</p>
|
||||
</div>
|
||||
|
||||
{#if !companyId}
|
||||
<Card.Root>
|
||||
<Card.Content class="py-8 text-center text-sm text-muted-foreground">
|
||||
Selecciona una compañía para ver las métricas del CRM.
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if metrics}
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<KpiCard metric={{ label: 'Cuentas', value: metrics.total_accounts }} icon={Building2} iconColor="text-blue-600" />
|
||||
<KpiCard metric={{ label: 'Contactos', value: metrics.total_contacts }} icon={Users} iconColor="text-cyan-600" />
|
||||
<KpiCard metric={{ label: 'Prospectos abiertos', value: metrics.open_leads }} icon={UserPlus} iconColor="text-purple-600" />
|
||||
<KpiCard metric={{ label: 'Oportunidades abiertas', value: metrics.open_opportunities }} icon={Target} iconColor="text-orange-600" />
|
||||
<KpiCard metric={{ label: 'Actividades pendientes', value: metrics.pending_activities }} icon={CalendarClock} iconColor="text-yellow-600" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Description>Valor del pipeline abierto</Card.Description>
|
||||
<Card.Title class="text-2xl">{formatMoney(metrics.open_pipeline_value)}</Card.Title>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Description>Ganado ({metrics.won_opportunities} oportunidades)</Card.Description>
|
||||
<Card.Title class="text-2xl text-emerald-600">{formatMoney(metrics.won_value)}</Card.Title>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Embudo de ventas</Card.Title>
|
||||
<Card.Description>Oportunidades abiertas por etapa</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if metrics.by_stage.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Aún no hay etapas configuradas. Crea un embudo desde Oportunidades.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each metrics.by_stage as stage (stage.stage_id)}
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="font-medium">{stage.stage_name}</span>
|
||||
<span class="text-muted-foreground">
|
||||
{stage.count} · {formatMoney(stage.value)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all"
|
||||
style="width: {Math.round((Number(stage.value) / maxStageValue) * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if loading}
|
||||
<p class="text-sm text-muted-foreground">Cargando métricas…</p>
|
||||
{/if}
|
||||
</div>
|
||||
218
frontend/src/routes/dashboard/crm/actividades/+page.svelte
Normal file
218
frontend/src/routes/dashboard/crm/actividades/+page.svelte
Normal file
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { CalendarClock, Plus, Pencil, Trash2, Check } 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 { activitiesAPI, type Activity, type ActivityInput } from '$lib/api/crm';
|
||||
import { ACTIVITY_TYPES, ACTIVITY_STATUS, labelOf, formatDate } from '$lib/components/crm/format';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let items = $state<Activity[]>([]);
|
||||
let loading = $state(false);
|
||||
let statusFilter = $state('');
|
||||
let modalOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let editingId = $state<number | null>(null);
|
||||
let form = $state<ActivityInput>({ activity_type: 'task', subject: '', status: 'pending' });
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
void load(cid);
|
||||
});
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
items = await activitiesAPI.list(cid, statusFilter ? { status: statusFilter } : undefined);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las actividades');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId = null;
|
||||
form = { activity_type: 'task', subject: '', status: 'pending' };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(a: Activity) {
|
||||
editingId = a.id;
|
||||
form = { ...a };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!companyId) return;
|
||||
if (!form.subject?.trim()) {
|
||||
toast.error('El asunto es obligatorio');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const payload = { ...form, due_date: form.due_date || null };
|
||||
if (editingId) {
|
||||
await activitiesAPI.update(editingId, payload, companyId);
|
||||
toast.success('Actividad actualizada');
|
||||
} else {
|
||||
await activitiesAPI.create(payload, companyId);
|
||||
toast.success('Actividad creada');
|
||||
}
|
||||
modalOpen = false;
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo guardar la actividad');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function complete(a: Activity) {
|
||||
if (!companyId) return;
|
||||
try {
|
||||
await activitiesAPI.complete(a.id, companyId);
|
||||
toast.success('Actividad completada');
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo completar la actividad');
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(a: Activity) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Eliminar la actividad "${a.subject}"?`)) return;
|
||||
try {
|
||||
await activitiesAPI.remove(a.id, companyId);
|
||||
toast.success('Actividad eliminada');
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar la actividad');
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400',
|
||||
completed: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
|
||||
canceled: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
|
||||
};
|
||||
</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">
|
||||
<CalendarClock class="h-6 w-6" />
|
||||
Actividades
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Llamadas, reuniones, tareas y notas.</p>
|
||||
</div>
|
||||
<Button onclick={openCreate} disabled={!companyId}>
|
||||
<Plus class="mr-1 h-4 w-4" /> Nueva actividad
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<select
|
||||
class="max-w-xs rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
bind:value={statusFilter}
|
||||
onchange={() => companyId && load(companyId)}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{#each ACTIVITY_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
||||
</select>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Sin actividades registradas.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Asunto</Table.Head>
|
||||
<Table.Head>Tipo</Table.Head>
|
||||
<Table.Head>Estado</Table.Head>
|
||||
<Table.Head>Vence</Table.Head>
|
||||
<Table.Head class="text-right">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items as a (a.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{a.subject}</Table.Cell>
|
||||
<Table.Cell>{labelOf(ACTIVITY_TYPES, a.activity_type)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[a.status] ?? ''}">
|
||||
{labelOf(ACTIVITY_STATUS, a.status)}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDate(a.due_date)}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if a.status === 'pending'}
|
||||
<Button variant="ghost" size="sm" onclick={() => complete(a)} aria-label="Completar" title="Marcar como completada">
|
||||
<Check class="h-4 w-4 text-emerald-600" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={() => openEdit(a)} aria-label="Editar">
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => remove(a)} 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-[90vh] w-full max-w-xl 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 actividad' : 'Nueva actividad'}</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">Asunto *</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.subject} required />
|
||||
</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.activity_type}>
|
||||
{#each ACTIVITY_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 ACTIVITY_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Fecha límite</span>
|
||||
<input type="datetime-local" 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.due_date} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Descripción</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.description}></textarea>
|
||||
</label>
|
||||
<div class="flex justify-end gap-2 sm:col-span-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}
|
||||
230
frontend/src/routes/dashboard/crm/contactos/+page.svelte
Normal file
230
frontend/src/routes/dashboard/crm/contactos/+page.svelte
Normal file
@@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { Contact as ContactIcon, Plus, Pencil, Trash2, Search } 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 { contactsAPI, accountsAPI, type Contact, type ContactInput, type Account } from '$lib/api/crm';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let items = $state<Contact[]>([]);
|
||||
let accounts = $state<Account[]>([]);
|
||||
let loading = $state(false);
|
||||
let search = $state('');
|
||||
let modalOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let editingId = $state<number | null>(null);
|
||||
let form = $state<ContactInput>({ first_name: '', is_primary: false });
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
function accountName(id: number | null): string {
|
||||
return accounts.find((a) => a.id === id)?.name ?? '—';
|
||||
}
|
||||
|
||||
const filtered = $derived(
|
||||
search.trim()
|
||||
? items.filter((c) =>
|
||||
`${c.first_name} ${c.last_name ?? ''} ${c.email ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase())
|
||||
)
|
||||
: items
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
void load(cid);
|
||||
});
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
[items, accounts] = await Promise.all([contactsAPI.list(cid), accountsAPI.list(cid)]);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los contactos');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId = null;
|
||||
form = { first_name: '', is_primary: false };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(c: Contact) {
|
||||
editingId = c.id;
|
||||
form = { ...c };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!companyId) return;
|
||||
if (!form.first_name?.trim()) {
|
||||
toast.error('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const payload = { ...form, account_id: form.account_id ? Number(form.account_id) : null };
|
||||
if (editingId) {
|
||||
await contactsAPI.update(editingId, payload, companyId);
|
||||
toast.success('Contacto actualizado');
|
||||
} else {
|
||||
await contactsAPI.create(payload, companyId);
|
||||
toast.success('Contacto creado');
|
||||
}
|
||||
modalOpen = false;
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo guardar el contacto');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(c: Contact) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Eliminar el contacto "${c.first_name} ${c.last_name ?? ''}"?`)) return;
|
||||
try {
|
||||
await contactsAPI.remove(c.id, companyId);
|
||||
toast.success('Contacto eliminado');
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar el contacto');
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<ContactIcon class="h-6 w-6" />
|
||||
Contactos
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Personas asociadas a tus cuentas.</p>
|
||||
</div>
|
||||
<Button onclick={openCreate} disabled={!companyId}>
|
||||
<Plus class="mr-1 h-4 w-4" /> Nuevo contacto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="relative max-w-sm">
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
class="w-full rounded-md border bg-transparent py-2 pl-8 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="Buscar por nombre o email…"
|
||||
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 contactos registrados.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Nombre</Table.Head>
|
||||
<Table.Head>Cuenta</Table.Head>
|
||||
<Table.Head>Puesto</Table.Head>
|
||||
<Table.Head>Email</Table.Head>
|
||||
<Table.Head>Teléfono</Table.Head>
|
||||
<Table.Head class="text-right">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filtered 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 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">Principal</span>{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{accountName(c.account_id)}</Table.Cell>
|
||||
<Table.Cell>{c.job_title ?? '—'}</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={() => openEdit(c)} aria-label="Editar">
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => remove(c)} 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-[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 contacto' : 'Nuevo contacto'}</h2>
|
||||
<form class="grid gap-4 sm:grid-cols-2" onsubmit={save}>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Nombre *</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.first_name} required />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Apellidos</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.last_name} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Cuenta</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_id}>
|
||||
<option value={null}>— Sin cuenta —</option>
|
||||
{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Puesto</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.job_title} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Departamento</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.department} />
|
||||
</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">Móvil</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.mobile} />
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.is_primary} />
|
||||
<span class="font-medium">Contacto principal</span>
|
||||
</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">
|
||||
<Button type="button" variant="outline" onclick={() => (modalOpen = false)}>Cancelar</Button>
|
||||
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
250
frontend/src/routes/dashboard/crm/cuentas/+page.svelte
Normal file
250
frontend/src/routes/dashboard/crm/cuentas/+page.svelte
Normal file
@@ -0,0 +1,250 @@
|
||||
<script lang="ts">
|
||||
import { Building2, Plus, Pencil, Trash2, Search } 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 { toast } from 'svelte-sonner';
|
||||
|
||||
let items = $state<Account[]>([]);
|
||||
let loading = $state(false);
|
||||
let search = $state('');
|
||||
let modalOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let editingId = $state<number | null>(null);
|
||||
let form = $state<AccountInput>({ name: '', 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
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
void load(cid);
|
||||
});
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
items = await accountsAPI.list(cid);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las cuentas');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId = null;
|
||||
form = { name: '', status: 'active', country: 'MX' };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(a: Account) {
|
||||
editingId = a.id;
|
||||
form = { ...a };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!companyId) return;
|
||||
if (!form.name?.trim()) {
|
||||
toast.error('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (editingId) {
|
||||
await accountsAPI.update(editingId, form, companyId);
|
||||
toast.success('Cuenta actualizada');
|
||||
} else {
|
||||
await accountsAPI.create(form, companyId);
|
||||
toast.success('Cuenta creada');
|
||||
}
|
||||
modalOpen = false;
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo guardar la cuenta');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(a: Account) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Eliminar la cuenta "${a.name}"?`)) return;
|
||||
try {
|
||||
await accountsAPI.remove(a.id, companyId);
|
||||
toast.success('Cuenta eliminada');
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar la cuenta');
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
};
|
||||
</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">
|
||||
<Building2 class="h-6 w-6" />
|
||||
Cuentas
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Empresas cliente y prospectos.</p>
|
||||
</div>
|
||||
<Button onclick={openCreate} disabled={!companyId}>
|
||||
<Plus class="mr-1 h-4 w-4" /> Nueva cuenta
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="relative max-w-sm">
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
class="w-full rounded-md border bg-transparent py-2 pl-8 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="Buscar por nombre o RFC…"
|
||||
bind:value={search}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Nombre</Table.Head>
|
||||
<Table.Head>RFC</Table.Head>
|
||||
<Table.Head>Tipo</Table.Head>
|
||||
<Table.Head>Estado</Table.Head>
|
||||
<Table.Head>Teléfono</Table.Head>
|
||||
<Table.Head class="text-right">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filtered as a (a.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
{a.name}
|
||||
{#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>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{a.phone ?? '—'}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<Button variant="ghost" size="sm" onclick={() => openEdit(a)} aria-label="Editar">
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => remove(a)} 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-[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">
|
||||
<Button type="button" variant="outline" onclick={() => (modalOpen = false)}>Cancelar</Button>
|
||||
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
299
frontend/src/routes/dashboard/crm/oportunidades/+page.svelte
Normal file
299
frontend/src/routes/dashboard/crm/oportunidades/+page.svelte
Normal file
@@ -0,0 +1,299 @@
|
||||
<script lang="ts">
|
||||
import { Target, Plus } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import {
|
||||
opportunitiesAPI,
|
||||
pipelinesAPI,
|
||||
stagesAPI,
|
||||
accountsAPI,
|
||||
type Opportunity,
|
||||
type OpportunityInput,
|
||||
type Pipeline,
|
||||
type Stage,
|
||||
type Account
|
||||
} from '$lib/api/crm';
|
||||
import { formatMoney } from '$lib/components/crm/format';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let pipelines = $state<Pipeline[]>([]);
|
||||
let stages = $state<Stage[]>([]);
|
||||
let opps = $state<Opportunity[]>([]);
|
||||
let accounts = $state<Account[]>([]);
|
||||
let selectedPipelineId = $state<number | null>(null);
|
||||
let loading = $state(false);
|
||||
let busy = $state(false);
|
||||
let draggingId = $state<number | null>(null);
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let form = $state<OpportunityInput>({ name: '' });
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
const currentStages = $derived(
|
||||
stages
|
||||
.filter((s) => s.pipeline_id === selectedPipelineId)
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
);
|
||||
|
||||
function oppsForStage(stageId: number): Opportunity[] {
|
||||
return opps.filter((o) => o.stage_id === stageId);
|
||||
}
|
||||
|
||||
function stageTotal(stageId: number): number {
|
||||
return oppsForStage(stageId).reduce((sum, o) => sum + Number(o.amount ?? 0), 0);
|
||||
}
|
||||
|
||||
function accountName(id: number | null): string {
|
||||
return accounts.find((a) => a.id === id)?.name ?? '';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
void load(cid);
|
||||
});
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
const [pl, st, ac] = await Promise.all([
|
||||
pipelinesAPI.list(cid),
|
||||
stagesAPI.list(cid),
|
||||
accountsAPI.list(cid)
|
||||
]);
|
||||
pipelines = pl;
|
||||
stages = st;
|
||||
accounts = ac;
|
||||
if (!selectedPipelineId || !pl.some((p) => p.id === selectedPipelineId)) {
|
||||
selectedPipelineId = pl.find((p) => p.is_default)?.id ?? pl[0]?.id ?? null;
|
||||
}
|
||||
opps = selectedPipelineId
|
||||
? await opportunitiesAPI.list(cid, { pipeline_id: selectedPipelineId })
|
||||
: [];
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el pipeline');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPipelineChange() {
|
||||
if (companyId) await load(companyId);
|
||||
}
|
||||
|
||||
async function seedPipeline() {
|
||||
if (!companyId) return;
|
||||
busy = true;
|
||||
try {
|
||||
const p = await pipelinesAPI.create(
|
||||
{ name: 'Ventas', is_default: pipelines.length === 0 },
|
||||
companyId
|
||||
);
|
||||
const defs: [string, number, boolean, boolean][] = [
|
||||
['Prospecto', 10, false, false],
|
||||
['Contactado', 25, false, false],
|
||||
['Propuesta', 50, false, false],
|
||||
['Negociación', 75, false, false],
|
||||
['Ganada', 100, true, false],
|
||||
['Perdida', 0, false, true]
|
||||
];
|
||||
let position = 0;
|
||||
for (const [name, probability, is_won, is_lost] of defs) {
|
||||
await stagesAPI.create(
|
||||
{ pipeline_id: p.id, name, position: position++, probability, is_won, is_lost },
|
||||
companyId
|
||||
);
|
||||
}
|
||||
selectedPipelineId = p.id;
|
||||
await load(companyId);
|
||||
toast.success('Embudo de ejemplo creado');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo crear el embudo');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form = {
|
||||
name: '',
|
||||
pipeline_id: selectedPipelineId ?? undefined,
|
||||
stage_id: currentStages[0]?.id
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!companyId) return;
|
||||
if (!form.name?.trim()) {
|
||||
toast.error('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
await opportunitiesAPI.create(
|
||||
{ ...form, pipeline_id: selectedPipelineId ?? undefined },
|
||||
companyId
|
||||
);
|
||||
toast.success('Oportunidad creada');
|
||||
modalOpen = false;
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo crear la oportunidad');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, id: number) {
|
||||
draggingId = id;
|
||||
event.dataTransfer?.setData('text/plain', String(id));
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
async function onDrop(event: DragEvent, stageId: number) {
|
||||
event.preventDefault();
|
||||
const id = draggingId;
|
||||
draggingId = null;
|
||||
if (!id || !companyId) return;
|
||||
const opp = opps.find((o) => o.id === id);
|
||||
if (!opp || opp.stage_id === stageId) return;
|
||||
try {
|
||||
const updated = await opportunitiesAPI.move(id, stageId, companyId);
|
||||
opps = opps.map((o) => (o.id === id ? updated : o));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo mover la oportunidad');
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<Target class="h-6 w-6" />
|
||||
Oportunidades
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Arrastra las tarjetas entre etapas del embudo.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if pipelines.length > 1}
|
||||
<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={selectedPipelineId}
|
||||
onchange={onPipelineChange}
|
||||
>
|
||||
{#each pipelines as p (p.id)}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<Button onclick={openCreate} disabled={!companyId || currentStages.length === 0}>
|
||||
<Plus class="mr-1 h-4 w-4" /> Nueva oportunidad
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !companyId}
|
||||
<Card.Root><Card.Content class="py-8 text-center text-sm text-muted-foreground">Selecciona una compañía.</Card.Content></Card.Root>
|
||||
{:else if loading}
|
||||
<p class="text-sm text-muted-foreground">Cargando pipeline…</p>
|
||||
{:else if currentStages.length === 0}
|
||||
<Card.Root>
|
||||
<Card.Content class="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p class="text-sm text-muted-foreground">Aún no tienes un embudo con etapas.</p>
|
||||
<Button onclick={seedPipeline} disabled={busy}>
|
||||
{busy ? 'Creando…' : 'Crear embudo de ejemplo'}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<div class="flex gap-4 overflow-x-auto pb-4">
|
||||
{#each currentStages as stage (stage.id)}
|
||||
<div
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg border bg-muted/30"
|
||||
role="list"
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={(e) => onDrop(e, stage.id)}
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-3 py-2">
|
||||
<span class="text-sm font-semibold">{stage.name}</span>
|
||||
<span class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{oppsForStage(stage.id).length}
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-3 py-1 text-xs text-muted-foreground">{formatMoney(stageTotal(stage.id))}</div>
|
||||
<div class="flex min-h-24 flex-1 flex-col gap-2 p-2">
|
||||
{#each oppsForStage(stage.id) as opp (opp.id)}
|
||||
<div
|
||||
class="cursor-grab rounded-md border bg-card p-3 shadow-sm active:cursor-grabbing"
|
||||
role="listitem"
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, opp.id)}
|
||||
>
|
||||
<p class="text-sm font-medium">{opp.name}</p>
|
||||
{#if accountName(opp.account_id)}
|
||||
<p class="text-xs text-muted-foreground">{accountName(opp.account_id)}</p>
|
||||
{/if}
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<span class="text-sm font-semibold">{formatMoney(opp.amount, opp.currency)}</span>
|
||||
{#if opp.status === 'won'}
|
||||
<span class="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400">Ganada</span>
|
||||
{:else if opp.status === 'lost'}
|
||||
<span class="rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] text-red-700 dark:bg-red-950/40 dark:text-red-400">Perdida</span>
|
||||
{:else if opp.probability !== null}
|
||||
<span class="text-[10px] text-muted-foreground">{opp.probability}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</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="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()}>
|
||||
<h2 class="mb-4 text-lg font-semibold">Nueva oportunidad</h2>
|
||||
<form class="grid gap-4" onsubmit={save}>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Nombre *</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">Cuenta</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_id}>
|
||||
<option value={undefined}>— Sin cuenta —</option>
|
||||
{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Etapa</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.stage_id}>
|
||||
{#each currentStages as s (s.id)}<option value={s.id}>{s.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Monto (MXN)</span>
|
||||
<input type="number" min="0" step="0.01" 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.amount} />
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Cierre estimado</span>
|
||||
<input type="date" 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.expected_close_date} />
|
||||
</label>
|
||||
<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}
|
||||
257
frontend/src/routes/dashboard/crm/prospectos/+page.svelte
Normal file
257
frontend/src/routes/dashboard/crm/prospectos/+page.svelte
Normal file
@@ -0,0 +1,257 @@
|
||||
<script lang="ts">
|
||||
import { UserPlus, Plus, Pencil, Trash2, Search, ArrowRightLeft } 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 { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm';
|
||||
import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let items = $state<Lead[]>([]);
|
||||
let loading = $state(false);
|
||||
let search = $state('');
|
||||
let modalOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let editingId = $state<number | null>(null);
|
||||
let form = $state<LeadInput>({ name: '', status: 'new' });
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
const filtered = $derived(
|
||||
search.trim()
|
||||
? items.filter((l) =>
|
||||
`${l.name} ${l.company_name ?? ''} ${l.email ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase())
|
||||
)
|
||||
: items
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
void load(cid);
|
||||
});
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
items = await leadsAPI.list(cid);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los prospectos');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId = null;
|
||||
form = { name: '', status: 'new' };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(l: Lead) {
|
||||
editingId = l.id;
|
||||
form = { ...l };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!companyId) return;
|
||||
if (!form.name?.trim()) {
|
||||
toast.error('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (editingId) {
|
||||
await leadsAPI.update(editingId, form, companyId);
|
||||
toast.success('Prospecto actualizado');
|
||||
} else {
|
||||
await leadsAPI.create(form, companyId);
|
||||
toast.success('Prospecto creado');
|
||||
}
|
||||
modalOpen = false;
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo guardar el prospecto');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function convert(l: Lead) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Convertir "${l.name}" en cuenta, contacto y oportunidad?`)) return;
|
||||
try {
|
||||
await leadsAPI.convert(l.id, { create_opportunity: true }, companyId);
|
||||
toast.success('Prospecto convertido');
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo convertir el prospecto');
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(l: Lead) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Eliminar el prospecto "${l.name}"?`)) return;
|
||||
try {
|
||||
await leadsAPI.remove(l.id, companyId);
|
||||
toast.success('Prospecto eliminado');
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar el prospecto');
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
new: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
|
||||
contacted: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-400',
|
||||
qualified: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
|
||||
unqualified: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
|
||||
converted: 'bg-purple-100 text-purple-700 dark:bg-purple-950/40 dark:text-purple-400'
|
||||
};
|
||||
</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">
|
||||
<UserPlus class="h-6 w-6" />
|
||||
Prospectos
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Leads sin calificar. Conviértelos cuando avancen.</p>
|
||||
</div>
|
||||
<Button onclick={openCreate} disabled={!companyId}>
|
||||
<Plus class="mr-1 h-4 w-4" /> Nuevo prospecto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="relative max-w-sm">
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
class="w-full rounded-md border bg-transparent py-2 pl-8 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="Buscar por nombre o empresa…"
|
||||
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 prospectos registrados.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Nombre</Table.Head>
|
||||
<Table.Head>Empresa</Table.Head>
|
||||
<Table.Head>Origen</Table.Head>
|
||||
<Table.Head>Estado</Table.Head>
|
||||
<Table.Head class="text-right">Valor est.</Table.Head>
|
||||
<Table.Head class="text-right">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filtered as l (l.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
{l.name}
|
||||
{#if l.contact_name}<span class="block text-xs text-muted-foreground">{l.contact_name}</span>{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{l.company_name ?? '—'}</Table.Cell>
|
||||
<Table.Cell>{labelOf(LEAD_SOURCES, l.source)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[l.status] ?? ''}">
|
||||
{labelOf(LEAD_STATUS, l.status)}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">{formatMoney(l.estimated_value)}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => convert(l)}
|
||||
disabled={l.status === 'converted'}
|
||||
aria-label="Convertir"
|
||||
title="Convertir a cuenta/contacto/oportunidad"
|
||||
>
|
||||
<ArrowRightLeft class="h-4 w-4 text-primary" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => openEdit(l)} aria-label="Editar">
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => remove(l)} 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-[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 prospecto' : 'Nuevo prospecto'}</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">Nombre / título *</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">Empresa</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.company_name} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Nombre de contacto</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.contact_name} />
|
||||
</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">Origen</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.source}>
|
||||
<option value={undefined}>—</option>
|
||||
{#each LEAD_SOURCES 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">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 LEAD_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">Valor estimado (MXN)</span>
|
||||
<input type="number" min="0" step="0.01" 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.estimated_value} />
|
||||
</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">
|
||||
<Button type="button" variant="outline" onclick={() => (modalOpen = false)}>Cancelar</Button>
|
||||
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user