diff --git a/.env.example b/.env.example index e3663b4..74481c2 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ================================== -# ANEXO76 - Variables de Entorno +# CRM Aduanasoft - Variables de Entorno # ================================== # ----- PostgreSQL App ----- @@ -19,11 +19,12 @@ KEYCLOAK_CLIENT_SECRET=dev-secret KEYCLOAK_FRONTEND_CLIENT_ID=app-frontend # ----- Backend ----- +APP_NAME=CRM Aduanasoft DEBUG=True ENVIRONMENT=development -CORE_DB_HOST=postgres-a76 +CORE_DB_HOST=postgres CORE_DB_PORT=5432 -CORE_DB_NAME=anexo76_core +CORE_DB_NAME=crm_core CORE_DB_USER=postgres CORE_DB_PASSWORD=postgres @@ -77,7 +78,7 @@ CSV_IMPORT_STORAGE=minio S3_ENDPOINT_URL=http://minio:9000 S3_ACCESS_KEY=minioadmin S3_SECRET_KEY=minioadmin -S3_BUCKET=anexo76 +S3_BUCKET=crm S3_REGION=us-east-1 S3_USE_SSL=false # Logos, certificados, help: mismo bucket. Si CSV_IMPORT_STORAGE=minio, también se usa MinIO aquí diff --git a/README.md b/README.md index d300d73..a48c53c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ -# Plantilla Workspace — Aduanasoft +# CRM — Aduanasoft -Plantilla base para nuevos proyectos del ecosistema **Workspace de Aduanasoft**. -Incluye autenticación SSO con Keycloak/Hub, arquitectura multi-tenant, y un dashboard -funcional listo para extender. +Sistema CRM construido sobre la **plantilla Workspace de Aduanasoft** (SvelteKit 5 + +FastAPI + PostgreSQL, multi-tenant con Keycloak/Hub). Gestiona cuentas, contactos, +prospectos, oportunidades (pipeline Kanban) y actividades comerciales. + +> Base: `plantillas-proyectos`. Este repo conserva el core de la plantilla (auth, +> tenants, permisos, licencias) y agrega el dominio **CRM** en backend y frontend. --- @@ -10,147 +13,110 @@ funcional listo para extender. | Capa | Tecnología | |---|---| -| Frontend | SvelteKit 5 + Tailwind CSS | -| Backend | FastAPI + SQLAlchemy + PostgreSQL | -| Auth | Keycloak (OpenID Connect) vía Workspace Hub | +| Frontend | SvelteKit 5 (runes) + Tailwind + shadcn-svelte | +| Backend | FastAPI + SQLAlchemy 2.0 + Pydantic v2 | +| Auth | Keycloak (OIDC) vía Workspace Hub | +| Base de datos | PostgreSQL (schema `crm`) | | Cache / Queue | Valkey (Redis) + Celery | | Storage | MinIO (S3-compatible) | | Contenedores | Docker Compose | --- -## Estructura del proyecto +## Módulo CRM -``` -├── backend/ -│ ├── api/v1/modules/ -│ │ ├── core/ # Auth, usuarios, tenants, permisos, licencias -│ │ └── example/ # Módulo de referencia — copia y renombra -│ ├── core/ # Config, seguridad, DB, middleware -│ └── alembic/ # Migraciones (solo esquema core) -│ -├── frontend/ -│ ├── src/routes/ -│ │ ├── auth/ # Callback OAuth2, SSO, logout -│ │ ├── login/ # Login local (dev) o redirect al workspace -│ │ └── dashboard/ # Shell + rutas stub -│ └── src/lib/ -│ ├── server/ # workspace-auth, workspace-apps, api SSR -│ └── stores/ # company, system, workspace-apps -│ -└── scripts/ - └── auth-mode.sh # Alterna entre modo local y workspace -``` +Esquema dedicado `crm` con 7 tablas multi-tenant (`tenant_id` + `company_id`, soft delete): + +| Entidad | Tabla | Descripción | +|---|---|---| +| Cuentas | `crm.accounts` | Empresas cliente/prospecto (importador, IMMEX, agencia aduanal, transportista). Incluye RFC y patente aduanal. | +| Contactos | `crm.contacts` | Personas asociadas a una cuenta. | +| Prospectos | `crm.leads` | Leads sin calificar; se convierten en cuenta + contacto + oportunidad. | +| Embudos | `crm.pipelines` | Embudos de venta por compañía. | +| Etapas | `crm.pipeline_stages` | Columnas del Kanban (con probabilidad y etapas terminales ganada/perdida). | +| Oportunidades | `crm.opportunities` | Negocios que avanzan por el embudo. | +| Actividades | `crm.activities` | Llamadas, reuniones, tareas, correos y notas. | + +### Endpoints (prefijo `/v1/crm`) + +Todos reciben `company_id` como query param y validan permisos vía Keycloak/`PermissionService`. + +- `GET|POST /accounts`, `GET|PATCH|DELETE /accounts/{id}` +- `GET|POST /contacts`, `GET|PATCH|DELETE /contacts/{id}` +- `GET|POST /leads`, `GET|PATCH|DELETE /leads/{id}`, `POST /leads/{id}/convert` +- `GET|POST /pipelines`, `PATCH|DELETE /pipelines/{id}` +- `GET|POST /stages`, `PATCH|DELETE /stages/{id}` +- `GET|POST /opportunities`, `GET|PATCH|DELETE /opportunities/{id}`, `PATCH /opportunities/{id}/move` +- `GET|POST /activities`, `GET|PATCH|DELETE /activities/{id}`, `PATCH /activities/{id}/complete` +- `GET /metrics` — KPIs y embudo por etapa para el dashboard + +### Permisos + +Se registran en el `PermissionRegistry` al arrancar (25 permisos: `crm.access` + +`crm.{account,contact,lead,opportunity,pipeline,activity}.{view,create,edit,delete}`). +Para persistirlos en BD: `POST /v1/core/permissions/sync` (o el CLI de sincronización). --- -## Inicio rápido - -### 1. Clonar y configurar +## Inicio rápido (dev) ```bash -git clone https://git.aduanasoft.com/ADUANASOFT/plantillas-proyectos.git mi-proyecto -cd mi-proyecto -``` - -Renombrar el proyecto en `.env` y `docker-compose.yml`: -- `CORE_DB_NAME=app_core` → `mi_proyecto_core` -- `name: app` en `docker-compose.yml` → `mi-proyecto` - -### 2. Levantar en modo local (sin workspace) - -```bash -./scripts/auth-mode.sh local +cp .env.example .env # ajustar CORE_DB_NAME, Keycloak, etc. +./scripts/auth-mode.sh local # login local sin workspace docker compose up -d ``` -Abre `http://localhost:5173` → click **"Entrar como dev"**. +Abre `http://localhost:5173` → **CRM** en el sidebar. -### 3. Conectar al workspace +### Migraciones ```bash -./scripts/auth-mode.sh workspace -# Pregunta: -# URL del workspace → https://workspace.aduanasoft.com -# Keycloak Realm → master -# Keycloak Client ID → nombre-del-client -``` - -> El equipo del Hub debe registrar la app y agregar el redirect URI: -> `http://localhost:5173/auth/callback` - ---- - -## Variables de entorno - -El `.env` usa una sola URL base para derivar toda la configuración del workspace: - -```env -# Una variable, todo se deriva de aquí -WORKSPACE_URL=https://workspace.aduanasoft.com -KEYCLOAK_REALM=master -KEYCLOAK_CLIENT_ID=mi-app-frontend - -# Auth local (desarrollo sin workspace) -DEV_LOCAL_AUTH=False # True = botón "Entrar como dev" -SECRET_KEY=... # Se genera automáticamente con auth-mode.sh local -``` - -El `docker-compose.yml` construye automáticamente: -- `HUB_URL` = `${WORKSPACE_URL}` -- `VITE_KEYCLOAK_URL` = `${WORKSPACE_URL}/kcauth` - ---- - -## Agregar un módulo nuevo - -### Backend - -Copia `backend/api/v1/modules/example/` y renombra: - -``` -my_module/ -├── __init__.py -├── models.py # SQLAlchemy — hereda TenantScopedMixin + TimestampMixin -├── dto.py # Pydantic v2 — Request / Response separados -├── service.py # Lógica de negocio, recibe db + tenant_id + company_id -└── routes.py # FastAPI router, usa Depends(get_current_user) -``` - -Registrar en `backend/api/v1/router.py`: - -```python -from .modules.my_module.routes import router as my_router -router.include_router(my_router, prefix="/my-module", tags=["my-module"]) -``` - -### Frontend - -Crear `frontend/src/routes/dashboard/my-module/+page.svelte` y agregar al sidebar en -`frontend/src/lib/components/sidebar/modules.ts`: - -```typescript -{ title: 'Mi módulo', url: '/dashboard/my-module', icon: MyIcon } +cd backend +alembic upgrade head # crea el schema crm y sus tablas (revisión f1a2b3c4d5e6) +alembic downgrade -1 # revierte el CRM (down() probado) ``` --- -## Script auth-mode +## Pruebas + +Backend (servicios CRM): ```bash -./scripts/auth-mode.sh status # Ver modo actual -./scripts/auth-mode.sh local # Activar login local (dev sin workspace) -./scripts/auth-mode.sh workspace # Configurar y conectar al workspace +cd backend +pytest tests/ -q # 24 pruebas de servicios (SQLite en memoria) ``` -Al cambiar de modo el script pregunta si reiniciar los contenedores automáticamente. +En CI/PostgreSQL, define `TEST_DATABASE_URL` para ejercitar el esquema real y las +políticas RLS (ver `docs/ARCHITECTURE.md`). + +--- + +## Estructura del CRM + +``` +backend/api/v1/modules/crm/ +├── router.py # agrega submódulos bajo /crm (y registra permisos) +├── permissions.py # alta de permisos CRM en el registry +├── accounts/ contacts/ leads/ pipelines/ opportunities/ activities/ metrics/ +│ └── models.py · dto.py · service.py · routes.py +backend/alembic/versions/f1a2b3c4d5e6_crm_schema.py + +frontend/src/ +├── lib/api/crm/ # clientes API tipados por entidad +├── lib/components/crm/ # helpers de formato/etiquetas +└── routes/dashboard/crm/ + ├── +page.svelte # panel (KPIs + embudo) + ├── cuentas/ contactos/ prospectos/ actividades/ # CRUD + └── oportunidades/ # Kanban con drag & drop +``` --- ## Convenciones - **Commits**: Conventional Commits (`feat:`, `fix:`, `refactor:`, `chore:`) -- **Branches**: `feature/DESC`, `fix/DESC` -- **Nombres en código**: inglés; comentarios de lógica de negocio en español -- **Backend**: Pydantic v2, async por default, routers por dominio -- **Frontend**: SvelteKit 5 runes (`$state`, `$derived`, `$effect`), Tailwind utility-first +- **Ramas**: `feature/AS-###-desc`, `fix/AS-###-desc` +- **Código**: nombres en inglés, comentarios de negocio en español +- **Backend**: Pydantic v2, routers por dominio, filtros multi-tenant explícitos +- **Frontend**: runes (`$state`, `$derived`, `$effect`, `$props`), Tailwind utility-first diff --git a/backend/.env.example b/backend/.env.example index 43973d9..809cde1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,5 @@ # Application -APP_NAME=Mi Aplicación +APP_NAME=CRM Aduanasoft APP_VERSION=1.0.0 DEBUG=True ENVIRONMENT=development @@ -15,7 +15,7 @@ DEV_LOCAL_AUTH_COMPANY_ID=1 # Database - Core CORE_DB_HOST=localhost CORE_DB_PORT=5432 -CORE_DB_NAME=anexo76_core +CORE_DB_NAME=crm_core CORE_DB_USER=postgres CORE_DB_PASSWORD=postgres diff --git a/frontend/package.json b/frontend/package.json index 2e49b7a..ca9dded 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "frontend", + "name": "crm-frontend", "private": true, "version": "0.0.1", "type": "module", diff --git a/frontend/src/lib/api/crm/accounts.ts b/frontend/src/lib/api/crm/accounts.ts new file mode 100644 index 0000000..12f29d6 --- /dev/null +++ b/frontend/src/lib/api/crm/accounts.ts @@ -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 { + 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(`/v1/crm/accounts?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async get(id: number, companyId: number): Promise { + const res = await api.get(`/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 { + const res = await api.post(`/v1/crm/accounts?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise { + const res = await api.patch(`/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 { + const res = await api.delete(`/v1/crm/accounts/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/crm/activities.ts b/frontend/src/lib/api/crm/activities.ts new file mode 100644 index 0000000..3b99648 --- /dev/null +++ b/frontend/src/lib/api/crm/activities.ts @@ -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 { + 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(`/v1/crm/activities?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async get(id: number, companyId: number): Promise { + const res = await api.get(`/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 { + const res = await api.post(`/v1/crm/activities?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise { + const res = await api.patch(`/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 { + const res = await api.patch( + `/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 { + const res = await api.delete(`/v1/crm/activities/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/crm/contacts.ts b/frontend/src/lib/api/crm/contacts.ts new file mode 100644 index 0000000..24716e9 --- /dev/null +++ b/frontend/src/lib/api/crm/contacts.ts @@ -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 { + 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(`/v1/crm/contacts?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async get(id: number, companyId: number): Promise { + const res = await api.get(`/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 { + const res = await api.post(`/v1/crm/contacts?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise { + const res = await api.patch(`/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 { + const res = await api.delete(`/v1/crm/contacts/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/crm/index.ts b/frontend/src/lib/api/crm/index.ts new file mode 100644 index 0000000..9ad63f5 --- /dev/null +++ b/frontend/src/lib/api/crm/index.ts @@ -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'; diff --git a/frontend/src/lib/api/crm/leads.ts b/frontend/src/lib/api/crm/leads.ts new file mode 100644 index 0000000..ad8d7e4 --- /dev/null +++ b/frontend/src/lib/api/crm/leads.ts @@ -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 { + 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(`/v1/crm/leads?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async get(id: number, companyId: number): Promise { + const res = await api.get(`/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 { + const res = await api.post(`/v1/crm/leads?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise { + const res = await api.patch(`/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 { + const res = await api.post( + `/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 { + const res = await api.delete(`/v1/crm/leads/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/crm/metrics.ts b/frontend/src/lib/api/crm/metrics.ts new file mode 100644 index 0000000..32ae424 --- /dev/null +++ b/frontend/src/lib/api/crm/metrics.ts @@ -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 { + const qs = new URLSearchParams({ company_id: String(companyId) }); + if (pipelineId) qs.set('pipeline_id', String(pipelineId)); + const res = await api.get(`/v1/crm/metrics?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + } +}; diff --git a/frontend/src/lib/api/crm/opportunities.ts b/frontend/src/lib/api/crm/opportunities.ts new file mode 100644 index 0000000..e7fef0b --- /dev/null +++ b/frontend/src/lib/api/crm/opportunities.ts @@ -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 { + 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(`/v1/crm/opportunities?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async get(id: number, companyId: number): Promise { + const res = await api.get(`/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 { + const res = await api.post(`/v1/crm/opportunities?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise { + const res = await api.patch( + `/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 { + const res = await api.patch( + `/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 { + const res = await api.delete(`/v1/crm/opportunities/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/crm/pipelines.ts b/frontend/src/lib/api/crm/pipelines.ts new file mode 100644 index 0000000..dae5318 --- /dev/null +++ b/frontend/src/lib/api/crm/pipelines.ts @@ -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 { + const res = await api.get(`/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 { + const res = await api.post(`/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 { + const res = await api.patch(`/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 { + 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 { + const qs = new URLSearchParams({ company_id: String(companyId) }); + if (pipelineId) qs.set('pipeline_id', String(pipelineId)); + const res = await api.get(`/v1/crm/stages?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async create(data: StageInput, companyId: number): Promise { + const res = await api.post(`/v1/crm/stages?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise { + const res = await api.patch(`/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 { + const res = await api.delete(`/v1/crm/stages/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/crm/types.ts b/frontend/src/lib/api/crm/types.ts new file mode 100644 index 0000000..a1fe54f --- /dev/null +++ b/frontend/src/lib/api/crm/types.ts @@ -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> & { + 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> & { + 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> & { + 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> & { + 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> & { + 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[]; +} diff --git a/frontend/src/lib/components/crm/format.ts b/frontend/src/lib/components/crm/format.ts new file mode 100644 index 0000000..c820344 --- /dev/null +++ b/frontend/src/lib/components/crm/format.ts @@ -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; +} diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 3dbd257..af8dabe 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -3,6 +3,7 @@ import { Settings2, Users, Shield, + Briefcase, } from '@lucide/svelte'; export type SystemContext = 'fixed_asset' | 'inventory'; @@ -34,6 +35,19 @@ export function getNavMain(): NavMainItem[] { url: '/dashboard', icon: LayoutDashboard, }, + { + title: 'CRM', + url: '/dashboard/crm', + icon: Briefcase, + items: [ + { title: 'Panel', url: '/dashboard/crm' }, + { title: 'Cuentas', url: '/dashboard/crm/cuentas' }, + { title: 'Contactos', url: '/dashboard/crm/contactos' }, + { title: 'Prospectos', url: '/dashboard/crm/prospectos' }, + { title: 'Oportunidades', url: '/dashboard/crm/oportunidades' }, + { title: 'Actividades', url: '/dashboard/crm/actividades' }, + ], + }, { title: 'Usuarios', url: '/dashboard/users', diff --git a/frontend/src/lib/components/sidebar/nav-main.svelte b/frontend/src/lib/components/sidebar/nav-main.svelte index 293cc07..15be225 100644 --- a/frontend/src/lib/components/sidebar/nav-main.svelte +++ b/frontend/src/lib/components/sidebar/nav-main.svelte @@ -174,7 +174,7 @@ - Anexo-76 + CRM {#if $permissionsRefreshing} + 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(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 + ); + + +
+
+

+ + CRM +

+

Panel comercial: cuentas, prospectos y pipeline de ventas.

+
+ + {#if !companyId} + + + Selecciona una compañía para ver las métricas del CRM. + + + {:else if metrics} +
+ + + + + +
+ +
+ + + Valor del pipeline abierto + {formatMoney(metrics.open_pipeline_value)} + + + + + Ganado ({metrics.won_opportunities} oportunidades) + {formatMoney(metrics.won_value)} + + +
+ + + + Embudo de ventas + Oportunidades abiertas por etapa + + + {#if metrics.by_stage.length === 0} +

+ Aún no hay etapas configuradas. Crea un embudo desde Oportunidades. +

+ {:else} +
+ {#each metrics.by_stage as stage (stage.stage_id)} +
+
+ {stage.stage_name} + + {stage.count} · {formatMoney(stage.value)} + +
+
+
+
+
+ {/each} +
+ {/if} +
+
+ {:else if loading} +

Cargando métricas…

+ {/if} +
diff --git a/frontend/src/routes/dashboard/crm/actividades/+page.svelte b/frontend/src/routes/dashboard/crm/actividades/+page.svelte new file mode 100644 index 0000000..5de0376 --- /dev/null +++ b/frontend/src/routes/dashboard/crm/actividades/+page.svelte @@ -0,0 +1,218 @@ + + +
+
+
+

+ + Actividades +

+

Llamadas, reuniones, tareas y notas.

+
+ +
+ + + + + + + {#if loading} +

Cargando…

+ {:else if items.length === 0} +

Sin actividades registradas.

+ {:else} +
+ + + + Asunto + Tipo + Estado + Vence + Acciones + + + + {#each items as a (a.id)} + + {a.subject} + {labelOf(ACTIVITY_TYPES, a.activity_type)} + + + {labelOf(ACTIVITY_STATUS, a.status)} + + + {formatDate(a.due_date)} + + {#if a.status === 'pending'} + + {/if} + + + + + {/each} + + +
+ {/if} +
+
+
+ +{#if modalOpen} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/contactos/+page.svelte b/frontend/src/routes/dashboard/crm/contactos/+page.svelte new file mode 100644 index 0000000..41aff07 --- /dev/null +++ b/frontend/src/routes/dashboard/crm/contactos/+page.svelte @@ -0,0 +1,230 @@ + + +
+
+
+

+ + Contactos +

+

Personas asociadas a tus cuentas.

+
+ +
+ + + +
+ + +
+
+ + {#if loading} +

Cargando…

+ {:else if filtered.length === 0} +

Sin contactos registrados.

+ {:else} +
+ + + + Nombre + Cuenta + Puesto + Email + Teléfono + Acciones + + + + {#each filtered as c (c.id)} + + + {c.first_name} {c.last_name ?? ''} + {#if c.is_primary}Principal{/if} + + {accountName(c.account_id)} + {c.job_title ?? '—'} + {c.email ?? '—'} + {c.phone ?? c.mobile ?? '—'} + + + + + + {/each} + + +
+ {/if} +
+
+
+ +{#if modalOpen} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/cuentas/+page.svelte b/frontend/src/routes/dashboard/crm/cuentas/+page.svelte new file mode 100644 index 0000000..1aa1e8a --- /dev/null +++ b/frontend/src/routes/dashboard/crm/cuentas/+page.svelte @@ -0,0 +1,250 @@ + + +
+
+
+

+ + Cuentas +

+

Empresas cliente y prospectos.

+
+ +
+ + + +
+ + +
+
+ + {#if loading} +

Cargando…

+ {:else if filtered.length === 0} +

Sin cuentas registradas.

+ {:else} +
+ + + + Nombre + RFC + Tipo + Estado + Teléfono + Acciones + + + + {#each filtered as a (a.id)} + + + {a.name} + {#if a.trade_name}{a.trade_name}{/if} + + {a.rfc ?? '—'} + {labelOf(ACCOUNT_TYPES, a.account_type)} + + + {labelOf(ACCOUNT_STATUS, a.status)} + + + {a.phone ?? '—'} + + + + + + {/each} + + +
+ {/if} +
+
+
+ +{#if modalOpen} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte new file mode 100644 index 0000000..3c2b59e --- /dev/null +++ b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte @@ -0,0 +1,299 @@ + + +
+
+
+

+ + Oportunidades +

+

Arrastra las tarjetas entre etapas del embudo.

+
+
+ {#if pipelines.length > 1} + + {/if} + +
+
+ + {#if !companyId} + Selecciona una compañía. + {:else if loading} +

Cargando pipeline…

+ {:else if currentStages.length === 0} + + +

Aún no tienes un embudo con etapas.

+ +
+
+ {:else} +
+ {#each currentStages as stage (stage.id)} +
e.preventDefault()} + ondrop={(e) => onDrop(e, stage.id)} + > +
+ {stage.name} + + {oppsForStage(stage.id).length} + +
+
{formatMoney(stageTotal(stage.id))}
+
+ {#each oppsForStage(stage.id) as opp (opp.id)} +
onDragStart(e, opp.id)} + > +

{opp.name}

+ {#if accountName(opp.account_id)} +

{accountName(opp.account_id)}

+ {/if} +
+ {formatMoney(opp.amount, opp.currency)} + {#if opp.status === 'won'} + Ganada + {:else if opp.status === 'lost'} + Perdida + {:else if opp.probability !== null} + {opp.probability}% + {/if} +
+
+ {/each} +
+
+ {/each} +
+ {/if} +
+ +{#if modalOpen} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/prospectos/+page.svelte b/frontend/src/routes/dashboard/crm/prospectos/+page.svelte new file mode 100644 index 0000000..99de7a1 --- /dev/null +++ b/frontend/src/routes/dashboard/crm/prospectos/+page.svelte @@ -0,0 +1,257 @@ + + +
+
+
+

+ + Prospectos +

+

Leads sin calificar. Conviértelos cuando avancen.

+
+ +
+ + + +
+ + +
+
+ + {#if loading} +

Cargando…

+ {:else if filtered.length === 0} +

Sin prospectos registrados.

+ {:else} +
+ + + + Nombre + Empresa + Origen + Estado + Valor est. + Acciones + + + + {#each filtered as l (l.id)} + + + {l.name} + {#if l.contact_name}{l.contact_name}{/if} + + {l.company_name ?? '—'} + {labelOf(LEAD_SOURCES, l.source)} + + + {labelOf(LEAD_STATUS, l.status)} + + + {formatMoney(l.estimated_value)} + + + + + + + {/each} + + +
+ {/if} +
+
+
+ +{#if modalOpen} + +{/if}