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 @@
|
|||||||
# ==================================
|
# ==================================
|
||||||
# ANEXO76 - Variables de Entorno
|
# CRM Aduanasoft - Variables de Entorno
|
||||||
# ==================================
|
# ==================================
|
||||||
|
|
||||||
# ----- PostgreSQL App -----
|
# ----- PostgreSQL App -----
|
||||||
@@ -19,11 +19,12 @@ KEYCLOAK_CLIENT_SECRET=dev-secret
|
|||||||
KEYCLOAK_FRONTEND_CLIENT_ID=app-frontend
|
KEYCLOAK_FRONTEND_CLIENT_ID=app-frontend
|
||||||
|
|
||||||
# ----- Backend -----
|
# ----- Backend -----
|
||||||
|
APP_NAME=CRM Aduanasoft
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
CORE_DB_HOST=postgres-a76
|
CORE_DB_HOST=postgres
|
||||||
CORE_DB_PORT=5432
|
CORE_DB_PORT=5432
|
||||||
CORE_DB_NAME=anexo76_core
|
CORE_DB_NAME=crm_core
|
||||||
CORE_DB_USER=postgres
|
CORE_DB_USER=postgres
|
||||||
CORE_DB_PASSWORD=postgres
|
CORE_DB_PASSWORD=postgres
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ CSV_IMPORT_STORAGE=minio
|
|||||||
S3_ENDPOINT_URL=http://minio:9000
|
S3_ENDPOINT_URL=http://minio:9000
|
||||||
S3_ACCESS_KEY=minioadmin
|
S3_ACCESS_KEY=minioadmin
|
||||||
S3_SECRET_KEY=minioadmin
|
S3_SECRET_KEY=minioadmin
|
||||||
S3_BUCKET=anexo76
|
S3_BUCKET=crm
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
S3_USE_SSL=false
|
S3_USE_SSL=false
|
||||||
# Logos, certificados, help: mismo bucket. Si CSV_IMPORT_STORAGE=minio, también se usa MinIO aquí
|
# Logos, certificados, help: mismo bucket. Si CSV_IMPORT_STORAGE=minio, también se usa MinIO aquí
|
||||||
|
|||||||
198
README.md
198
README.md
@@ -1,8 +1,11 @@
|
|||||||
# Plantilla Workspace — Aduanasoft
|
# CRM — Aduanasoft
|
||||||
|
|
||||||
Plantilla base para nuevos proyectos del ecosistema **Workspace de Aduanasoft**.
|
Sistema CRM construido sobre la **plantilla Workspace de Aduanasoft** (SvelteKit 5 +
|
||||||
Incluye autenticación SSO con Keycloak/Hub, arquitectura multi-tenant, y un dashboard
|
FastAPI + PostgreSQL, multi-tenant con Keycloak/Hub). Gestiona cuentas, contactos,
|
||||||
funcional listo para extender.
|
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 |
|
| Capa | Tecnología |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frontend | SvelteKit 5 + Tailwind CSS |
|
| Frontend | SvelteKit 5 (runes) + Tailwind + shadcn-svelte |
|
||||||
| Backend | FastAPI + SQLAlchemy + PostgreSQL |
|
| Backend | FastAPI + SQLAlchemy 2.0 + Pydantic v2 |
|
||||||
| Auth | Keycloak (OpenID Connect) vía Workspace Hub |
|
| Auth | Keycloak (OIDC) vía Workspace Hub |
|
||||||
|
| Base de datos | PostgreSQL (schema `crm`) |
|
||||||
| Cache / Queue | Valkey (Redis) + Celery |
|
| Cache / Queue | Valkey (Redis) + Celery |
|
||||||
| Storage | MinIO (S3-compatible) |
|
| Storage | MinIO (S3-compatible) |
|
||||||
| Contenedores | Docker Compose |
|
| Contenedores | Docker Compose |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Estructura del proyecto
|
## Módulo CRM
|
||||||
|
|
||||||
```
|
Esquema dedicado `crm` con 7 tablas multi-tenant (`tenant_id` + `company_id`, soft delete):
|
||||||
├── backend/
|
|
||||||
│ ├── api/v1/modules/
|
| Entidad | Tabla | Descripción |
|
||||||
│ │ ├── core/ # Auth, usuarios, tenants, permisos, licencias
|
|---|---|---|
|
||||||
│ │ └── example/ # Módulo de referencia — copia y renombra
|
| Cuentas | `crm.accounts` | Empresas cliente/prospecto (importador, IMMEX, agencia aduanal, transportista). Incluye RFC y patente aduanal. |
|
||||||
│ ├── core/ # Config, seguridad, DB, middleware
|
| Contactos | `crm.contacts` | Personas asociadas a una cuenta. |
|
||||||
│ └── alembic/ # Migraciones (solo esquema core)
|
| Prospectos | `crm.leads` | Leads sin calificar; se convierten en cuenta + contacto + oportunidad. |
|
||||||
│
|
| Embudos | `crm.pipelines` | Embudos de venta por compañía. |
|
||||||
├── frontend/
|
| Etapas | `crm.pipeline_stages` | Columnas del Kanban (con probabilidad y etapas terminales ganada/perdida). |
|
||||||
│ ├── src/routes/
|
| Oportunidades | `crm.opportunities` | Negocios que avanzan por el embudo. |
|
||||||
│ │ ├── auth/ # Callback OAuth2, SSO, logout
|
| Actividades | `crm.activities` | Llamadas, reuniones, tareas, correos y notas. |
|
||||||
│ │ ├── login/ # Login local (dev) o redirect al workspace
|
|
||||||
│ │ └── dashboard/ # Shell + rutas stub
|
### Endpoints (prefijo `/v1/crm`)
|
||||||
│ └── src/lib/
|
|
||||||
│ ├── server/ # workspace-auth, workspace-apps, api SSR
|
Todos reciben `company_id` como query param y validan permisos vía Keycloak/`PermissionService`.
|
||||||
│ └── stores/ # company, system, workspace-apps
|
|
||||||
│
|
- `GET|POST /accounts`, `GET|PATCH|DELETE /accounts/{id}`
|
||||||
└── scripts/
|
- `GET|POST /contacts`, `GET|PATCH|DELETE /contacts/{id}`
|
||||||
└── auth-mode.sh # Alterna entre modo local y workspace
|
- `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
|
## Inicio rápido (dev)
|
||||||
|
|
||||||
### 1. Clonar y configurar
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.aduanasoft.com/ADUANASOFT/plantillas-proyectos.git mi-proyecto
|
cp .env.example .env # ajustar CORE_DB_NAME, Keycloak, etc.
|
||||||
cd mi-proyecto
|
./scripts/auth-mode.sh local # login local sin workspace
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
docker compose up -d
|
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
|
```bash
|
||||||
./scripts/auth-mode.sh workspace
|
cd backend
|
||||||
# Pregunta:
|
alembic upgrade head # crea el schema crm y sus tablas (revisión f1a2b3c4d5e6)
|
||||||
# URL del workspace → https://workspace.aduanasoft.com
|
alembic downgrade -1 # revierte el CRM (down() probado)
|
||||||
# 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 }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Script auth-mode
|
## Pruebas
|
||||||
|
|
||||||
|
Backend (servicios CRM):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/auth-mode.sh status # Ver modo actual
|
cd backend
|
||||||
./scripts/auth-mode.sh local # Activar login local (dev sin workspace)
|
pytest tests/ -q # 24 pruebas de servicios (SQLite en memoria)
|
||||||
./scripts/auth-mode.sh workspace # Configurar y conectar al workspace
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Convenciones
|
||||||
|
|
||||||
- **Commits**: Conventional Commits (`feat:`, `fix:`, `refactor:`, `chore:`)
|
- **Commits**: Conventional Commits (`feat:`, `fix:`, `refactor:`, `chore:`)
|
||||||
- **Branches**: `feature/DESC`, `fix/DESC`
|
- **Ramas**: `feature/AS-###-desc`, `fix/AS-###-desc`
|
||||||
- **Nombres en código**: inglés; comentarios de lógica de negocio en español
|
- **Código**: nombres en inglés, comentarios de negocio en español
|
||||||
- **Backend**: Pydantic v2, async por default, routers por dominio
|
- **Backend**: Pydantic v2, routers por dominio, filtros multi-tenant explícitos
|
||||||
- **Frontend**: SvelteKit 5 runes (`$state`, `$derived`, `$effect`), Tailwind utility-first
|
- **Frontend**: runes (`$state`, `$derived`, `$effect`, `$props`), Tailwind utility-first
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Application
|
# Application
|
||||||
APP_NAME=Mi Aplicación
|
APP_NAME=CRM Aduanasoft
|
||||||
APP_VERSION=1.0.0
|
APP_VERSION=1.0.0
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
@@ -15,7 +15,7 @@ DEV_LOCAL_AUTH_COMPANY_ID=1
|
|||||||
# Database - Core
|
# Database - Core
|
||||||
CORE_DB_HOST=localhost
|
CORE_DB_HOST=localhost
|
||||||
CORE_DB_PORT=5432
|
CORE_DB_PORT=5432
|
||||||
CORE_DB_NAME=anexo76_core
|
CORE_DB_NAME=crm_core
|
||||||
CORE_DB_USER=postgres
|
CORE_DB_USER=postgres
|
||||||
CORE_DB_PASSWORD=postgres
|
CORE_DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "crm-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"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,
|
Settings2,
|
||||||
Users,
|
Users,
|
||||||
Shield,
|
Shield,
|
||||||
|
Briefcase,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
|
|
||||||
export type SystemContext = 'fixed_asset' | 'inventory';
|
export type SystemContext = 'fixed_asset' | 'inventory';
|
||||||
@@ -34,6 +35,19 @@ export function getNavMain(): NavMainItem[] {
|
|||||||
url: '/dashboard',
|
url: '/dashboard',
|
||||||
icon: LayoutDashboard,
|
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',
|
title: 'Usuarios',
|
||||||
url: '/dashboard/users',
|
url: '/dashboard/users',
|
||||||
|
|||||||
@@ -174,7 +174,7 @@
|
|||||||
|
|
||||||
<Sidebar.Group>
|
<Sidebar.Group>
|
||||||
<Sidebar.GroupLabel class="flex items-center gap-2">
|
<Sidebar.GroupLabel class="flex items-center gap-2">
|
||||||
<span>Anexo-76</span>
|
<span>CRM</span>
|
||||||
{#if $permissionsRefreshing}
|
{#if $permissionsRefreshing}
|
||||||
<span
|
<span
|
||||||
class="size-1.5 shrink-0 animate-pulse rounded-full bg-primary"
|
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