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

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

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

View File

@@ -1,5 +1,5 @@
# ==================================
# ANEXO76 - Variables de Entorno
# CRM Aduanasoft - Variables de Entorno
# ==================================
# ----- PostgreSQL App -----
@@ -19,11 +19,12 @@ KEYCLOAK_CLIENT_SECRET=dev-secret
KEYCLOAK_FRONTEND_CLIENT_ID=app-frontend
# ----- Backend -----
APP_NAME=CRM Aduanasoft
DEBUG=True
ENVIRONMENT=development
CORE_DB_HOST=postgres-a76
CORE_DB_HOST=postgres
CORE_DB_PORT=5432
CORE_DB_NAME=anexo76_core
CORE_DB_NAME=crm_core
CORE_DB_USER=postgres
CORE_DB_PASSWORD=postgres
@@ -77,7 +78,7 @@ CSV_IMPORT_STORAGE=minio
S3_ENDPOINT_URL=http://minio:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=anexo76
S3_BUCKET=crm
S3_REGION=us-east-1
S3_USE_SSL=false
# Logos, certificados, help: mismo bucket. Si CSV_IMPORT_STORAGE=minio, también se usa MinIO aquí

198
README.md
View File

@@ -1,8 +1,11 @@
# Plantilla Workspace — Aduanasoft
# CRM — Aduanasoft
Plantilla base para nuevos proyectos del ecosistema **Workspace de Aduanasoft**.
Incluye autenticación SSO con Keycloak/Hub, arquitectura multi-tenant, y un dashboard
funcional listo para extender.
Sistema CRM construido sobre la **plantilla Workspace de Aduanasoft** (SvelteKit 5 +
FastAPI + PostgreSQL, multi-tenant con Keycloak/Hub). Gestiona cuentas, contactos,
prospectos, oportunidades (pipeline Kanban) y actividades comerciales.
> Base: `plantillas-proyectos`. Este repo conserva el core de la plantilla (auth,
> tenants, permisos, licencias) y agrega el dominio **CRM** en backend y frontend.
---
@@ -10,147 +13,110 @@ funcional listo para extender.
| Capa | Tecnología |
|---|---|
| Frontend | SvelteKit 5 + Tailwind CSS |
| Backend | FastAPI + SQLAlchemy + PostgreSQL |
| Auth | Keycloak (OpenID Connect) vía Workspace Hub |
| Frontend | SvelteKit 5 (runes) + Tailwind + shadcn-svelte |
| Backend | FastAPI + SQLAlchemy 2.0 + Pydantic v2 |
| Auth | Keycloak (OIDC) vía Workspace Hub |
| Base de datos | PostgreSQL (schema `crm`) |
| Cache / Queue | Valkey (Redis) + Celery |
| Storage | MinIO (S3-compatible) |
| Contenedores | Docker Compose |
---
## Estructura del proyecto
## Módulo CRM
```
├── backend/
│ ├── api/v1/modules/
│ │ ├── core/ # Auth, usuarios, tenants, permisos, licencias
│ │ └── example/ # Módulo de referencia — copia y renombra
│ ├── core/ # Config, seguridad, DB, middleware
│ └── alembic/ # Migraciones (solo esquema core)
├── frontend/
│ ├── src/routes/
│ │ ├── auth/ # Callback OAuth2, SSO, logout
│ │ ├── login/ # Login local (dev) o redirect al workspace
│ │ └── dashboard/ # Shell + rutas stub
│ └── src/lib/
│ ├── server/ # workspace-auth, workspace-apps, api SSR
│ └── stores/ # company, system, workspace-apps
└── scripts/
└── auth-mode.sh # Alterna entre modo local y workspace
```
Esquema dedicado `crm` con 7 tablas multi-tenant (`tenant_id` + `company_id`, soft delete):
| Entidad | Tabla | Descripción |
|---|---|---|
| Cuentas | `crm.accounts` | Empresas cliente/prospecto (importador, IMMEX, agencia aduanal, transportista). Incluye RFC y patente aduanal. |
| Contactos | `crm.contacts` | Personas asociadas a una cuenta. |
| Prospectos | `crm.leads` | Leads sin calificar; se convierten en cuenta + contacto + oportunidad. |
| Embudos | `crm.pipelines` | Embudos de venta por compañía. |
| Etapas | `crm.pipeline_stages` | Columnas del Kanban (con probabilidad y etapas terminales ganada/perdida). |
| Oportunidades | `crm.opportunities` | Negocios que avanzan por el embudo. |
| Actividades | `crm.activities` | Llamadas, reuniones, tareas, correos y notas. |
### Endpoints (prefijo `/v1/crm`)
Todos reciben `company_id` como query param y validan permisos vía Keycloak/`PermissionService`.
- `GET|POST /accounts`, `GET|PATCH|DELETE /accounts/{id}`
- `GET|POST /contacts`, `GET|PATCH|DELETE /contacts/{id}`
- `GET|POST /leads`, `GET|PATCH|DELETE /leads/{id}`, `POST /leads/{id}/convert`
- `GET|POST /pipelines`, `PATCH|DELETE /pipelines/{id}`
- `GET|POST /stages`, `PATCH|DELETE /stages/{id}`
- `GET|POST /opportunities`, `GET|PATCH|DELETE /opportunities/{id}`, `PATCH /opportunities/{id}/move`
- `GET|POST /activities`, `GET|PATCH|DELETE /activities/{id}`, `PATCH /activities/{id}/complete`
- `GET /metrics` — KPIs y embudo por etapa para el dashboard
### Permisos
Se registran en el `PermissionRegistry` al arrancar (25 permisos: `crm.access` +
`crm.{account,contact,lead,opportunity,pipeline,activity}.{view,create,edit,delete}`).
Para persistirlos en BD: `POST /v1/core/permissions/sync` (o el CLI de sincronización).
---
## Inicio rápido
### 1. Clonar y configurar
## Inicio rápido (dev)
```bash
git clone https://git.aduanasoft.com/ADUANASOFT/plantillas-proyectos.git mi-proyecto
cd mi-proyecto
```
Renombrar el proyecto en `.env` y `docker-compose.yml`:
- `CORE_DB_NAME=app_core``mi_proyecto_core`
- `name: app` en `docker-compose.yml``mi-proyecto`
### 2. Levantar en modo local (sin workspace)
```bash
./scripts/auth-mode.sh local
cp .env.example .env # ajustar CORE_DB_NAME, Keycloak, etc.
./scripts/auth-mode.sh local # login local sin workspace
docker compose up -d
```
Abre `http://localhost:5173`click **"Entrar como dev"**.
Abre `http://localhost:5173`**CRM** en el sidebar.
### 3. Conectar al workspace
### Migraciones
```bash
./scripts/auth-mode.sh workspace
# Pregunta:
# URL del workspace → https://workspace.aduanasoft.com
# Keycloak Realm → master
# Keycloak Client ID → nombre-del-client
```
> El equipo del Hub debe registrar la app y agregar el redirect URI:
> `http://localhost:5173/auth/callback`
---
## Variables de entorno
El `.env` usa una sola URL base para derivar toda la configuración del workspace:
```env
# Una variable, todo se deriva de aquí
WORKSPACE_URL=https://workspace.aduanasoft.com
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=mi-app-frontend
# Auth local (desarrollo sin workspace)
DEV_LOCAL_AUTH=False # True = botón "Entrar como dev"
SECRET_KEY=... # Se genera automáticamente con auth-mode.sh local
```
El `docker-compose.yml` construye automáticamente:
- `HUB_URL` = `${WORKSPACE_URL}`
- `VITE_KEYCLOAK_URL` = `${WORKSPACE_URL}/kcauth`
---
## Agregar un módulo nuevo
### Backend
Copia `backend/api/v1/modules/example/` y renombra:
```
my_module/
├── __init__.py
├── models.py # SQLAlchemy — hereda TenantScopedMixin + TimestampMixin
├── dto.py # Pydantic v2 — Request / Response separados
├── service.py # Lógica de negocio, recibe db + tenant_id + company_id
└── routes.py # FastAPI router, usa Depends(get_current_user)
```
Registrar en `backend/api/v1/router.py`:
```python
from .modules.my_module.routes import router as my_router
router.include_router(my_router, prefix="/my-module", tags=["my-module"])
```
### Frontend
Crear `frontend/src/routes/dashboard/my-module/+page.svelte` y agregar al sidebar en
`frontend/src/lib/components/sidebar/modules.ts`:
```typescript
{ title: 'Mi módulo', url: '/dashboard/my-module', icon: MyIcon }
cd backend
alembic upgrade head # crea el schema crm y sus tablas (revisión f1a2b3c4d5e6)
alembic downgrade -1 # revierte el CRM (down() probado)
```
---
## Script auth-mode
## Pruebas
Backend (servicios CRM):
```bash
./scripts/auth-mode.sh status # Ver modo actual
./scripts/auth-mode.sh local # Activar login local (dev sin workspace)
./scripts/auth-mode.sh workspace # Configurar y conectar al workspace
cd backend
pytest tests/ -q # 24 pruebas de servicios (SQLite en memoria)
```
Al cambiar de modo el script pregunta si reiniciar los contenedores automáticamente.
En CI/PostgreSQL, define `TEST_DATABASE_URL` para ejercitar el esquema real y las
políticas RLS (ver `docs/ARCHITECTURE.md`).
---
## Estructura del CRM
```
backend/api/v1/modules/crm/
├── router.py # agrega submódulos bajo /crm (y registra permisos)
├── permissions.py # alta de permisos CRM en el registry
├── accounts/ contacts/ leads/ pipelines/ opportunities/ activities/ metrics/
│ └── models.py · dto.py · service.py · routes.py
backend/alembic/versions/f1a2b3c4d5e6_crm_schema.py
frontend/src/
├── lib/api/crm/ # clientes API tipados por entidad
├── lib/components/crm/ # helpers de formato/etiquetas
└── routes/dashboard/crm/
├── +page.svelte # panel (KPIs + embudo)
├── cuentas/ contactos/ prospectos/ actividades/ # CRUD
└── oportunidades/ # Kanban con drag & drop
```
---
## Convenciones
- **Commits**: Conventional Commits (`feat:`, `fix:`, `refactor:`, `chore:`)
- **Branches**: `feature/DESC`, `fix/DESC`
- **Nombres en código**: inglés; comentarios de lógica de negocio en español
- **Backend**: Pydantic v2, async por default, routers por dominio
- **Frontend**: SvelteKit 5 runes (`$state`, `$derived`, `$effect`), Tailwind utility-first
- **Ramas**: `feature/AS-###-desc`, `fix/AS-###-desc`
- **Código**: nombres en inglés, comentarios de negocio en español
- **Backend**: Pydantic v2, routers por dominio, filtros multi-tenant explícitos
- **Frontend**: runes (`$state`, `$derived`, `$effect`, `$props`), Tailwind utility-first

View File

@@ -1,5 +1,5 @@
# Application
APP_NAME=Mi Aplicación
APP_NAME=CRM Aduanasoft
APP_VERSION=1.0.0
DEBUG=True
ENVIRONMENT=development
@@ -15,7 +15,7 @@ DEV_LOCAL_AUTH_COMPANY_ID=1
# Database - Core
CORE_DB_HOST=localhost
CORE_DB_PORT=5432
CORE_DB_NAME=anexo76_core
CORE_DB_NAME=crm_core
CORE_DB_USER=postgres
CORE_DB_PASSWORD=postgres

View File

@@ -1,5 +1,5 @@
{
"name": "frontend",
"name": "crm-frontend",
"private": true,
"version": "0.0.1",
"type": "module",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>

View 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}

View 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}

View 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}

View 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}

View 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}