merca y ch

This commit is contained in:
Marcelo
2026-03-31 13:21:48 +00:00
commit 773bfab393
326 changed files with 52705 additions and 0 deletions

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

BIN
PDF KPI breakdown.pdf Normal file

Binary file not shown.

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# Casa Benell - Dashboard ejecutivo
Dashboard en Next.js + TypeScript para Casa Benell, con base PostgreSQL para auth/roles/invitaciones.
## Stack
- Next.js (App Router) + TypeScript
- TailwindCSS
- Recharts (Sankey)
- Zustand (estado UI persistido en `localStorage`)
- Prisma + PostgreSQL
- NextAuth (credentials + adapter Prisma)
- Nodemailer (SMTP invitations)
## Ejecutar
```bash
npm install
npm run dev
```
Abre `https://benell.maliountech.com.mx`.
## Configuración backend (Fase 3/4)
1. Crea tu archivo de entorno:
```bash
cp .env.example .env.local
```
2. Ajusta valores reales (DB, `NEXTAUTH_SECRET`, SMTP).
Importante: define `APP_URL` con `https://benell.maliountech.com.mx` para enlaces por correo.
3. Genera cliente Prisma y aplica migraciones:
```bash
npm run prisma:generate
npm run prisma:migrate
npm run prisma:seed
```
Para servidor (sin permisos de create database), usa:
```bash
npm run prisma:deploy
```
4. Inicia el proyecto:
```bash
npm run dev
```
Build de producción:
```bash
npm run build
npm run start
```
## Sync automático Meta (Marketing) diario 06:00
1. Define en `.env` o `.env.local`:
```bash
MARKETING_SYNC_CRON_SECRET="un-secreto-largo"
INSTAGRAM_TOKEN="..."
INSTAGRAM_USER_ID="..."
```
2. Instala unidades `systemd` (si despliegas con `systemd`):
```bash
sudo cp deploy/systemd/benell-marketing-sync.service /etc/systemd/system/
sudo cp deploy/systemd/benell-marketing-sync.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now benell-marketing-sync.timer
```
3. Verifica:
```bash
systemctl status benell-marketing-sync.timer
systemctl list-timers | grep benell-marketing-sync
```
Nota: `OnCalendar=*-*-* 06:00:00` usa la zona horaria del servidor.
## Estructura relevante
- `src/app`: rutas App Router (`/login`, `/dashboard`, `/financial-flow`, etc.)
- `src/components`: shell y componentes UI reutilizables
- `src/lib/mock`: datos mock tipados (`locations`, `departments`, `kpis`, `initiatives`, `meetings`, `people`)
- `src/lib/store/ui-store.ts`: rol/filtros globales (persistidos)
- `src/styles/tokens.ts`: tokens de diseño (paleta, radios, sombras, spacing)
- `public/brand/logo.webp`, `public/brand/mascot.png`: assets de marca
- `prisma/schema.prisma`: modelos DB (users, roles, user_roles, invitations, next-auth tables)
- `src/app/api/invitations/route.ts`: crear invitaciones + envío SMTP
- `src/app/api/invitations/accept/route.ts`: aceptar invitación y crear/actualizar usuario
- `src/app/api/auth/register/route.ts`: crear cuenta y enviar verificación por correo
- `src/app/api/auth/verify-email/route.ts`: validar token de verificación y activar login
## Estado UI (sin backend)
- Rol de vista en topbar es informativo y se toma de la sesión autenticada (no editable en UI).
- Filtros de rango de fecha y ubicación en topbar
- Búsqueda global local para filtrar listas visibles
## Estado backend actual
1. Login con credenciales reales desde PostgreSQL.
2. Rutas de app protegidas con middleware (`/dashboard`, `/financial-flow`, etc).
3. Rol en sesión (`owner|leader|employee`) como fuente de verdad para la UI.
4. Invitaciones por email con token seguro hash + expiración + aceptación.
5. Botón "Crear cuenta" y verificación de correo antes de login.
6. Recuperación de contraseña por correo (`/forgot-password` + `/reset-password`).
## Pendiente
1. Migrar KPIs y métricas mock a tablas reales.
2. Agregar UI admin para listar/reenviar invitaciones.
3. Fortalecer manejo de errores SMTP/DB en panel de configuración.

View File

@@ -0,0 +1,55 @@
### Weekly KPI Board V1 (Owner Dashboard + Scoped Leader View + PDF Export)
### Summary
- Build a new weekly KPI visualization layer on the main dashboard using the KPI structure from `PDF KPI breakdown.pdf` (responsabilidad, objetivo/indicador, quantity/quality, % cumplimiento, fecha/compromiso).
- Power V1 with weekly snapshot data in Prisma (seeded from the extracted PDF rows first), exposed via internal API.
- Show full board to `owner`, scoped board to `leader` by department, and keep `employee` without dashboard access.
- Include a print-optimized export flow for “Save as PDF”.
### Implementation Changes
- Add weekly KPI persistence models in [schema.prisma](/home/mdares03/benell/prisma/schema.prisma):
- `WeeklyKpiSnapshot` (weekStart, weekEnd, source, timestamps).
- `WeeklyKpiSection` (raw section label, optional mapped `DepartmentKey`, owner/team labels, sort order).
- `WeeklyKpiRow` (KPI text fields + parsed compliance/value fields + derived status + sort order).
- Seed initial snapshot from the PDF baseline into the week of **March 1622, 2026** (PDF creation date is March 18, 2026), preserving raw labels exactly.
- Add KPI API surface:
- `GET /api/kpis/weekly?weekStart=YYYY-MM-DD` (grouped board payload with role-based scoping).
- `POST /api/kpis/weekly/ingest` (upsert snapshot payload for future platform-generated data).
- `PATCH /api/kpis/weekly/rows/:id` (owner + relevant leader edits for label/target/compliance text corrections).
- Update access control so `/dashboard` is allowed for `owner` and `leader` (leaders receive department-scoped dataset).
- Extend [dashboard/page.tsx](/home/mdares03/benell/src/app/(app)/dashboard/page.tsx) with a hybrid KPI board:
- Header KPI summary cards (total rows, on-track %, at-risk count, due-soon count).
- Department/section blocks with sortable table rows and visual status chips/progress bars.
- Search + status + section filters.
- Add print export mode:
- Print-focused dashboard route/view with A4 CSS and hidden interactive controls.
- “Exportar PDF” button triggers print flow (`window.print`) for browser Save-as-PDF.
### Public APIs / Interfaces / Types
- New Prisma entities: `WeeklyKpiSnapshot`, `WeeklyKpiSection`, `WeeklyKpiRow`.
- New shared DTOs for KPI board payload (`KpiBoardResponse`, `KpiSectionDTO`, `KpiRowDTO`).
- New status enum for visualization and filtering (`on_track`, `watch`, `risk`, `no_score`).
- Ingest contract supports raw text + optional parsed fields so platform integration can incrementally mature without breaking UI.
### Test Plan
- Unit tests:
- compliance/value parser from mixed text (`"90%"`, `"800,000"`, `"PLAN 50%"`).
- row status derivation and due-soon/risk classification.
- API tests:
- authz for owner/leader/employee.
- leader scoping returns only mapped department sections.
- ingest upsert idempotency by `(weekStart, section, row key)`.
- UI tests:
- filters/sorting behavior and risk highlighting.
- dashboard renders sectioned KPI table without text overflow.
- print view renders cleanly in desktop/mobile and produces expected PDF layout.
- Regression checks:
- existing non-KPI dashboard blocks still render.
- projects/marketing routes unaffected.
### Assumptions And Defaults
- Week cadence is MondaySunday in `America/Mexico_City`.
- V1 uses seeded PDF-derived data plus API-ready schema; external platform wiring uses the ingest endpoint in the next step.
- Raw PDF labels remain source of truth in V1; optional enum mapping is additive.
- Leader visibility is department-scoped only; owner is global.
- PDF export in V1 is print-layout based (not backend file rendering).

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Casa Benell Marketing Meta Daily Sync
After=network.target benell.service
Wants=benell.service
[Service]
Type=oneshot
User=mdares03
Group=mdares03
WorkingDirectory=/home/mdares03/benell
EnvironmentFile=-/home/mdares03/benell/.env
EnvironmentFile=-/home/mdares03/benell/.env.local
ExecStart=/bin/bash -lc 'set -euo pipefail; test -n "${MARKETING_SYNC_CRON_SECRET:-}"; curl -fsS -X POST "http://127.0.0.1:${PORT:-3000}/api/marketing/meta/sync" -H "Authorization: Bearer ${MARKETING_SYNC_CRON_SECRET}" -H "Content-Type: application/json" --data "{}"'
StandardOutput=journal
StandardError=journal

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Run Casa Benell Marketing Meta Sync every day at 06:00
[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
RandomizedDelaySec=120
Unit=benell-marketing-sync.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,21 @@
[Unit]
Description=Casa Benell Next.js App
After=network.target
[Service]
Type=simple
User=mdares03
Group=mdares03
WorkingDirectory=/home/mdares03/benell
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/npm run start -- --port=3000 --hostname=0.0.0.0
Restart=always
RestartSec=5
TimeoutStopSec=30
KillSignal=SIGINT
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

185
dev_plan_1.md Normal file
View File

@@ -0,0 +1,185 @@
<!-- --># Casa Benell - Dev Plan 1
## Goal
Stabilize UI quality (responsive + proportional typography), adopt final brand assets, and start backend foundations for users/roles/invitations with PostgreSQL and SMTP.
## Current pain points (from first pass)
- Typography scale is too large in several views.
- Numeric values overflow cards and break layout.
- Desktop-first layout is not fully adaptive on mobile.
- Sidebar needs a true mobile hamburger/sheet pattern.
- Brand assets in use are placeholders, not final logo/character files.
- No DB/auth/invite flow yet (all role switching is local UI state).
## Inputs confirmed
- Final logo source: `/home/mdares03/benell/example_ui_img/benell_logo.webp`
- Theme character source: `/home/mdares03/benell/example_ui_img/Logo Benito.png`
## Implementation strategy (phased)
### Phase 1 - Responsive UI hardening
Scope:
- Normalize type scale and spacing so content always fits card boundaries.
- Prevent number overflow in KPI cards, tables, and right panels.
- Add mobile nav with hamburger + slide-out menu.
- Improve responsive behavior for top bar filters/search/role selector.
Tasks:
1. Define responsive typography tokens with `clamp(...)` and semantic utility classes.
2. Replace oversized heading/body classes with tokenized scale:
- H1/H2/H3/body/label/caption.
3. Apply overflow-safe text rules:
- `min-w-0` in flex/grid children.
- `truncate`/`break-words` where needed.
- `tabular-nums` for KPI numeric alignment.
4. Refactor cards/tables to use adaptive grids:
- desktop: 4 columns where available
- tablet: 2 columns
- mobile: 1 column
5. Build mobile sidebar pattern:
- top-left hamburger button
- drawer/sheet navigation
- keyboard and focus support
6. Make Sankey containers responsive with controlled heights by breakpoint.
7. Validate at widths: 360, 390, 768, 1024, 1280, 1536.
Acceptance criteria:
- No text or numbers overflow containers at target breakpoints.
- Dashboard, Financial Flow, Meetings, People remain usable on mobile.
- Navigation works via sidebar (desktop) and hamburger drawer (mobile).
---
### Phase 2 - Final brand asset integration
Scope:
- Replace placeholder brand files with provided official assets.
Tasks:
1. Copy and normalize assets into `/public/brand/`:
- `logo.webp` from `benell_logo.webp`
- `mascot.png` from `Logo Benito.png`
2. Update components to use final files in:
- sidebar/header brand block
- login page
- empty states
3. Verify object-fit/crop so assets render clean on desktop + mobile.
Acceptance criteria:
- Only final brand assets are used in app shell and empty states.
- No stretching, clipping, or quality loss in common viewport sizes.
---
### Phase 3 - PostgreSQL foundation + RBAC data model
Scope:
- Start real user data layer with psql.
- Keep mock business metrics for now, but migrate auth/user/role from local store to DB-backed model.
Tech decisions (proposed):
- ORM: Prisma
- DB: PostgreSQL
- Auth/session: NextAuth/Auth.js with DB adapter
- Password hashing: bcrypt
- Email: Nodemailer SMTP transport
Core schema (initial):
1. `users`
- `id`, `name`, `email` (unique), `password_hash`, `status`, `created_at`, `updated_at`
2. `roles`
- `id`, `key` (`owner|leader|employee`), `name`
3. `user_roles`
- `user_id`, `role_id` (many-to-many)
4. `invitations`
- `id`, `email`, `role_key`, `token_hash`, `expires_at`, `accepted_at`, `invited_by`, `created_at`
5. `user_locations` (optional in this phase, recommended)
- `user_id`, `location_id`
Tasks:
1. Add Prisma and configure `DATABASE_URL`.
2. Create schema + first migration.
3. Add seed script for default roles and bootstrap owner account.
4. Add auth route handlers and session callback to include role.
5. Add middleware route protection for authenticated app routes.
6. Replace UI-only role switcher with role from session (keep optional dev override flag).
Acceptance criteria:
- App can connect to PostgreSQL locally.
- At least one owner user can login from DB.
- Protected routes reject anonymous requests.
- Role can be read from DB session payload.
---
### Phase 4 - Invitation flow via SMTP
Scope:
- Admin/owner can invite users by email and assign role.
Tasks:
1. Add env variables for SMTP and app URLs.
2. Create server actions or API routes:
- `POST /api/invitations`
- `POST /api/invitations/accept`
3. Generate secure random invitation tokens; store hash only.
4. Send email with invitation link (token + expiry).
5. Create acceptance page:
- set password
- confirm profile basics
- finalize user + role assignment
6. Add invitation status UI in admin/settings page.
Acceptance criteria:
- Owner can send invite with selected role.
- Recipient can accept invite and create account before expiry.
- Used/expired tokens are rejected.
---
### Phase 5 - Operational readiness and migration cleanup
Scope:
- Make deployment process stable under existing systemd flow.
Tasks:
1. Add `.env.example` with required vars only (no secrets).
2. Add startup checks for missing env values.
3. Document production sequence:
- `npm run build`
- `sudo systemctl restart benell.service`
4. Add backup/rollback notes for DB migrations.
Acceptance criteria:
- Clear runbook for local/prod env setup.
- No manual guessing for env keys or migration order.
## Environment variables to add (planned)
- `DATABASE_URL=postgresql://...`
- `NEXTAUTH_URL=https://benell.maliountech.com.mx`
- `NEXTAUTH_SECRET=...`
- `SMTP_HOST=...`
- `SMTP_PORT=587`
- `SMTP_USER=...`
- `SMTP_PASS=...`
- `SMTP_FROM="Casa Benell <noreply@...>"`
- `INVITE_TTL_HOURS=72`
## Risks and mitigations
- Risk: auth migration blocks UI progress.
- Mitigation: complete UI responsive fixes first, then backend integration.
- Risk: role logic duplicated in client and server.
- Mitigation: single source of truth from session role, with temporary dev override only.
- Risk: invite token leakage.
- Mitigation: store hashed tokens, set short expiry, single-use tokens.
## Execution order
1. Phase 1 (UI hardening)
2. Phase 2 (brand assets)
3. Phase 3 (DB + auth + RBAC)
4. Phase 4 (SMTP invites)
5. Phase 5 (runbook + stability)
## Deliverables for this plan
- Responsive and proportional UI baseline.
- Mobile hamburger navigation.
- Final branding applied.
- PostgreSQL user/role foundation.
- SMTP invitation workflow.
- Updated docs for env and deployment.

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

293
experiencometro.md Normal file
View File

@@ -0,0 +1,293 @@
Quiero que me ayudes a diseñar e implementar un apartado separado dentro de mi plataforma llamado Experienciómetro.
Contexto del negocio
El Experienciómetro es un módulo pensado para medir la experiencia que vive un cliente en cada sucursal/local de la marca. La idea no es solo calificar si “lo atendieron bien”, sino evaluar todo el recorrido del cliente dentro y alrededor de la sucursal: desde la llegada, la imagen exterior, la bienvenida, el ambiente, el servicio, la limpieza y los detalles que hacen que la experiencia suba o baje.
Este módulo debe funcionar como una mezcla entre:
dashboard de sucursales
sistema de evaluación tipo survey/checklist
scoring/rating por sucursal
seguimiento de hallazgos detectados
La intención es que cada sucursal tenga una calificación visible y dinámica, alimentada por evaluaciones que llenan ciertos usuarios internos (por ejemplo padrinos de casa o responsables operativos). Esa calificación debe representar la experiencia general de la sucursal.
Objetivo del módulo
Construir un apartado independiente dentro de la plataforma donde:
Se puedan ver todas las sucursales con su score de experiencia.
Se pueda entrar al detalle de cada sucursal.
Exista una encuesta/checklist estructurada para evaluar la experiencia.
Cada evaluación impacte automáticamente la calificación general de la sucursal.
Se puedan registrar observaciones, hallazgos y áreas de mejora.
El gerente o responsable pueda ver retroalimentación clara de su sucursal.
Concepto del Experienciómetro
El módulo debe representar la experiencia del cliente como un sistema medible.
La experiencia no depende de una sola cosa, sino de varias dimensiones operativas.
Las categorías base a evaluar serían:
Exterior y llegada
limpieza exterior
fachada
acceso
señalización
Entrada y bienvenida
puerta abierta / acceso disponible
recepción
saludo
actitud
tiempo de respuesta
Ambiente
orden visual
aroma
música
limpieza general
temperatura
mobiliario / confort
Servicio
rapidez
amabilidad
seguimiento
atención al detalle
actitud del personal
Hospitalidad / detalles extra
detalles memorables
anticipación a necesidades
pequeños extras que elevan la experiencia
Operación visible
baños
mesas / áreas comunes
objetos fuera de lugar
mantenimiento visible
Cada evaluación debe convertirse en un score cuantificable.
Cómo debe funcionar el scoring
Cada categoría tiene varios reactivos/preguntas.
Cada reactivo debe calificarse con una escala simple, por ejemplo:
0 = mal
1 = regular
2 = bien
3 = excelente
El sistema debe:
calcular un score por categoría
calcular un score general por evaluación
actualizar el score general histórico de la sucursal
La calificación general de la sucursal puede ser un promedio de evaluaciones recientes o un promedio ponderado. Diseña la estructura para que esto se pueda ajustar fácilmente después.
Flujo del módulo
1. Vista general del Experienciómetro
Debe existir una pantalla principal separada llamada Experienciómetro.
En esta vista se deben mostrar todas las sucursales como cards o filas de tabla con información como:
nombre de sucursal
score general actual
color o semáforo
última evaluación realizada
cantidad de evaluaciones
tendencia (subió, bajó o se mantuvo)
hallazgos abiertos
Esta pantalla debe permitir:
buscar sucursales
filtrar por score
filtrar por estatus/semaforo
entrar al detalle de cada sucursal
iniciar una nueva evaluación
2. Detalle de sucursal
Cada sucursal debe tener una vista individual donde se vea:
Header
nombre de sucursal
score actual
semáforo visual
última evaluación
gerente / responsable
cantidad total de evaluaciones
Secciones principales
resumen general
historial de evaluaciones
breakdown por categorías
hallazgos / observaciones
acciones o retroalimentación
Visualización recomendada
score general grande
barras por categoría
gráfica de tendencia histórica
lista de últimas evaluaciones
lista de hallazgos abiertos
3. Formulario de evaluación / survey
Debe existir una pantalla o modal para llenar una nueva evaluación.
Esta evaluación debe estar organizada por bloques/categorías:
Ejemplo de bloques
Exterior y llegada
fachada limpia
acceso libre
señalización clara
Bienvenida
puerta abierta
alguien recibe al cliente
saludo adecuado
actitud positiva
Ambiente
música adecuada
aroma agradable
limpieza visual
orden general
temperatura cómoda
Servicio
rapidez
atención
seguimiento
trato amable
Hospitalidad
hubo algún detalle extra
el servicio fue memorable
se anticiparon necesidades
Operación visible
baños limpios
áreas comunes ordenadas
mantenimiento visible correcto
Cada reactivo debe permitir:
seleccionar score
agregar comentario opcional
marcar observación
adjuntar evidencia si aplica
Al final del formulario:
observaciones generales
fortalezas detectadas
áreas de mejora
score automático calculado por el sistema
4. Impacto en la calificación de la sucursal
Cada vez que se envía una evaluación:
se guarda el registro completo
se recalcula el score promedio de la sucursal
se actualiza la vista general
se reflejan las nuevas métricas en dashboard y detalle
5. Retroalimentación y hallazgos
Además del survey, el módulo debe permitir guardar hallazgos/observaciones.
Ejemplos:
baño sucio
recepción vacía
bocina sin funcionar
objeto fuera de lugar
mala presentación visual
Cada hallazgo puede tener:
título
descripción
categoría
prioridad
estatus
fecha
responsable
relación con una evaluación específica
Esto servirá para que el gerente vea retroalimentación más accionable, no solo un número.
Cómo debe verse en UI/UX
Vista principal
Quiero un módulo visual, ejecutivo y fácil de leer.
Debe sentirse como un dashboard operativo moderno.
Elementos visuales sugeridos
cards por sucursal
score grande y destacado
badges tipo semáforo:
verde = excelente
amarillo = atención
rojo = crítico
barras de progreso por categoría
gráficas de tendencia
tabla o timeline de evaluaciones
Navegación
El Experienciómetro debe ser un apartado separado del resto de la plataforma, no algo escondido dentro de otra sección.
Idealmente tendría navegación como:
Experienciómetro / Resumen
Sucursales
Evaluaciones
Hallazgos
Requerimientos funcionales
Diseña este módulo con arquitectura limpia y escalable.
Debe contemplar:
listado de sucursales
detalle de sucursal
formulario de evaluación
historial de evaluaciones
cálculo automático de score
almacenamiento de comentarios y observaciones
visualización de hallazgos
dashboard general
Requerimientos técnicos
Quiero que propongas la mejor forma de estructurarlo en frontend y backend.
Necesito que plantees:
componentes necesarios
vistas/páginas
modelos de datos
lógica de score
relaciones entre sucursal, evaluación, categorías y hallazgos
Piensa el módulo de forma reusable y mantenible.
Resultado esperado
Quiero que me entregues una propuesta completa para este módulo, incluyendo:
estructura del apartado
flujo del usuario
componentes de interfaz
modelo de datos recomendado
lógica de scoring
propuesta visual del dashboard
cómo conectar la evaluación con la calificación general de cada sucursal
No uses datos hardcodeados en la solución final.
Diseña todo para que sea dinámico y escalable.

42
middleware.ts Normal file
View File

@@ -0,0 +1,42 @@
import { withAuth } from "next-auth/middleware";
import { NextResponse } from "next/server";
import { canAccessPath, getDepartmentHomeRoute } from "@/lib/access-control";
import type { DepartmentKey, UserRole } from "@/lib/types";
export default withAuth(
function middleware(req) {
const token = req.nextauth.token;
const role = token?.role as UserRole | undefined;
const department = (token?.department as DepartmentKey | null | undefined) ?? null;
const pathname = req.nextUrl.pathname;
if (!canAccessPath({ role, department }, pathname)) {
const fallbackPath = role === "owner" ? "/dashboard" : getDepartmentHomeRoute(department);
const safeFallbackPath = pathname === fallbackPath ? "/settings" : fallbackPath;
return NextResponse.redirect(new URL(safeFallbackPath, req.url));
}
return NextResponse.next();
},
{
pages: {
signIn: "/login",
},
}
);
export const config = {
matcher: [
"/dashboard/:path*",
"/financial-flow/:path*",
"/experienciometro/:path*",
"/departments/:path*",
"/initiatives/:path*",
"/meetings/:path*",
"/people/:path*",
"/data-entry/:path*",
"/settings/:path*",
"/api/invitations",
"/api/experienciometro/:path*",
],
};

6
next.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;

7117
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "casa-benell",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "node --import tsx --test src/lib/**/*.test.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.16.2",
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1",
"lucide-react": "^0.564.0",
"next": "14.2.33",
"next-auth": "^4.24.13",
"nodemailer": "^7.0.13",
"prisma": "^6.16.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^3.7.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^7.0.10",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.33",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tsx": "^4.21.0",
"typescript": "^5"
}
}

84
pending/nextsteps.md Normal file
View File

@@ -0,0 +1,84 @@
# V1.1 Handoff: Done vs Pending
Date: 2026-03-19
## Completed in this pass
### Owner KPI (scan-first)
- [x] Snapshot freshness now updates when KPI rows are edited (`patchWeeklyKpiRow` touches parent snapshot `updatedAt`).
- [x] Owner board metadata now shows `source` together with `lastUpdatedAt` and coverage.
- [x] Drill-down `<details>` sections are no longer forced closed on rerender.
### Human Capital
- [x] Removed write side-effect from `GET /api/human-capital/dashboard` (read no longer auto-publishes KPIs).
- [x] `people` endpoint now requires owner/HC leader (`canManageHumanCapital`) for employee-level visibility.
- [x] `lifecycle-events` GET now requires owner/HC leader.
- [x] `updates` GET now hides non-published items from non-managers.
- [x] CSV export fixed to real newlines (`\n`) instead of escaped `\\n` text.
- [x] People endpoint now calls `ensureEmployeeProfiles()` to reduce first-load/race gaps.
- [x] HC compliance input excludes auto-published HC KPI section (`human-capital-auto-v1`) to prevent KPI self-feedback.
- [x] Churn trend denominator now uses month-specific estimated headcount rather than current headcount for all months.
### Operations / Maintenance
- [x] Removed write side-effect from `GET /api/operations/dashboard` (read no longer auto-publishes KPIs).
- [x] Added owner approval action endpoint: `PATCH /api/operations/approvals/[id]`.
- [x] Added policy endpoint: `GET/PATCH /api/operations/policy`.
- [x] Work-order read/list is department-scoped.
- [x] Template read/list is department-scoped.
- [x] Reminder read/list is department-scoped.
- [x] Asset update/delete now validates department ownership before mutating.
- [x] Work-order patch now validates department scope and blocks non-owner state transitions when owner approval is pending.
- [x] Production-plan patch now validates department scope and enforces owner-only transitions for `approved/released`.
- [x] Production-plan create enforces owner-only for privileged states and sets approval metadata accordingly.
- [x] Maintenance policy bootstrap now backfills missing owner approver when possible.
- [x] Work-order create now validates asset/template department ownership and resolves owner approver fallback.
- [x] Operations dashboard payload now includes `workOrderId` / `productionPlanId` in approval inbox entries.
- [x] Operations UI owner inbox now has Approve/Reject actions wired to approval API.
- [x] Operations UI now shows down-assets in summary and an upcoming maintenance list.
### Marketing
- [x] Meta connection/disconnect now avoids inactive-row unique collisions by clearing stale inactive rows before state flips.
- [x] Meta sync now attempts both Facebook and Instagram ingestion (best-effort Instagram discovery/fetch).
- [x] Social snapshot writes now dedupe same `department+channel+range+capturedAt` before create.
- [x] SocialPanel no longer computes local fallback growth math from snapshots; it displays server insights only.
- [x] Initiative drawer now includes attribution fields in UI: channel, page ID, campaign tag, date window.
- [x] Initiative drawer includes "Use auto values" action and displays last auto-fill summary.
- [x] Marketing scoring and initiative mapping now consume auto-attributed actuals when manual values are still zero.
- [x] Sync auto-fill now respects attribution page/date windows; campaign-tagged records are intentionally skipped until campaign-level data is ingested.
### Database / schema
- [x] Prisma migration created: `prisma/migrations/20260319180300_0012_v11_scan_first_owner_department_dashboards/migration.sql`.
## Validation status
- [x] `npm test` passes (19/19).
- [x] `npm run build` passes.
- [ ] Recharts static-generation warnings remain (`width(-1)/height(-1)`), non-blocking but should be cleaned later.
## Remaining / Follow-up items
### High-priority follow-up
- [ ] Add campaign-level attribution ingestion for Meta sync (currently campaign-tagged initiatives are skipped to avoid bad attribution).
- [ ] Add explicit CRUD coverage for operations entities still missing full lifecycle endpoints (e.g., delete/update routes for every list entity as originally envisioned).
- [ ] Add automated API tests for new authorization hardening and owner-approval transition guards.
### Medium-priority follow-up
- [ ] Add UI controls for operations policy thresholds (`costThreshold`, `downtimeThresholdHours`, approver owner).
- [ ] Improve Instagram metrics quality (current implementation is best-effort and may use limited fields depending on account permissions).
- [ ] Normalize/chunk dashboard chart containers to remove Recharts build-time width warnings.
### Optional / V1.2 candidates
- [ ] Improve attribution model to support campaign/source dimensions end-to-end in stored metrics.
- [ ] Add audit trail events for owner approval actions (who/when/why) in UI timeline.
- [ ] Add integration tests for Meta sync -> initiative auto-fill -> publish -> owner KPI chain.
## Quick resume commands
```bash
cd /home/mdares03/benell
npm test
npm run build
```
## Notes for next person
- Projects module was intentionally left unchanged per V1.1 scope.
- Department dashboards now feed owner KPI via explicit publish endpoints (read endpoints no longer mutate KPI data).

8
postcss.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,135 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "public"."RoleKey" AS ENUM ('owner', 'leader', 'employee');
-- CreateTable
CREATE TABLE "public"."User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT NOT NULL,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"passwordHash" TEXT,
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Role" (
"id" TEXT NOT NULL,
"key" "public"."RoleKey" NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."UserRole" (
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("userId","roleId")
);
-- CreateTable
CREATE TABLE "public"."Invitation" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"roleKey" "public"."RoleKey" NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"invitedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Invitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "public"."User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Role_key_key" ON "public"."Role"("key");
-- CreateIndex
CREATE UNIQUE INDEX "Invitation_tokenHash_key" ON "public"."Invitation"("tokenHash");
-- CreateIndex
CREATE INDEX "Invitation_email_expiresAt_idx" ON "public"."Invitation"("email", "expiresAt");
-- CreateIndex
CREATE INDEX "Account_userId_idx" ON "public"."Account"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "public"."Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "public"."Session"("sessionToken");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "public"."Session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "public"."VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "public"."VerificationToken"("identifier", "token");
-- AddForeignKey
ALTER TABLE "public"."UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "public"."Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Invitation" ADD CONSTRAINT "Invitation_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,19 @@
-- CreateEnum
CREATE TYPE "public"."DepartmentKey" AS ENUM ('marketing', 'administracion', 'capital_humano', 'operaciones');
-- AlterTable
ALTER TABLE "public"."User"
ADD COLUMN "department" "public"."DepartmentKey",
ADD COLUMN "departmentRole" TEXT;
-- AlterTable
ALTER TABLE "public"."Invitation"
ADD COLUMN "inviteeName" TEXT NOT NULL DEFAULT 'Usuario invitado',
ADD COLUMN "department" "public"."DepartmentKey" NOT NULL DEFAULT 'marketing',
ADD COLUMN "departmentRole" TEXT NOT NULL DEFAULT 'Miembro';
-- RemoveDefaults
ALTER TABLE "public"."Invitation"
ALTER COLUMN "inviteeName" DROP DEFAULT,
ALTER COLUMN "department" DROP DEFAULT,
ALTER COLUMN "departmentRole" DROP DEFAULT;

View File

@@ -0,0 +1,48 @@
-- CreateEnum
CREATE TYPE "public"."MarketingMeetingStatus" AS ENUM ('requested', 'scheduled', 'completed', 'cancelled');
-- CreateTable
CREATE TABLE "public"."MarketingMeeting" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL,
"title" TEXT NOT NULL,
"agenda" TEXT NOT NULL,
"status" "public"."MarketingMeetingStatus" NOT NULL DEFAULT 'requested',
"requestedById" TEXT,
"requestedByName" TEXT NOT NULL,
"participantNames" JSONB NOT NULL,
"suggestedTimes" JSONB NOT NULL,
"scheduledFor" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketingMeeting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingCommitment" (
"id" TEXT NOT NULL,
"meetingId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"ownerName" TEXT,
"dueDate" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'pendiente',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketingCommitment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MarketingMeeting_department_status_scheduledFor_idx" ON "public"."MarketingMeeting"("department", "status", "scheduledFor");
-- CreateIndex
CREATE INDEX "MarketingCommitment_meetingId_dueDate_idx" ON "public"."MarketingCommitment"("meetingId", "dueDate");
-- AddForeignKey
ALTER TABLE "public"."MarketingMeeting" ADD CONSTRAINT "MarketingMeeting_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingCommitment" ADD CONSTRAINT "MarketingCommitment_meetingId_fkey" FOREIGN KEY ("meetingId") REFERENCES "public"."MarketingMeeting"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "public"."PasswordResetToken" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "public"."PasswordResetToken"("tokenHash");
-- CreateIndex
CREATE INDEX "PasswordResetToken_email_expiresAt_idx" ON "public"."PasswordResetToken"("email", "expiresAt");

View File

@@ -0,0 +1,216 @@
-- CreateEnum
CREATE TYPE "public"."MarketingInitiativeType" AS ENUM ('evento', 'campania', 'cambio', 'implementacion', 'otro');
-- CreateEnum
CREATE TYPE "public"."MarketingInitiativeStatus" AS ENUM ('planning', 'in_progress', 'completion', 'results', 'evaluation');
-- CreateEnum
CREATE TYPE "public"."MarketingTaskStatus" AS ENUM ('todo', 'in_progress', 'blocked', 'done');
-- CreateEnum
CREATE TYPE "public"."MarketingPublicOpinion" AS ENUM ('positive', 'mixed', 'negative');
-- CreateTable
CREATE TABLE "public"."MarketingInitiative" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL,
"name" TEXT NOT NULL,
"type" "public"."MarketingInitiativeType" NOT NULL,
"status" "public"."MarketingInitiativeStatus" NOT NULL DEFAULT 'planning',
"ownerId" TEXT,
"dueDate" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"importanceWeight" DOUBLE PRECISION NOT NULL DEFAULT 1,
"leadRating1to5" INTEGER NOT NULL DEFAULT 3,
"target" DOUBLE PRECISION NOT NULL DEFAULT 0,
"actual" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ticketsTarget" INTEGER NOT NULL DEFAULT 0,
"ticketsActual" INTEGER NOT NULL DEFAULT 0,
"revenueTarget" DOUBLE PRECISION NOT NULL DEFAULT 0,
"revenueActual" DOUBLE PRECISION NOT NULL DEFAULT 0,
"opinionPublica" "public"."MarketingPublicOpinion" NOT NULL DEFAULT 'mixed',
"queFunciono" TEXT NOT NULL DEFAULT '',
"queNo" TEXT NOT NULL DEFAULT '',
"proximoIntento" TEXT NOT NULL DEFAULT '',
"updatedById" TEXT,
"updatedByName" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketingInitiative_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingInitiativeContributor" (
"initiativeId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketingInitiativeContributor_pkey" PRIMARY KEY ("initiativeId","userId")
);
-- CreateTable
CREATE TABLE "public"."MarketingInitiativeLocation" (
"id" TEXT NOT NULL,
"initiativeId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketingInitiativeLocation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingInitiativeEvidence" (
"id" TEXT NOT NULL,
"initiativeId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketingInitiativeEvidence_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingTask" (
"id" TEXT NOT NULL,
"initiativeId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"assigneeId" TEXT,
"status" "public"."MarketingTaskStatus" NOT NULL DEFAULT 'todo',
"dueDate" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketingTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingTaskEvidence" (
"id" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketingTaskEvidence_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingInitiativeEdit" (
"id" TEXT NOT NULL,
"initiativeId" TEXT NOT NULL,
"editedById" TEXT,
"editedByName" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketingInitiativeEdit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingSocialSnapshot" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL,
"channel" TEXT NOT NULL,
"range" TEXT NOT NULL,
"followersStart" INTEGER NOT NULL,
"followersEnd" INTEGER NOT NULL,
"engagementRate" DOUBLE PRECISION NOT NULL,
"reach" INTEGER NOT NULL,
"impressions" INTEGER NOT NULL,
"capturedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketingSocialSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingBrandPulse" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL,
"month" TEXT NOT NULL,
"rating1to5" INTEGER NOT NULL,
"notes" TEXT NOT NULL DEFAULT '',
"updatedById" TEXT,
"updatedByName" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketingBrandPulse_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MarketingInitiative_department_status_dueDate_idx" ON "public"."MarketingInitiative"("department", "status", "dueDate");
-- CreateIndex
CREATE INDEX "MarketingInitiativeContributor_userId_idx" ON "public"."MarketingInitiativeContributor"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "MarketingInitiativeLocation_initiativeId_locationId_key" ON "public"."MarketingInitiativeLocation"("initiativeId", "locationId");
-- CreateIndex
CREATE INDEX "MarketingInitiativeLocation_locationId_idx" ON "public"."MarketingInitiativeLocation"("locationId");
-- CreateIndex
CREATE INDEX "MarketingInitiativeEvidence_initiativeId_createdAt_idx" ON "public"."MarketingInitiativeEvidence"("initiativeId", "createdAt");
-- CreateIndex
CREATE INDEX "MarketingTask_initiativeId_status_dueDate_idx" ON "public"."MarketingTask"("initiativeId", "status", "dueDate");
-- CreateIndex
CREATE INDEX "MarketingTask_assigneeId_idx" ON "public"."MarketingTask"("assigneeId");
-- CreateIndex
CREATE INDEX "MarketingTaskEvidence_taskId_createdAt_idx" ON "public"."MarketingTaskEvidence"("taskId", "createdAt");
-- CreateIndex
CREATE INDEX "MarketingInitiativeEdit_initiativeId_createdAt_idx" ON "public"."MarketingInitiativeEdit"("initiativeId", "createdAt");
-- CreateIndex
CREATE INDEX "MarketingSocialSnapshot_department_range_channel_capturedAt_idx" ON "public"."MarketingSocialSnapshot"("department", "range", "channel", "capturedAt");
-- CreateIndex
CREATE INDEX "MarketingBrandPulse_department_month_idx" ON "public"."MarketingBrandPulse"("department", "month");
-- AddForeignKey
ALTER TABLE "public"."MarketingInitiative" ADD CONSTRAINT "MarketingInitiative_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingInitiative" ADD CONSTRAINT "MarketingInitiative_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingInitiativeContributor" ADD CONSTRAINT "MarketingInitiativeContributor_initiativeId_fkey" FOREIGN KEY ("initiativeId") REFERENCES "public"."MarketingInitiative"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingInitiativeContributor" ADD CONSTRAINT "MarketingInitiativeContributor_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingInitiativeLocation" ADD CONSTRAINT "MarketingInitiativeLocation_initiativeId_fkey" FOREIGN KEY ("initiativeId") REFERENCES "public"."MarketingInitiative"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingInitiativeEvidence" ADD CONSTRAINT "MarketingInitiativeEvidence_initiativeId_fkey" FOREIGN KEY ("initiativeId") REFERENCES "public"."MarketingInitiative"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingInitiativeEvidence" ADD CONSTRAINT "MarketingInitiativeEvidence_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingTask" ADD CONSTRAINT "MarketingTask_initiativeId_fkey" FOREIGN KEY ("initiativeId") REFERENCES "public"."MarketingInitiative"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingTask" ADD CONSTRAINT "MarketingTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingTaskEvidence" ADD CONSTRAINT "MarketingTaskEvidence_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "public"."MarketingTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingTaskEvidence" ADD CONSTRAINT "MarketingTaskEvidence_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingInitiativeEdit" ADD CONSTRAINT "MarketingInitiativeEdit_initiativeId_fkey" FOREIGN KEY ("initiativeId") REFERENCES "public"."MarketingInitiative"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingInitiativeEdit" ADD CONSTRAINT "MarketingInitiativeEdit_editedById_fkey" FOREIGN KEY ("editedById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingBrandPulse" ADD CONSTRAINT "MarketingBrandPulse_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,12 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'DepartmentKey'
AND e.enumlabel = 'proyectos'
) THEN
ALTER TYPE "public"."DepartmentKey" ADD VALUE 'proyectos';
END IF;
END $$;

View File

@@ -0,0 +1,49 @@
-- CreateEnum
CREATE TYPE "public"."MarketingMilestoneStatus" AS ENUM ('pending', 'in_progress', 'completed');
-- CreateTable
CREATE TABLE "public"."MarketingMilestone" (
"id" TEXT NOT NULL,
"initiativeId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"dueDate" TIMESTAMP(3) NOT NULL,
"status" "public"."MarketingMilestoneStatus" NOT NULL DEFAULT 'pending',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdById" TEXT,
"createdByName" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketingMilestone_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingMilestoneCheckpoint" (
"id" TEXT NOT NULL,
"milestoneId" TEXT NOT NULL,
"note" TEXT NOT NULL,
"createdById" TEXT,
"createdByName" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketingMilestoneCheckpoint_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MarketingMilestone_initiativeId_dueDate_status_idx" ON "public"."MarketingMilestone"("initiativeId", "dueDate", "status");
-- CreateIndex
CREATE INDEX "MarketingMilestoneCheckpoint_milestoneId_createdAt_idx" ON "public"."MarketingMilestoneCheckpoint"("milestoneId", "createdAt");
-- AddForeignKey
ALTER TABLE "public"."MarketingMilestone" ADD CONSTRAINT "MarketingMilestone_initiativeId_fkey" FOREIGN KEY ("initiativeId") REFERENCES "public"."MarketingInitiative"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingMilestone" ADD CONSTRAINT "MarketingMilestone_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingMilestoneCheckpoint" ADD CONSTRAINT "MarketingMilestoneCheckpoint_milestoneId_fkey" FOREIGN KEY ("milestoneId") REFERENCES "public"."MarketingMilestone"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingMilestoneCheckpoint" ADD CONSTRAINT "MarketingMilestoneCheckpoint_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "public"."MarketingInitiative"
ADD COLUMN "isGlobal" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,4 @@
ALTER TABLE "public"."MarketingInitiative"
ADD COLUMN "trackScore" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "trackTickets" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "trackRevenue" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "MarketingInitiative"
ADD COLUMN IF NOT EXISTS "plannedCost" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "actualCost" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "targetTicketPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "actualTicketPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "projectsSchemaVersion" INTEGER NOT NULL DEFAULT 1;

View File

@@ -0,0 +1,88 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'MeetingResponseStatus') THEN
CREATE TYPE "public"."MeetingResponseStatus" AS ENUM ('pending', 'accepted', 'declined');
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'ProjectCalendarVisibility') THEN
CREATE TYPE "public"."ProjectCalendarVisibility" AS ENUM ('personal', 'team');
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'ProjectCaptureEvidenceKind') THEN
CREATE TYPE "public"."ProjectCaptureEvidenceKind" AS ENUM ('photo', 'document', 'link');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS "public"."MarketingMeetingParticipant" (
"id" TEXT NOT NULL,
"meetingId" TEXT NOT NULL,
"userId" TEXT,
"displayName" TEXT NOT NULL,
"responseStatus" "public"."MeetingResponseStatus" NOT NULL DEFAULT 'pending',
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketingMeetingParticipant_pkey" PRIMARY KEY ("id"),
CONSTRAINT "MarketingMeetingParticipant_meetingId_fkey" FOREIGN KEY ("meetingId") REFERENCES "public"."MarketingMeeting"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "MarketingMeetingParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "public"."ProjectCalendarEvent" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL DEFAULT 'proyectos',
"ownerUserId" TEXT NOT NULL,
"meetingId" TEXT,
"title" TEXT NOT NULL,
"notes" TEXT,
"startAt" TIMESTAMP(3) NOT NULL,
"endAt" TIMESTAMP(3) NOT NULL,
"visibility" "public"."ProjectCalendarVisibility" NOT NULL DEFAULT 'personal',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectCalendarEvent_pkey" PRIMARY KEY ("id"),
CONSTRAINT "ProjectCalendarEvent_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectCalendarEvent_meetingId_fkey" FOREIGN KEY ("meetingId") REFERENCES "public"."MarketingMeeting"("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "public"."ProjectCaptureEvidence" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL DEFAULT 'proyectos',
"initiativeId" TEXT,
"taskId" TEXT,
"uploadedById" TEXT NOT NULL,
"kind" "public"."ProjectCaptureEvidenceKind" NOT NULL,
"title" TEXT NOT NULL,
"note" TEXT,
"url" TEXT,
"storagePath" TEXT,
"mimeType" TEXT,
"sizeBytes" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectCaptureEvidence_pkey" PRIMARY KEY ("id"),
CONSTRAINT "ProjectCaptureEvidence_initiativeId_fkey" FOREIGN KEY ("initiativeId") REFERENCES "public"."MarketingInitiative"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "ProjectCaptureEvidence_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "public"."MarketingTask"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "ProjectCaptureEvidence_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "MarketingMeetingParticipant_meetingId_userId_key" ON "public"."MarketingMeetingParticipant"("meetingId", "userId");
CREATE INDEX IF NOT EXISTS "MarketingMeetingParticipant_meetingId_responseStatus_idx" ON "public"."MarketingMeetingParticipant"("meetingId", "responseStatus");
CREATE INDEX IF NOT EXISTS "MarketingMeetingParticipant_userId_idx" ON "public"."MarketingMeetingParticipant"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "ProjectCalendarEvent_meetingId_key" ON "public"."ProjectCalendarEvent"("meetingId");
CREATE INDEX IF NOT EXISTS "ProjectCalendarEvent_department_startAt_endAt_idx" ON "public"."ProjectCalendarEvent"("department", "startAt", "endAt");
CREATE INDEX IF NOT EXISTS "ProjectCalendarEvent_ownerUserId_startAt_idx" ON "public"."ProjectCalendarEvent"("ownerUserId", "startAt");
CREATE INDEX IF NOT EXISTS "ProjectCaptureEvidence_department_createdAt_idx" ON "public"."ProjectCaptureEvidence"("department", "createdAt");
CREATE INDEX IF NOT EXISTS "ProjectCaptureEvidence_initiativeId_createdAt_idx" ON "public"."ProjectCaptureEvidence"("initiativeId", "createdAt");
CREATE INDEX IF NOT EXISTS "ProjectCaptureEvidence_taskId_createdAt_idx" ON "public"."ProjectCaptureEvidence"("taskId", "createdAt");
CREATE INDEX IF NOT EXISTS "ProjectCaptureEvidence_uploadedById_createdAt_idx" ON "public"."ProjectCaptureEvidence"("uploadedById", "createdAt");

View File

@@ -0,0 +1,60 @@
CREATE TYPE "public"."WeeklyKpiStatus" AS ENUM ('on_track', 'watch', 'risk', 'no_score');
CREATE TABLE "public"."WeeklyKpiSnapshot" (
"id" TEXT NOT NULL,
"weekStart" TIMESTAMP(3) NOT NULL,
"weekEnd" TIMESTAMP(3) NOT NULL,
"source" TEXT NOT NULL DEFAULT 'platform',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WeeklyKpiSnapshot_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "public"."WeeklyKpiSection" (
"id" TEXT NOT NULL,
"snapshotId" TEXT NOT NULL,
"sectionKey" TEXT NOT NULL,
"rawSectionLabel" TEXT NOT NULL,
"mappedDepartment" "public"."DepartmentKey",
"ownerTeamLabel" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WeeklyKpiSection_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "public"."WeeklyKpiRow" (
"id" TEXT NOT NULL,
"sectionId" TEXT NOT NULL,
"rowKey" TEXT NOT NULL,
"responsibilityText" TEXT NOT NULL,
"objectiveIndicatorText" TEXT,
"quantityQualityText" TEXT,
"complianceText" TEXT,
"dueCommitmentText" TEXT,
"targetValue" DOUBLE PRECISION,
"quantityValue" DOUBLE PRECISION,
"compliancePct" DOUBLE PRECISION,
"dueDate" TIMESTAMP(3),
"status" "public"."WeeklyKpiStatus" NOT NULL DEFAULT 'no_score',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WeeklyKpiRow_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "WeeklyKpiSnapshot_weekStart_key" ON "public"."WeeklyKpiSnapshot"("weekStart");
CREATE INDEX "WeeklyKpiSnapshot_weekStart_weekEnd_idx" ON "public"."WeeklyKpiSnapshot"("weekStart", "weekEnd");
CREATE UNIQUE INDEX "WeeklyKpiSection_snapshotId_sectionKey_key" ON "public"."WeeklyKpiSection"("snapshotId", "sectionKey");
CREATE INDEX "WeeklyKpiSection_snapshotId_mappedDepartment_sortOrder_idx" ON "public"."WeeklyKpiSection"("snapshotId", "mappedDepartment", "sortOrder");
CREATE UNIQUE INDEX "WeeklyKpiRow_sectionId_rowKey_key" ON "public"."WeeklyKpiRow"("sectionId", "rowKey");
CREATE INDEX "WeeklyKpiRow_sectionId_sortOrder_idx" ON "public"."WeeklyKpiRow"("sectionId", "sortOrder");
CREATE INDEX "WeeklyKpiRow_status_dueDate_idx" ON "public"."WeeklyKpiRow"("status", "dueDate");
ALTER TABLE "public"."WeeklyKpiSection"
ADD CONSTRAINT "WeeklyKpiSection_snapshotId_fkey"
FOREIGN KEY ("snapshotId") REFERENCES "public"."WeeklyKpiSnapshot"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "public"."WeeklyKpiRow"
ADD CONSTRAINT "WeeklyKpiRow_sectionId_fkey"
FOREIGN KEY ("sectionId") REFERENCES "public"."WeeklyKpiSection"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,411 @@
-- CreateEnum
CREATE TYPE "public"."MarketingSyncRunStatus" AS ENUM ('queued', 'running', 'success', 'failed');
-- CreateEnum
CREATE TYPE "public"."EmploymentStatus" AS ENUM ('active', 'leave', 'terminated');
-- CreateEnum
CREATE TYPE "public"."EmployeeLifecycleEventType" AS ENUM ('hire', 'transfer', 'leave', 'termination', 'rehire');
-- CreateEnum
CREATE TYPE "public"."HrUpdateSeverity" AS ENUM ('info', 'warning', 'critical');
-- CreateEnum
CREATE TYPE "public"."FactoryAssetState" AS ENUM ('draft', 'active', 'down', 'retired');
-- CreateEnum
CREATE TYPE "public"."PmTemplateState" AS ENUM ('draft', 'active', 'archived');
-- CreateEnum
CREATE TYPE "public"."PmWorkOrderState" AS ENUM ('draft', 'scheduled', 'in_progress', 'completed', 'verified', 'overdue', 'cancelled');
-- CreateEnum
CREATE TYPE "public"."ReminderEventState" AS ENUM ('queued', 'sent', 'acknowledged', 'escalated', 'failed');
-- CreateEnum
CREATE TYPE "public"."ApprovalState" AS ENUM ('draft', 'submitted', 'pending_owner', 'approved', 'rejected', 'changes_requested', 'cancelled');
-- CreateEnum
CREATE TYPE "public"."SalesForecastState" AS ENUM ('draft', 'published', 'superseded');
-- CreateEnum
CREATE TYPE "public"."ProductionPlanState" AS ENUM ('draft', 'simulated', 'locked', 'approved', 'released', 'replanned');
-- AlterTable
ALTER TABLE "public"."MarketingInitiative" ADD COLUMN "attributionCampaign" TEXT,
ADD COLUMN "attributionChannel" TEXT,
ADD COLUMN "attributionEnd" TIMESTAMP(3),
ADD COLUMN "attributionPageId" TEXT,
ADD COLUMN "attributionStart" TIMESTAMP(3),
ADD COLUMN "autoActual" DOUBLE PRECISION,
ADD COLUMN "autoRevenueActual" DOUBLE PRECISION,
ADD COLUMN "autoTicketsActual" INTEGER,
ADD COLUMN "autoUpdatedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "public"."MarketingMetaConnection" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL DEFAULT 'marketing',
"accountId" TEXT NOT NULL,
"pageId" TEXT NOT NULL,
"pageName" TEXT NOT NULL,
"pageAccessToken" TEXT NOT NULL,
"tokenExpiresAt" TIMESTAMP(3),
"connectedById" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastSyncedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketingMetaConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingSocialMetricDaily" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL DEFAULT 'marketing',
"connectionId" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"metricDate" TIMESTAMP(3) NOT NULL,
"followers" INTEGER NOT NULL,
"reach" INTEGER NOT NULL,
"impressions" INTEGER NOT NULL,
"engagements" INTEGER NOT NULL,
"engagementRate" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketingSocialMetricDaily_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MarketingSyncRun" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL DEFAULT 'marketing',
"connectionId" TEXT,
"status" "public"."MarketingSyncRunStatus" NOT NULL DEFAULT 'queued',
"message" TEXT,
"rowsIngested" INTEGER NOT NULL DEFAULT 0,
"triggeredById" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketingSyncRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."EmployeeProfile" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"employeeCode" TEXT,
"hireDate" TIMESTAMP(3),
"employmentType" TEXT,
"managerUserId" TEXT,
"locationId" TEXT,
"fte" DOUBLE PRECISION NOT NULL DEFAULT 1,
"employmentStatus" "public"."EmploymentStatus" NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EmployeeProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."EmployeeLifecycleEvent" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"eventType" "public"."EmployeeLifecycleEventType" NOT NULL,
"effectiveAt" TIMESTAMP(3) NOT NULL,
"reason" TEXT,
"isVoluntary" BOOLEAN,
"metadata" JSONB,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmployeeLifecycleEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."HrMetricSnapshot" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey",
"snapshotDate" TIMESTAMP(3) NOT NULL,
"headcount" INTEGER NOT NULL,
"hires" INTEGER NOT NULL,
"exits" INTEGER NOT NULL,
"churnPct" DOUBLE PRECISION NOT NULL,
"medianTenureMonths" DOUBLE PRECISION NOT NULL,
"peopleHealthScore" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "HrMetricSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."HrUpdate" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"severity" "public"."HrUpdateSeverity" NOT NULL DEFAULT 'info',
"audience" TEXT NOT NULL DEFAULT 'hc_leadership',
"status" TEXT NOT NULL DEFAULT 'draft',
"publishedAt" TIMESTAMP(3),
"authorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrUpdate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."FactoryAsset" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL DEFAULT 'operaciones',
"assetCode" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"locationId" TEXT,
"criticality" INTEGER NOT NULL DEFAULT 3,
"state" "public"."FactoryAssetState" NOT NULL DEFAULT 'active',
"serviceStrategy" TEXT,
"lastMaintenanceAt" TIMESTAMP(3),
"nextMaintenanceAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FactoryAsset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."PmTemplate" (
"id" TEXT NOT NULL,
"assetId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"cadenceDays" INTEGER,
"cadenceHours" INTEGER,
"state" "public"."PmTemplateState" NOT NULL DEFAULT 'active',
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PmTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."PmWorkOrder" (
"id" TEXT NOT NULL,
"assetId" TEXT NOT NULL,
"templateId" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"scheduledFor" TIMESTAMP(3) NOT NULL,
"dueBy" TIMESTAMP(3) NOT NULL,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"estimatedCost" DOUBLE PRECISION NOT NULL DEFAULT 0,
"expectedDowntimeHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"actualCost" DOUBLE PRECISION,
"actualDowntimeHours" DOUBLE PRECISION,
"state" "public"."PmWorkOrderState" NOT NULL DEFAULT 'scheduled',
"requestedById" TEXT,
"approvedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PmWorkOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ReminderEvent" (
"id" TEXT NOT NULL,
"workOrderId" TEXT NOT NULL,
"remindAt" TIMESTAMP(3) NOT NULL,
"channel" TEXT NOT NULL DEFAULT 'in_app',
"message" TEXT,
"state" "public"."ReminderEventState" NOT NULL DEFAULT 'queued',
"acknowledgedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReminderEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MaintenanceApprovalPolicy" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL DEFAULT 'operaciones',
"costThreshold" DOUBLE PRECISION NOT NULL DEFAULT 5000,
"downtimeThresholdHours" DOUBLE PRECISION NOT NULL DEFAULT 4,
"ownerUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MaintenanceApprovalPolicy_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."SalesForecast" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL DEFAULT 'operaciones',
"weekStart" TIMESTAMP(3) NOT NULL,
"weekEnd" TIMESTAMP(3) NOT NULL,
"locationId" TEXT,
"sku" TEXT,
"forecastUnits" DOUBLE PRECISION NOT NULL,
"multiplier" DOUBLE PRECISION NOT NULL DEFAULT 1.2,
"state" "public"."SalesForecastState" NOT NULL DEFAULT 'draft',
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SalesForecast_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ProductionPlan" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL DEFAULT 'operaciones',
"weekStart" TIMESTAMP(3) NOT NULL,
"weekEnd" TIMESTAMP(3) NOT NULL,
"lineName" TEXT NOT NULL,
"plannedUnits" DOUBLE PRECISION NOT NULL,
"forecastUnits" DOUBLE PRECISION NOT NULL,
"capacityUnits" DOUBLE PRECISION NOT NULL,
"varianceUnits" DOUBLE PRECISION NOT NULL,
"state" "public"."ProductionPlanState" NOT NULL DEFAULT 'draft',
"createdById" TEXT,
"approvedById" TEXT,
"approvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductionPlan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ApprovalRequest" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL DEFAULT 'operaciones',
"requestType" TEXT NOT NULL,
"workOrderId" TEXT,
"productionPlanId" TEXT,
"submittedById" TEXT,
"approverId" TEXT,
"state" "public"."ApprovalState" NOT NULL DEFAULT 'submitted',
"reason" TEXT,
"decidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApprovalRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MarketingMetaConnection_department_isActive_idx" ON "public"."MarketingMetaConnection"("department", "isActive");
-- CreateIndex
CREATE UNIQUE INDEX "MarketingMetaConnection_department_pageId_isActive_key" ON "public"."MarketingMetaConnection"("department", "pageId", "isActive");
-- CreateIndex
CREATE INDEX "MarketingSocialMetricDaily_department_channel_metricDate_idx" ON "public"."MarketingSocialMetricDaily"("department", "channel", "metricDate");
-- CreateIndex
CREATE UNIQUE INDEX "MarketingSocialMetricDaily_connectionId_channel_metricDate_key" ON "public"."MarketingSocialMetricDaily"("connectionId", "channel", "metricDate");
-- CreateIndex
CREATE INDEX "MarketingSyncRun_department_createdAt_idx" ON "public"."MarketingSyncRun"("department", "createdAt");
-- CreateIndex
CREATE INDEX "MarketingSyncRun_status_createdAt_idx" ON "public"."MarketingSyncRun"("status", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "EmployeeProfile_userId_key" ON "public"."EmployeeProfile"("userId");
-- CreateIndex
CREATE INDEX "EmployeeProfile_employmentStatus_hireDate_idx" ON "public"."EmployeeProfile"("employmentStatus", "hireDate");
-- CreateIndex
CREATE INDEX "EmployeeLifecycleEvent_userId_effectiveAt_idx" ON "public"."EmployeeLifecycleEvent"("userId", "effectiveAt");
-- CreateIndex
CREATE INDEX "EmployeeLifecycleEvent_eventType_effectiveAt_idx" ON "public"."EmployeeLifecycleEvent"("eventType", "effectiveAt");
-- CreateIndex
CREATE INDEX "HrMetricSnapshot_snapshotDate_department_idx" ON "public"."HrMetricSnapshot"("snapshotDate", "department");
-- CreateIndex
CREATE INDEX "HrUpdate_status_publishedAt_idx" ON "public"."HrUpdate"("status", "publishedAt");
-- CreateIndex
CREATE UNIQUE INDEX "FactoryAsset_assetCode_key" ON "public"."FactoryAsset"("assetCode");
-- CreateIndex
CREATE INDEX "FactoryAsset_department_state_idx" ON "public"."FactoryAsset"("department", "state");
-- CreateIndex
CREATE INDEX "FactoryAsset_nextMaintenanceAt_state_idx" ON "public"."FactoryAsset"("nextMaintenanceAt", "state");
-- CreateIndex
CREATE INDEX "PmTemplate_assetId_state_idx" ON "public"."PmTemplate"("assetId", "state");
-- CreateIndex
CREATE INDEX "PmWorkOrder_assetId_state_dueBy_idx" ON "public"."PmWorkOrder"("assetId", "state", "dueBy");
-- CreateIndex
CREATE INDEX "PmWorkOrder_state_scheduledFor_idx" ON "public"."PmWorkOrder"("state", "scheduledFor");
-- CreateIndex
CREATE INDEX "ReminderEvent_workOrderId_state_idx" ON "public"."ReminderEvent"("workOrderId", "state");
-- CreateIndex
CREATE INDEX "ReminderEvent_remindAt_state_idx" ON "public"."ReminderEvent"("remindAt", "state");
-- CreateIndex
CREATE UNIQUE INDEX "MaintenanceApprovalPolicy_department_key" ON "public"."MaintenanceApprovalPolicy"("department");
-- CreateIndex
CREATE INDEX "SalesForecast_department_weekStart_state_idx" ON "public"."SalesForecast"("department", "weekStart", "state");
-- CreateIndex
CREATE INDEX "ProductionPlan_department_weekStart_state_idx" ON "public"."ProductionPlan"("department", "weekStart", "state");
-- CreateIndex
CREATE INDEX "ApprovalRequest_department_state_createdAt_idx" ON "public"."ApprovalRequest"("department", "state", "createdAt");
-- AddForeignKey
ALTER TABLE "public"."MarketingSocialMetricDaily" ADD CONSTRAINT "MarketingSocialMetricDaily_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "public"."MarketingMetaConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MarketingSyncRun" ADD CONSTRAINT "MarketingSyncRun_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "public"."MarketingMetaConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."EmployeeProfile" ADD CONSTRAINT "EmployeeProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."EmployeeLifecycleEvent" ADD CONSTRAINT "EmployeeLifecycleEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."EmployeeLifecycleEvent" ADD CONSTRAINT "EmployeeLifecycleEvent_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."HrUpdate" ADD CONSTRAINT "HrUpdate_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."PmTemplate" ADD CONSTRAINT "PmTemplate_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "public"."FactoryAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."PmWorkOrder" ADD CONSTRAINT "PmWorkOrder_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "public"."FactoryAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."PmWorkOrder" ADD CONSTRAINT "PmWorkOrder_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "public"."PmTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ReminderEvent" ADD CONSTRAINT "ReminderEvent_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "public"."PmWorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ApprovalRequest" ADD CONSTRAINT "ApprovalRequest_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "public"."PmWorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ApprovalRequest" ADD CONSTRAINT "ApprovalRequest_productionPlanId_fkey" FOREIGN KEY ("productionPlanId") REFERENCES "public"."ProductionPlan"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,327 @@
-- CreateEnum
CREATE TYPE "public"."ExperienceTemplateState" AS ENUM ('draft', 'published', 'archived');
-- CreateEnum
CREATE TYPE "public"."ExperienceFindingPriority" AS ENUM ('low', 'medium', 'high', 'critical');
-- CreateEnum
CREATE TYPE "public"."ExperienceFindingStatus" AS ENUM ('open', 'in_progress', 'resolved', 'closed');
-- CreateEnum
CREATE TYPE "public"."ExperienceScoreAggregationMode" AS ENUM ('weighted_recent', 'moving_average', 'full_average');
-- CreateEnum
CREATE TYPE "public"."ExperienceTrendDirection" AS ENUM ('up', 'down', 'flat');
-- CreateEnum
CREATE TYPE "public"."ExperienceSignalStatus" AS ENUM ('green', 'yellow', 'red');
-- CreateEnum
CREATE TYPE "public"."ExperienceEvidenceKind" AS ENUM ('photo', 'document', 'link');
-- CreateTable
CREATE TABLE "public"."Location" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"city" TEXT,
"managerUserId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ExperienceTemplate" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"state" "public"."ExperienceTemplateState" NOT NULL DEFAULT 'draft',
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdById" TEXT,
"publishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExperienceTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ExperienceTemplateCategory" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"weight" DOUBLE PRECISION NOT NULL DEFAULT 1,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExperienceTemplateCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ExperienceTemplateItem" (
"id" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label" TEXT NOT NULL,
"weight" DOUBLE PRECISION NOT NULL DEFAULT 1,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"allowsComment" BOOLEAN NOT NULL DEFAULT true,
"requiresObservation" BOOLEAN NOT NULL DEFAULT false,
"allowsEvidence" BOOLEAN NOT NULL DEFAULT true,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExperienceTemplateItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ExperienceEvaluation" (
"id" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"createdById" TEXT,
"evaluatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"generalObservations" TEXT NOT NULL DEFAULT '',
"strengths" TEXT NOT NULL DEFAULT '',
"improvementAreas" TEXT NOT NULL DEFAULT '',
"totalScore" DOUBLE PRECISION NOT NULL,
"signal" "public"."ExperienceSignalStatus" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExperienceEvaluation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ExperienceEvaluationResponse" (
"id" TEXT NOT NULL,
"evaluationId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"scorePct" DOUBLE PRECISION NOT NULL,
"comment" TEXT,
"observation" TEXT,
"hasObservation" BOOLEAN NOT NULL DEFAULT false,
"categoryLabelSnapshot" TEXT NOT NULL,
"itemLabelSnapshot" TEXT NOT NULL,
"categoryWeightSnapshot" DOUBLE PRECISION NOT NULL,
"itemWeightSnapshot" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExperienceEvaluationResponse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ExperienceFinding" (
"id" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"evaluationId" TEXT,
"createdById" TEXT,
"responsibleUserId" TEXT,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"categoryKey" TEXT,
"categoryLabel" TEXT,
"priority" "public"."ExperienceFindingPriority" NOT NULL DEFAULT 'medium',
"status" "public"."ExperienceFindingStatus" NOT NULL DEFAULT 'open',
"dueDate" TIMESTAMP(3),
"resolvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExperienceFinding_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ExperienceEvidence" (
"id" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"evaluationId" TEXT,
"findingId" TEXT,
"responseId" TEXT,
"uploadedById" TEXT,
"kind" "public"."ExperienceEvidenceKind" NOT NULL,
"title" TEXT NOT NULL,
"note" TEXT,
"url" TEXT,
"storagePath" TEXT,
"mimeType" TEXT,
"sizeBytes" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExperienceEvidence_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ExperienceLocationMetric" (
"id" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"currentScore" DOUBLE PRECISION NOT NULL DEFAULT 0,
"previousScore" DOUBLE PRECISION NOT NULL DEFAULT 0,
"trendDelta" DOUBLE PRECISION NOT NULL DEFAULT 0,
"trendDirection" "public"."ExperienceTrendDirection" NOT NULL DEFAULT 'flat',
"signal" "public"."ExperienceSignalStatus" NOT NULL DEFAULT 'yellow',
"totalEvaluations" INTEGER NOT NULL DEFAULT 0,
"openFindings" INTEGER NOT NULL DEFAULT 0,
"lastEvaluationAt" TIMESTAMP(3),
"lastEvaluationId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExperienceLocationMetric_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ExperienceScoringPolicy" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"aggregationMode" "public"."ExperienceScoreAggregationMode" NOT NULL DEFAULT 'weighted_recent',
"recentWindow" INTEGER NOT NULL DEFAULT 3,
"recentWeightsCsv" TEXT NOT NULL DEFAULT '0.5,0.3,0.2',
"greenThreshold" DOUBLE PRECISION NOT NULL DEFAULT 85,
"yellowThreshold" DOUBLE PRECISION NOT NULL DEFAULT 70,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExperienceScoringPolicy_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Location_code_key" ON "public"."Location"("code");
-- CreateIndex
CREATE INDEX "Location_isActive_name_idx" ON "public"."Location"("isActive", "name");
-- CreateIndex
CREATE INDEX "ExperienceTemplate_state_updatedAt_idx" ON "public"."ExperienceTemplate"("state", "updatedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ExperienceTemplate_name_version_key" ON "public"."ExperienceTemplate"("name", "version");
-- CreateIndex
CREATE INDEX "ExperienceTemplateCategory_templateId_sortOrder_idx" ON "public"."ExperienceTemplateCategory"("templateId", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "ExperienceTemplateCategory_templateId_key_key" ON "public"."ExperienceTemplateCategory"("templateId", "key");
-- CreateIndex
CREATE INDEX "ExperienceTemplateItem_categoryId_sortOrder_idx" ON "public"."ExperienceTemplateItem"("categoryId", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "ExperienceTemplateItem_categoryId_key_key" ON "public"."ExperienceTemplateItem"("categoryId", "key");
-- CreateIndex
CREATE INDEX "ExperienceEvaluation_locationId_evaluatedAt_idx" ON "public"."ExperienceEvaluation"("locationId", "evaluatedAt");
-- CreateIndex
CREATE INDEX "ExperienceEvaluation_templateId_evaluatedAt_idx" ON "public"."ExperienceEvaluation"("templateId", "evaluatedAt");
-- CreateIndex
CREATE INDEX "ExperienceEvaluationResponse_evaluationId_categoryId_idx" ON "public"."ExperienceEvaluationResponse"("evaluationId", "categoryId");
-- CreateIndex
CREATE UNIQUE INDEX "ExperienceEvaluationResponse_evaluationId_itemId_key" ON "public"."ExperienceEvaluationResponse"("evaluationId", "itemId");
-- CreateIndex
CREATE INDEX "ExperienceFinding_locationId_status_createdAt_idx" ON "public"."ExperienceFinding"("locationId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "ExperienceFinding_evaluationId_idx" ON "public"."ExperienceFinding"("evaluationId");
-- CreateIndex
CREATE INDEX "ExperienceFinding_responsibleUserId_status_idx" ON "public"."ExperienceFinding"("responsibleUserId", "status");
-- CreateIndex
CREATE INDEX "ExperienceEvidence_locationId_createdAt_idx" ON "public"."ExperienceEvidence"("locationId", "createdAt");
-- CreateIndex
CREATE INDEX "ExperienceEvidence_evaluationId_createdAt_idx" ON "public"."ExperienceEvidence"("evaluationId", "createdAt");
-- CreateIndex
CREATE INDEX "ExperienceEvidence_findingId_createdAt_idx" ON "public"."ExperienceEvidence"("findingId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "ExperienceLocationMetric_locationId_key" ON "public"."ExperienceLocationMetric"("locationId");
-- CreateIndex
CREATE INDEX "ExperienceLocationMetric_signal_currentScore_idx" ON "public"."ExperienceLocationMetric"("signal", "currentScore");
-- CreateIndex
CREATE INDEX "ExperienceScoringPolicy_isActive_updatedAt_idx" ON "public"."ExperienceScoringPolicy"("isActive", "updatedAt");
-- AddForeignKey
ALTER TABLE "public"."Location" ADD CONSTRAINT "Location_managerUserId_fkey" FOREIGN KEY ("managerUserId") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceTemplate" ADD CONSTRAINT "ExperienceTemplate_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceTemplateCategory" ADD CONSTRAINT "ExperienceTemplateCategory_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "public"."ExperienceTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceTemplateItem" ADD CONSTRAINT "ExperienceTemplateItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "public"."ExperienceTemplateCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvaluation" ADD CONSTRAINT "ExperienceEvaluation_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "public"."Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvaluation" ADD CONSTRAINT "ExperienceEvaluation_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "public"."ExperienceTemplate"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvaluation" ADD CONSTRAINT "ExperienceEvaluation_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvaluationResponse" ADD CONSTRAINT "ExperienceEvaluationResponse_evaluationId_fkey" FOREIGN KEY ("evaluationId") REFERENCES "public"."ExperienceEvaluation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvaluationResponse" ADD CONSTRAINT "ExperienceEvaluationResponse_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "public"."ExperienceTemplateCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvaluationResponse" ADD CONSTRAINT "ExperienceEvaluationResponse_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "public"."ExperienceTemplateItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceFinding" ADD CONSTRAINT "ExperienceFinding_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "public"."Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceFinding" ADD CONSTRAINT "ExperienceFinding_evaluationId_fkey" FOREIGN KEY ("evaluationId") REFERENCES "public"."ExperienceEvaluation"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceFinding" ADD CONSTRAINT "ExperienceFinding_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceFinding" ADD CONSTRAINT "ExperienceFinding_responsibleUserId_fkey" FOREIGN KEY ("responsibleUserId") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvidence" ADD CONSTRAINT "ExperienceEvidence_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "public"."Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvidence" ADD CONSTRAINT "ExperienceEvidence_evaluationId_fkey" FOREIGN KEY ("evaluationId") REFERENCES "public"."ExperienceEvaluation"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvidence" ADD CONSTRAINT "ExperienceEvidence_findingId_fkey" FOREIGN KEY ("findingId") REFERENCES "public"."ExperienceFinding"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvidence" ADD CONSTRAINT "ExperienceEvidence_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "public"."ExperienceEvaluationResponse"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceEvidence" ADD CONSTRAINT "ExperienceEvidence_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceLocationMetric" ADD CONSTRAINT "ExperienceLocationMetric_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "public"."Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceLocationMetric" ADD CONSTRAINT "ExperienceLocationMetric_lastEvaluationId_fkey" FOREIGN KEY ("lastEvaluationId") REFERENCES "public"."ExperienceEvaluation"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ExperienceScoringPolicy" ADD CONSTRAINT "ExperienceScoringPolicy_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,158 @@
-- CreateEnum
CREATE TYPE "public"."CaptureMode" AS ENUM ('manual', 'auto', 'hybrid');
-- CreateEnum
CREATE TYPE "public"."CaptureAutomationRunStatus" AS ENUM ('queued', 'running', 'success', 'failed');
-- AlterTable
ALTER TABLE "public"."WeeklyKpiRow"
ADD COLUMN "captureNote" TEXT,
ADD COLUMN "lastAutomationAt" TIMESTAMP(3),
ADD COLUMN "lastCapturedAt" TIMESTAMP(3),
ADD COLUMN "lastCapturedById" TEXT;
-- CreateTable
CREATE TABLE "public"."KpiCaptureCatalog" (
"id" TEXT NOT NULL,
"weekStart" TIMESTAMP(3) NOT NULL,
"sectionKey" TEXT NOT NULL,
"rowKey" TEXT NOT NULL,
"weeklyKpiRowId" TEXT,
"department" "public"."DepartmentKey" NOT NULL,
"captureMode" "public"."CaptureMode" NOT NULL DEFAULT 'manual',
"automationSource" TEXT,
"ownerUserId" TEXT,
"freshnessSlaHours" INTEGER NOT NULL DEFAULT 168,
"captureNote" TEXT,
"lastCapturedAt" TIMESTAMP(3),
"lastCapturedById" TEXT,
"lastAutomationAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KpiCaptureCatalog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."KpiCaptureEvidence" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL,
"weekStart" TIMESTAMP(3) NOT NULL,
"sectionKey" TEXT NOT NULL,
"rowKey" TEXT NOT NULL,
"catalogId" TEXT,
"weeklyKpiRowId" TEXT,
"initiativeId" TEXT,
"taskId" TEXT,
"uploadedById" TEXT NOT NULL,
"kind" "public"."ProjectCaptureEvidenceKind" NOT NULL,
"title" TEXT NOT NULL,
"note" TEXT,
"url" TEXT,
"storagePath" TEXT,
"mimeType" TEXT,
"sizeBytes" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KpiCaptureEvidence_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."KpiCaptureAutomationRun" (
"id" TEXT NOT NULL,
"department" "public"."DepartmentKey" NOT NULL,
"weekStart" TIMESTAMP(3) NOT NULL,
"source" TEXT NOT NULL,
"status" "public"."CaptureAutomationRunStatus" NOT NULL DEFAULT 'queued',
"rowsTouched" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT,
"triggeredById" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KpiCaptureAutomationRun_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "KpiCaptureCatalog_weekStart_sectionKey_rowKey_key" ON "public"."KpiCaptureCatalog"("weekStart", "sectionKey", "rowKey");
-- CreateIndex
CREATE UNIQUE INDEX "KpiCaptureCatalog_weeklyKpiRowId_key" ON "public"."KpiCaptureCatalog"("weeklyKpiRowId");
-- CreateIndex
CREATE INDEX "KpiCaptureCatalog_department_weekStart_idx" ON "public"."KpiCaptureCatalog"("department", "weekStart");
-- CreateIndex
CREATE INDEX "KpiCaptureCatalog_ownerUserId_weekStart_idx" ON "public"."KpiCaptureCatalog"("ownerUserId", "weekStart");
-- CreateIndex
CREATE INDEX "KpiCaptureEvidence_department_weekStart_createdAt_idx" ON "public"."KpiCaptureEvidence"("department", "weekStart", "createdAt");
-- CreateIndex
CREATE INDEX "KpiCaptureEvidence_sectionKey_rowKey_weekStart_idx" ON "public"."KpiCaptureEvidence"("sectionKey", "rowKey", "weekStart");
-- CreateIndex
CREATE INDEX "KpiCaptureEvidence_catalogId_createdAt_idx" ON "public"."KpiCaptureEvidence"("catalogId", "createdAt");
-- CreateIndex
CREATE INDEX "KpiCaptureEvidence_initiativeId_createdAt_idx" ON "public"."KpiCaptureEvidence"("initiativeId", "createdAt");
-- CreateIndex
CREATE INDEX "KpiCaptureEvidence_taskId_createdAt_idx" ON "public"."KpiCaptureEvidence"("taskId", "createdAt");
-- CreateIndex
CREATE INDEX "KpiCaptureEvidence_uploadedById_createdAt_idx" ON "public"."KpiCaptureEvidence"("uploadedById", "createdAt");
-- CreateIndex
CREATE INDEX "KpiCaptureAutomationRun_department_weekStart_createdAt_idx" ON "public"."KpiCaptureAutomationRun"("department", "weekStart", "createdAt");
-- CreateIndex
CREATE INDEX "KpiCaptureAutomationRun_status_createdAt_idx" ON "public"."KpiCaptureAutomationRun"("status", "createdAt");
-- AddForeignKey
ALTER TABLE "public"."KpiCaptureCatalog"
ADD CONSTRAINT "KpiCaptureCatalog_weeklyKpiRowId_fkey"
FOREIGN KEY ("weeklyKpiRowId") REFERENCES "public"."WeeklyKpiRow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."KpiCaptureCatalog"
ADD CONSTRAINT "KpiCaptureCatalog_ownerUserId_fkey"
FOREIGN KEY ("ownerUserId") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."KpiCaptureCatalog"
ADD CONSTRAINT "KpiCaptureCatalog_lastCapturedById_fkey"
FOREIGN KEY ("lastCapturedById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."KpiCaptureEvidence"
ADD CONSTRAINT "KpiCaptureEvidence_catalogId_fkey"
FOREIGN KEY ("catalogId") REFERENCES "public"."KpiCaptureCatalog"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."KpiCaptureEvidence"
ADD CONSTRAINT "KpiCaptureEvidence_weeklyKpiRowId_fkey"
FOREIGN KEY ("weeklyKpiRowId") REFERENCES "public"."WeeklyKpiRow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."KpiCaptureEvidence"
ADD CONSTRAINT "KpiCaptureEvidence_initiativeId_fkey"
FOREIGN KEY ("initiativeId") REFERENCES "public"."MarketingInitiative"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."KpiCaptureEvidence"
ADD CONSTRAINT "KpiCaptureEvidence_taskId_fkey"
FOREIGN KEY ("taskId") REFERENCES "public"."MarketingTask"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."KpiCaptureEvidence"
ADD CONSTRAINT "KpiCaptureEvidence_uploadedById_fkey"
FOREIGN KEY ("uploadedById") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."KpiCaptureAutomationRun"
ADD CONSTRAINT "KpiCaptureAutomationRun_triggeredById_fkey"
FOREIGN KEY ("triggeredById") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,399 @@
-- CreateEnum
CREATE TYPE "WorkspaceConfigStatus" AS ENUM ('draft', 'published', 'archived');
-- CreateEnum
CREATE TYPE "HrFileRecordStatus" AS ENUM ('missing', 'submitted', 'verified', 'rejected');
-- CreateEnum
CREATE TYPE "HrVacancyStatus" AS ENUM ('draft', 'open', 'interviewing', 'offered', 'hired', 'closed', 'cancelled');
-- CreateEnum
CREATE TYPE "HrPayrollFrequency" AS ENUM ('weekly', 'biweekly');
-- CreateEnum
CREATE TYPE "HrPayrollLineType" AS ENUM ('percepciones', 'deducciones', 'aportaciones');
-- CreateEnum
CREATE TYPE "HrImportJobStatus" AS ENUM ('queued', 'success', 'partial', 'failed');
-- CreateEnum
CREATE TYPE "HrCareerContentType" AS ENUM ('announcement', 'course');
-- CreateEnum
CREATE TYPE "HrCareerAssignmentStatus" AS ENUM ('not_started', 'in_progress', 'completed');
-- CreateEnum
CREATE TYPE "HrComplianceBody" AS ENUM ('imss', 'infonavit', 'fonacot', 'other');
-- CreateEnum
CREATE TYPE "HrComplianceStatus" AS ENUM ('on_time', 'due_soon', 'overdue', 'paid', 'blocked');
-- CreateEnum
CREATE TYPE "HrCompliancePaymentStatus" AS ENUM ('scheduled', 'paid', 'late', 'failed');
-- CreateEnum
CREATE TYPE "HrAutomationTaskStatus" AS ENUM ('queued', 'success', 'warning', 'failed');
-- CreateTable
CREATE TABLE "HrWorkspaceConfigVersion" (
"id" TEXT NOT NULL,
"department" "DepartmentKey" NOT NULL DEFAULT 'capital_humano',
"version" INTEGER NOT NULL,
"status" "WorkspaceConfigStatus" NOT NULL DEFAULT 'draft',
"name" TEXT NOT NULL DEFAULT 'Capital Humano Workspace',
"tabs" JSONB NOT NULL,
"changeSummary" TEXT,
"createdById" TEXT,
"publishedById" TEXT,
"publishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrWorkspaceConfigVersion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrFileRequirement" (
"id" TEXT NOT NULL,
"department" "DepartmentKey" NOT NULL DEFAULT 'capital_humano',
"key" TEXT NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT,
"fieldType" TEXT NOT NULL DEFAULT 'document',
"isRequired" BOOLEAN NOT NULL DEFAULT true,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdById" TEXT,
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrFileRequirement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrEmployeeFileRecord" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"requirementId" TEXT NOT NULL,
"status" "HrFileRecordStatus" NOT NULL DEFAULT 'missing',
"valueText" TEXT,
"evidenceUrl" TEXT,
"note" TEXT,
"verifiedAt" TIMESTAMP(3),
"verifiedById" TEXT,
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrEmployeeFileRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrIdealOrgTarget" (
"id" TEXT NOT NULL,
"department" "DepartmentKey" NOT NULL DEFAULT 'capital_humano',
"locationCode" TEXT NOT NULL,
"roleKey" TEXT NOT NULL,
"targetCount" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrIdealOrgTarget_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrVacancy" (
"id" TEXT NOT NULL,
"department" "DepartmentKey" NOT NULL DEFAULT 'capital_humano',
"locationCode" TEXT NOT NULL,
"roleKey" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" "HrVacancyStatus" NOT NULL DEFAULT 'open',
"priority" TEXT NOT NULL DEFAULT 'medium',
"openedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"targetStartAt" TIMESTAMP(3),
"closedAt" TIMESTAMP(3),
"hiringManagerUserId" TEXT,
"ownerUserId" TEXT,
"notes" TEXT,
"createdById" TEXT,
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrVacancy_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrPayrollRun" (
"id" TEXT NOT NULL,
"department" "DepartmentKey" NOT NULL DEFAULT 'capital_humano',
"source" TEXT NOT NULL DEFAULT 'contpaqi_csv',
"externalRef" TEXT,
"periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" TIMESTAMP(3) NOT NULL,
"frequency" "HrPayrollFrequency" NOT NULL,
"locationCode" TEXT,
"status" TEXT NOT NULL DEFAULT 'imported',
"rawHash" TEXT,
"importedById" TEXT,
"importedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrPayrollRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrPayrollLine" (
"id" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"userId" TEXT,
"personIdentifier" TEXT NOT NULL,
"lineType" "HrPayrollLineType" NOT NULL,
"concept" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MXN',
"locationCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrPayrollLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrPayrollImportJob" (
"id" TEXT NOT NULL,
"department" "DepartmentKey" NOT NULL DEFAULT 'capital_humano',
"status" "HrImportJobStatus" NOT NULL DEFAULT 'queued',
"sourceFileName" TEXT,
"requestHash" TEXT,
"message" TEXT,
"rowsRead" INTEGER NOT NULL DEFAULT 0,
"rowsImported" INTEGER NOT NULL DEFAULT 0,
"failedRows" INTEGER NOT NULL DEFAULT 0,
"runId" TEXT,
"triggeredById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
CONSTRAINT "HrPayrollImportJob_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrCareerContent" (
"id" TEXT NOT NULL,
"department" "DepartmentKey" NOT NULL DEFAULT 'capital_humano',
"contentType" "HrCareerContentType" NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"sourceDepartment" "DepartmentKey",
"sourceRef" TEXT,
"startsAt" TIMESTAMP(3),
"endsAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdById" TEXT,
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrCareerContent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrCareerAssignment" (
"id" TEXT NOT NULL,
"contentId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "HrCareerAssignmentStatus" NOT NULL DEFAULT 'not_started',
"progressPct" DOUBLE PRECISION NOT NULL DEFAULT 0,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dueAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"notes" TEXT,
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrCareerAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrComplianceObligation" (
"id" TEXT NOT NULL,
"department" "DepartmentKey" NOT NULL DEFAULT 'capital_humano',
"body" "HrComplianceBody" NOT NULL,
"title" TEXT NOT NULL,
"status" "HrComplianceStatus" NOT NULL DEFAULT 'due_soon',
"locationCode" TEXT,
"referencePeriod" TEXT,
"dueDate" TIMESTAMP(3),
"lastPaymentAt" TIMESTAMP(3),
"lastPaymentAmount" DOUBLE PRECISION,
"notes" TEXT,
"createdById" TEXT,
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrComplianceObligation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrCompliancePayment" (
"id" TEXT NOT NULL,
"obligationId" TEXT NOT NULL,
"paymentDate" TIMESTAMP(3) NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"status" "HrCompliancePaymentStatus" NOT NULL DEFAULT 'paid',
"referencePeriod" TEXT,
"receiptUrl" TEXT,
"note" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrCompliancePayment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrAutomationTask" (
"id" TEXT NOT NULL,
"department" "DepartmentKey" NOT NULL DEFAULT 'capital_humano',
"taskType" TEXT NOT NULL,
"status" "HrAutomationTaskStatus" NOT NULL DEFAULT 'queued',
"title" TEXT NOT NULL,
"message" TEXT,
"payload" JSONB,
"runAt" TIMESTAMP(3) NOT NULL,
"resolvedAt" TIMESTAMP(3),
"assignedToId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrAutomationTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HrExceptionQueueItem" (
"id" TEXT NOT NULL,
"department" "DepartmentKey" NOT NULL DEFAULT 'capital_humano',
"source" TEXT NOT NULL,
"errorCode" TEXT,
"message" TEXT NOT NULL,
"payload" JSONB,
"status" TEXT NOT NULL DEFAULT 'open',
"retryCount" INTEGER NOT NULL DEFAULT 0,
"lastRetriedAt" TIMESTAMP(3),
"resolvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HrExceptionQueueItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HrWorkspaceConfigVersion_department_version_key" ON "HrWorkspaceConfigVersion"("department", "version");
-- CreateIndex
CREATE INDEX "HrWorkspaceConfigVersion_department_status_version_idx" ON "HrWorkspaceConfigVersion"("department", "status", "version");
-- CreateIndex
CREATE UNIQUE INDEX "HrFileRequirement_department_key_key" ON "HrFileRequirement"("department", "key");
-- CreateIndex
CREATE INDEX "HrFileRequirement_department_isActive_sortOrder_idx" ON "HrFileRequirement"("department", "isActive", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "HrEmployeeFileRecord_userId_requirementId_key" ON "HrEmployeeFileRecord"("userId", "requirementId");
-- CreateIndex
CREATE INDEX "HrEmployeeFileRecord_userId_status_idx" ON "HrEmployeeFileRecord"("userId", "status");
-- CreateIndex
CREATE INDEX "HrEmployeeFileRecord_requirementId_status_idx" ON "HrEmployeeFileRecord"("requirementId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "HrIdealOrgTarget_department_locationCode_roleKey_key" ON "HrIdealOrgTarget"("department", "locationCode", "roleKey");
-- CreateIndex
CREATE INDEX "HrIdealOrgTarget_department_locationCode_roleKey_idx" ON "HrIdealOrgTarget"("department", "locationCode", "roleKey");
-- CreateIndex
CREATE INDEX "HrVacancy_department_status_openedAt_idx" ON "HrVacancy"("department", "status", "openedAt");
-- CreateIndex
CREATE INDEX "HrVacancy_department_locationCode_roleKey_idx" ON "HrVacancy"("department", "locationCode", "roleKey");
-- CreateIndex
CREATE INDEX "HrPayrollRun_department_periodStart_periodEnd_idx" ON "HrPayrollRun"("department", "periodStart", "periodEnd");
-- CreateIndex
CREATE INDEX "HrPayrollRun_department_frequency_locationCode_idx" ON "HrPayrollRun"("department", "frequency", "locationCode");
-- CreateIndex
CREATE INDEX "HrPayrollRun_rawHash_idx" ON "HrPayrollRun"("rawHash");
-- CreateIndex
CREATE INDEX "HrPayrollLine_runId_lineType_idx" ON "HrPayrollLine"("runId", "lineType");
-- CreateIndex
CREATE INDEX "HrPayrollLine_userId_idx" ON "HrPayrollLine"("userId");
-- CreateIndex
CREATE INDEX "HrPayrollLine_locationCode_lineType_idx" ON "HrPayrollLine"("locationCode", "lineType");
-- CreateIndex
CREATE INDEX "HrPayrollImportJob_department_status_createdAt_idx" ON "HrPayrollImportJob"("department", "status", "createdAt");
-- CreateIndex
CREATE INDEX "HrPayrollImportJob_requestHash_idx" ON "HrPayrollImportJob"("requestHash");
-- CreateIndex
CREATE INDEX "HrCareerContent_department_contentType_isActive_idx" ON "HrCareerContent"("department", "contentType", "isActive");
-- CreateIndex
CREATE INDEX "HrCareerContent_sourceDepartment_createdAt_idx" ON "HrCareerContent"("sourceDepartment", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "HrCareerAssignment_contentId_userId_key" ON "HrCareerAssignment"("contentId", "userId");
-- CreateIndex
CREATE INDEX "HrCareerAssignment_userId_status_idx" ON "HrCareerAssignment"("userId", "status");
-- CreateIndex
CREATE INDEX "HrComplianceObligation_department_body_status_dueDate_idx" ON "HrComplianceObligation"("department", "body", "status", "dueDate");
-- CreateIndex
CREATE INDEX "HrComplianceObligation_department_locationCode_body_idx" ON "HrComplianceObligation"("department", "locationCode", "body");
-- CreateIndex
CREATE INDEX "HrCompliancePayment_obligationId_paymentDate_idx" ON "HrCompliancePayment"("obligationId", "paymentDate");
-- CreateIndex
CREATE INDEX "HrAutomationTask_department_status_runAt_idx" ON "HrAutomationTask"("department", "status", "runAt");
-- CreateIndex
CREATE INDEX "HrExceptionQueueItem_department_status_createdAt_idx" ON "HrExceptionQueueItem"("department", "status", "createdAt");
-- CreateIndex
CREATE INDEX "HrExceptionQueueItem_source_status_idx" ON "HrExceptionQueueItem"("source", "status");
-- AddForeignKey
ALTER TABLE "HrEmployeeFileRecord" ADD CONSTRAINT "HrEmployeeFileRecord_requirementId_fkey" FOREIGN KEY ("requirementId") REFERENCES "HrFileRequirement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HrPayrollLine" ADD CONSTRAINT "HrPayrollLine_runId_fkey" FOREIGN KEY ("runId") REFERENCES "HrPayrollRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HrPayrollImportJob" ADD CONSTRAINT "HrPayrollImportJob_runId_fkey" FOREIGN KEY ("runId") REFERENCES "HrPayrollRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HrCareerAssignment" ADD CONSTRAINT "HrCareerAssignment_contentId_fkey" FOREIGN KEY ("contentId") REFERENCES "HrCareerContent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HrCompliancePayment" ADD CONSTRAINT "HrCompliancePayment_obligationId_fkey" FOREIGN KEY ("obligationId") REFERENCES "HrComplianceObligation"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

1635
prisma/schema.prisma Normal file

File diff suppressed because it is too large Load Diff

76
prisma/seed.ts Normal file
View File

@@ -0,0 +1,76 @@
import bcrypt from "bcryptjs";
import { PrismaClient, RoleKey } from "@prisma/client";
import { seedWeeklyKpiBaseline } from "../src/lib/kpis/persistence";
import { ensureExperienceBaseline } from "../src/lib/experienciometro/persistence";
import { ensureHumanCapitalWorkspaceBootstrap } from "../src/lib/human-capital/workspace";
const prisma = new PrismaClient();
async function main() {
const roles: Array<{ key: RoleKey; name: string }> = [
{ key: "owner", name: "Dueño" },
{ key: "leader", name: "Líder" },
{ key: "employee", name: "Empleado" },
];
for (const role of roles) {
await prisma.role.upsert({
where: { key: role.key },
update: { name: role.name },
create: role,
});
}
const ownerEmail = (process.env.BOOTSTRAP_OWNER_EMAIL ?? "owner@casabenell.com").toLowerCase().trim();
const ownerName = process.env.BOOTSTRAP_OWNER_NAME ?? "Owner Casa Benell";
const ownerPassword = process.env.BOOTSTRAP_OWNER_PASSWORD ?? "ChangeMe123!";
const ownerPasswordHash = await bcrypt.hash(ownerPassword, 12);
const owner = await prisma.user.upsert({
where: { email: ownerEmail },
update: {
name: ownerName,
passwordHash: ownerPasswordHash,
status: "active",
emailVerified: new Date(),
},
create: {
email: ownerEmail,
name: ownerName,
passwordHash: ownerPasswordHash,
status: "active",
emailVerified: new Date(),
},
});
const ownerRole = await prisma.role.findUniqueOrThrow({ where: { key: "owner" } });
await prisma.userRole.upsert({
where: {
userId_roleId: {
userId: owner.id,
roleId: ownerRole.id,
},
},
update: {},
create: {
userId: owner.id,
roleId: ownerRole.id,
},
});
await seedWeeklyKpiBaseline(prisma);
await ensureExperienceBaseline(prisma, owner.id);
await ensureHumanCapitalWorkspaceBootstrap();
console.log(`Seed ready. Owner: ${ownerEmail}`);
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

472
project_plan.md Normal file
View File

@@ -0,0 +1,472 @@
Plan
project_plan.md — Projects Navigation V1.2 (Dedicated Pages + Weekly Calendar + Capture)
Summary
We will replace the current Projects “single long page + scroll-to-section” behavior with true page-based navigation and a Projects-specific information architecture.
V1.2 will deliver:
A coherent Projects sidebar with dedicated routes.
A real Meetings page with weekly calendar view.
Auto-populated calendar meetings with attendee confirmation (pending/accepted/declined).
A new Capture page for evidence (links + local file uploads on server).
Backward-compatible redirects from old alias routes.
This keeps the existing Projects operational backend and removes the current marketing-style navigation confusion.
Current-State Findings (what we are fixing)
/departments/projects/initiatives and /departments/projects/meetings are aliases to the same page.
Projects page uses scrollIntoView based on pathname instead of real page navigation.
Sidebar/mobile nav currently repurposes generic “initiatives/meetings” keys and points to alias routes.
Meetings UI is embedded as a section (MeetingsWidget) instead of a dedicated planning surface.
Capture currently exists only as generic /data-entry placeholder, not Projects-specific evidence operations.
V1.2 Information Architecture (locked)
Projects Sidebar (for department=proyectos)
Overview → /departments/projects
Projects → /departments/projects/projects
Meetings → /departments/projects/meetings
Capture → /departments/projects/capture
Team → /departments/projects/team
Behavior rules
No sidebar item may trigger section scrolling.
Each sidebar item maps to its own page route and URL state.
Active nav state is based on pathname prefix, not hash/scroll.
Mobile nav uses identical route map and active logic.
Compatibility routes
/departments/projects/initiatives redirects to /departments/projects/projects.
Existing /departments/projects/meetings alias is replaced with a real page (no re-export).
Old intra-page anchor behavior is removed.
Page Specifications
1) Overview Page (/departments/projects)
Purpose: operational snapshot.
Header: title, date/location filters, Nuevo proyecto.
KPI strip: active projects, milestones on-time, blocked tasks, overdue tasks, cost variance, profit/margin.
Alerts: blocked tasks, overdue milestones, cost variance risk.
Top portfolio summary table (read-only quick scan, row opens project drawer).
“My workload” panel for assigned tasks and upcoming commitments.
No meetings form here; only compact “next meetings” preview with link to Meetings page.
2) Projects Page (/departments/projects/projects)
Purpose: project execution workspace.
Full portfolio table and filters.
Project detail drawer for definition, stages, financials, milestones, tasks.
Milestone board + task board with execution updates.
Leaders/owners can create/edit/delete project objects.
Employees see execution-only controls as currently enforced.
3) Meetings Page (/departments/projects/meetings)
Purpose: weekly planning and scheduling.
Weekly calendar view (Mon-Sun), 30-min slots, default 07:00-21:00.
Hybrid feed:
My personal events.
Team meetings where user is organizer/participant.
Meeting cards display status + attendee response summary.
Create event modal:
Tipo: meeting or personal_event
title, agenda/notes, start/end, participants.
Meetings created from Projects are auto-added to calendar.
Confirmation flow:
Invitees start as pending.
Invitee can accept or decline.
Organizer sees response counts and unresolved attendees.
Filters: All, Mine, Team, Pending response.
4) Capture Page (/departments/projects/capture)
Purpose: evidence intake and traceability.
Tabs:
Task Evidence
Project Evidence
My Uploads
Capture form:
association: project/task
evidence type: photo, document, link
title, note
link URL OR local file upload
Evidence list:
preview thumbnail (images), file badge, link badge
uploader, timestamp, associated project/task
Search/filter by project, task, uploader, date range.
Deletion permissions:
uploader can delete own evidence
leaders/owners can delete any project evidence.
5) Team Page (/departments/projects/team)
Purpose: project people visibility.
Team members list, role, current assigned tasks, blocked items.
Quick links to person detail (/people/:id) and assigned projects.
Read-only in V1.2 (management actions remain in People module).
Data Model / Persistence Changes
Prisma additions
MarketingMeetingParticipant (new table):
id
meetingId
userId (nullable for external participants if needed)
displayName
responseStatus enum: pending | accepted | declined
respondedAt
unique composite (meetingId, userId) when userId is present
ProjectCalendarEvent (new table):
id, department (default proyectos)
ownerUserId
title, notes
startAt, endAt
visibility (personal|team)
createdAt, updatedAt
ProjectCaptureEvidence (new table):
id, department
initiativeId nullable
taskId nullable
uploadedById
kind (photo|document|link)
title, note
url nullable
storagePath nullable
mimeType nullable
sizeBytes nullable
createdAt, updatedAt
Local upload storage defaults (locked)
Storage mode: server local uploads.
Base directory: PROJECT_UPLOAD_DIR (default /home/mdares03/benell/uploads/projects).
Max file size: 10 MB.
Allowed mime/extensions: image/jpeg, image/png, image/webp, application/pdf.
Files served through authenticated API route, not direct public static path.
Filenames replaced with generated IDs (no user filename trust).
Public API / Interface / Type Changes
Navigation/access
Update nav mapping so Projects gets dedicated route set and labels.
Keep shared nav for other departments unchanged.
Update active-state logic in Sidebar and MobileNav for new subroutes.
Meetings + calendar APIs
GET /api/projects/meetings
include participants with responseStatus and counts.
POST /api/projects/meetings
creates meeting + participant rows (pending).
calendar inclusion metadata returned.
PATCH /api/projects/meetings/:id
schedule/complete/cancel as today, plus participant updates when needed.
PATCH /api/projects/meetings/:id/respond (new)
attendee sets accepted|declined.
GET /api/projects/calendar/events (new)
POST /api/projects/calendar/events (new)
PATCH /api/projects/calendar/events/:id (new)
DELETE /api/projects/calendar/events/:id (new)
Capture APIs
GET /api/projects/capture (new, filterable list)
POST /api/projects/capture/link (new)
POST /api/projects/capture/upload (new, multipart/form-data)
DELETE /api/projects/capture/:id (new)
GET /api/projects/capture/assets/:id (new, secure file stream)
Type updates
Add Projects nav type map for page keys/labels/routes.
Add MeetingParticipant, MeetingResponseStatus.
Add ProjectCalendarEvent.
Add ProjectCaptureEvidence + upload response payloads.
RBAC Rules (explicit)
Owner:
full access across Projects pages/APIs.
Projects leader:
full management in Projects module.
Projects employee:
can view all Projects pages.
can update assigned tasks.
can respond to meeting invitations involving them.
can create personal calendar events.
can upload evidence tied to own tasks/assigned projects.
Non-project departments:
cannot access /departments/projects/* pages or Projects APIs.
Implementation Sequence (decision complete)
Foundation refactor:
extract Projects page sections into reusable components.
remove scrollIntoView route behavior.
Route scaffolding:
add real pages for projects, meetings, capture, team.
add redirect from /departments/projects/initiatives.
Nav/access updates:
route maps, labels, active-state logic for desktop/mobile.
Meetings participant model:
Prisma migration + API updates + response endpoint.
Calendar events model:
Prisma migration + CRUD APIs + weekly calendar UI.
Capture model + local uploads:
Prisma migration + upload/link endpoints + secure asset serving.
Capture UI:
forms, association selectors, evidence feed, filters.
Regression pass:
ensure marketing routes remain unaffected and no broken links.
Production readiness:
upload dir creation/permissions + env vars + service restart checklist.
File Touchpoints (expected)
src/components/layout/nav-items.ts
src/components/layout/Sidebar.tsx
src/components/layout/MobileNav.tsx
src/lib/access-control.ts
src/app/(app)/departments/projects/page.tsx (overview only)
src/app/(app)/departments/projects/initiatives/page.tsx (redirect)
src/app/(app)/departments/projects/projects/page.tsx (new)
src/app/(app)/departments/projects/meetings/page.tsx (real calendar page)
src/app/(app)/departments/projects/capture/page.tsx (new)
src/app/(app)/departments/projects/team/page.tsx (new)
src/app/api/projects/meetings/route.ts
src/app/api/projects/meetings/[id]/route.ts
src/app/api/projects/meetings/[id]/respond/route.ts (new)
src/app/api/projects/calendar/events/route.ts (new)
src/app/api/projects/calendar/events/[id]/route.ts (new)
src/app/api/projects/capture/* (new route group)
src/lib/projects/types.ts
src/lib/projects/persistence.ts
prisma/schema.prisma
Prisma migration files for new models/enums.
Test Cases and Scenarios
Navigation correctness
Sidebar Overview/Projects/Meetings/Capture/Team each open distinct page.
No route causes in-page scroll jumps.
/departments/projects/initiatives redirects to /departments/projects/projects.
Mobile nav mirrors desktop routes and active states.
Meetings/calendar flow
Create Projects meeting from UI appears on weekly calendar.
Invitee sees meeting as pending.
Invitee accepts; organizer sees updated counts and status badge.
Invitee declines; event remains visible with declined marker.
Personal event creation appears only in owners view unless visibility is team.
Capture flow
Upload image/pdf successfully and metadata persists.
Add link evidence successfully and renders in feed.
Evidence can be tied to project and/or task.
Unauthorized user cannot fetch another departments capture asset.
Delete permissions enforce uploader-or-manager rule.
RBAC
Employee cannot alter project definition/stage/targets.
Employee can update assigned tasks and submit evidence.
Non-project user gets 403 on projects APIs and pages.
Owner retains full access.
Regression
Marketing and other departments still navigate correctly.
Existing Projects financial logic remains intact.
Build/lint/typecheck pass.
No broken internal links in sidebar/mobile and page CTAs.
Acceptance Criteria
Projects navigation is page-based, not section-scroll based.
Meetings has a weekly calendar and confirmation workflow.
Meetings auto-populate calendar on creation.
Capture page supports links and local file uploads with evidence traceability.
Sidebar labels and routes are coherent for Projects operations.
No broken links and no cross-department permission leakage.
Assumptions and Defaults Chosen
Storage for Capture in V1.2 is local server filesystem (as selected).
Timezone display defaults to app/user locale; stored timestamps remain UTC.
Existing meeting statuses remain (requested/scheduled/completed/cancelled); attendee response is separate participant state.
Marketing module behavior and routes are not redesigned in this phase.
Team page is read-only in V1.2; People module remains source of truth for personnel edits.
Execution Checklist (Projects Lead Runbook)
Objective
Ship V1.2 with stable navigation, calendar + attendee confirmations, and evidence capture without regressions in other departments.
Operating cadence
Monday planning:
- review Overview KPIs and alerts.
- lock weekly priorities and milestone owners.
- publish all team meetings for the week in Meetings page.
Daily control loop:
- triage blocked and overdue items in Overview.
- unblock/escalate anything aging >24h.
- enforce project/task updates in Projects page only.
Friday close:
- verify evidence completeness in Capture.
- close unresolved meeting responses.
- publish weekly delivery summary and carry-over risks.
Definition of done (workflow)
- A task cannot be marked complete without evidence (link or upload) tied to task or project.
- Every milestone has owner, due date, status, and risk flag.
- Every meeting has agenda, participants, and decision notes.
- Every blocker has escalation owner and next action date.
Phase-based implementation checklist
Phase 0 - preflight (0.5 day)
- confirm V1.2 scope lock with engineering + design.
- confirm env var defaults for uploads and filesystem permissions.
- capture baseline behavior videos/screenshots for regression comparison.
- define feature flags if rollout will be staged.
Exit criteria:
- scope is frozen, rollout owner assigned, baseline captured.
Phase 1 - navigation foundation (1 day)
- remove in-page scroll navigation behavior from Projects.
- implement dedicated Projects route map in desktop and mobile nav.
- add compatibility redirect `/departments/projects/initiatives -> /departments/projects/projects`.
- validate active-state logic by pathname prefix.
Exit criteria:
- each sidebar item opens a distinct page; no scroll jump behavior remains.
Phase 2 - route scaffolding and page split (1 to 1.5 days)
- keep Overview at `/departments/projects`.
- create real pages: `/projects`, `/meetings`, `/capture`, `/team`.
- move existing reusable sections/components out of monolithic page.
- ensure role-based access gates on new routes.
Exit criteria:
- all five pages render and route correctly for allowed roles.
Phase 3 - meetings participants model (1 day)
- Prisma migration for `MarketingMeetingParticipant`.
- update meetings list/create/update APIs to include participant rows and counts.
- add response endpoint `PATCH /api/projects/meetings/:id/respond`.
- display pending/accepted/declined summaries in meetings UI.
Exit criteria:
- invitation lifecycle works end-to-end with correct counts.
Phase 4 - calendar events model + weekly UI (1.5 to 2 days)
- Prisma migration for `ProjectCalendarEvent`.
- CRUD APIs for calendar events.
- weekly calendar UI (Mon-Sun, 30-min slots, 07:00-21:00 default).
- auto-create calendar event when Projects meeting is created.
- filters: All, Mine, Team, Pending response.
Exit criteria:
- new meetings appear in calendar; personal and team events behave as expected.
Phase 5 - capture model + secure local uploads (2 days)
- Prisma migration for `ProjectCaptureEvidence`.
- implement link create/list/delete APIs.
- implement upload endpoint with MIME/size validation (10 MB max).
- implement secure asset stream endpoint (authenticated, RBAC checked).
- implement capture UI tabs and filterable evidence feed.
Exit criteria:
- links and files are persisted, visible, downloadable, and permission-protected.
Phase 6 - regression + hardening (1 day)
- run navigation, RBAC, meetings/calendar, and capture scenarios.
- run typecheck/lint/build and fix all blocking defects.
- verify non-project departments unchanged.
- verify no broken internal links.
Exit criteria:
- acceptance criteria pass and release notes are ready.
Implementation Tickets (V1.2 Backlog)
PJT-1201 - Projects nav route map refactor
Scope:
- update `nav-items.ts`, `Sidebar.tsx`, `MobileNav.tsx` for dedicated Projects IA.
- remove alias/section-scroll assumptions.
Depends on:
- none
Acceptance:
- Projects shows Overview, Projects, Meetings, Capture, Team routes with correct active state.
PJT-1202 - Redirect compatibility for legacy initiatives route
Scope:
- implement redirect from `/departments/projects/initiatives` to `/departments/projects/projects`.
Depends on:
- PJT-1201
Acceptance:
- direct visits and in-app links always land on `/projects`.
PJT-1203 - Split Projects monolith into dedicated pages
Scope:
- scaffold and render page-specific components for overview/projects/meetings/capture/team.
Depends on:
- PJT-1201
Acceptance:
- each page has unique URL and no in-page scroll coupling.
PJT-1204 - Meeting participant persistence model
Scope:
- add `MarketingMeetingParticipant` model and migration.
- wire participants into meetings APIs.
Depends on:
- PJT-1203
Acceptance:
- meetings return participant response states and summary counts.
PJT-1205 - Meeting invite response endpoint
Scope:
- add `PATCH /api/projects/meetings/:id/respond`.
- enforce participant-only response updates.
Depends on:
- PJT-1204
Acceptance:
- invitees can accept/decline; organizer sees live status changes.
PJT-1206 - Calendar events persistence + APIs
Scope:
- add `ProjectCalendarEvent` model + CRUD routes.
- enforce personal/team visibility rules.
Depends on:
- PJT-1203
Acceptance:
- create/edit/delete works with role constraints and list filters.
PJT-1207 - Weekly calendar UI
Scope:
- render weekly grid in Meetings page with 30-min slots and default hours.
- add create event modal.
Depends on:
- PJT-1206
Acceptance:
- events show in correct slots and respect filter mode.
PJT-1208 - Auto-populate calendar from meetings
Scope:
- create/maintain linked calendar records for project meetings.
Depends on:
- PJT-1204, PJT-1206
Acceptance:
- meeting creation always appears in calendar without manual duplication.
PJT-1209 - Capture evidence persistence model
Scope:
- add `ProjectCaptureEvidence` model + migration.
Depends on:
- PJT-1203
Acceptance:
- evidence rows support project/task relation, metadata, and ownership.
PJT-1210 - Capture link and upload APIs
Scope:
- implement list/create/delete routes for link and file evidence.
- enforce 10 MB and MIME allowlist.
Depends on:
- PJT-1209
Acceptance:
- valid uploads succeed, invalid files are rejected with clear errors.
PJT-1211 - Secure capture asset streaming
Scope:
- authenticated download route by evidence id.
- prevent cross-department and unauthorized access.
Depends on:
- PJT-1210
Acceptance:
- authorized users can fetch assets; unauthorized users receive 403.
PJT-1212 - Capture UI tabs and evidence feed
Scope:
- implement Task Evidence / Project Evidence / My Uploads tabs.
- implement preview badges and filters.
Depends on:
- PJT-1210, PJT-1211
Acceptance:
- users can upload, filter, preview, and delete per permission rules.
PJT-1213 - Projects RBAC enforcement sweep
Scope:
- verify page/API protections for owner, leader, employee, non-project users.
Depends on:
- PJT-1203 through PJT-1212
Acceptance:
- RBAC matrix in plan passes all listed scenarios.
PJT-1214 - Regression, QA, and release checklist
Scope:
- execute test scenarios and fix defects.
- finalize deployment steps: upload directory, env vars, service restart checks.
Depends on:
- PJT-1201 through PJT-1213
Acceptance:
- all acceptance criteria pass; no navigation regressions outside Projects.
KPIs to track during rollout
- Navigation defects: target 0 critical.
- Meeting invite response within 24h: target >=90%.
- Evidence completeness on completed tasks: target >=95%.
- Blocker aging >48h: target 0 without escalation owner.
- Milestones delivered on time: upward trend week-over-week.

BIN
public/brand/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
public/brand/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/brand/mascot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,218 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import KPIStatCard from "@/components/KPIStatCard";
import WeeklyKpiBoard from "@/components/kpis/WeeklyKpiBoard";
import SankeyChart from "@/components/SankeyChart";
import { financialPreview } from "@/lib/mock";
import { useUIStore } from "@/lib/store/ui-store";
import { getDepartmentHomeRoute } from "@/lib/access-control";
import { applyRangeMultiplier, formatCurrencyK, ROLE_LABELS } from "@/lib/utils";
import type { DepartmentHealth, DepartmentKey, UserRole } from "@/lib/types";
import type { OwnerCaptureRollupDTO } from "@/lib/capture";
const OWNER_CARD_ORDER: Array<"marketing" | "capital_humano" | "operaciones" | "proyectos"> = [
"marketing",
"capital_humano",
"operaciones",
"proyectos",
];
const OWNER_CARD_ROUTE: Record<(typeof OWNER_CARD_ORDER)[number], string> = {
marketing: "/data-entry?department=marketing",
capital_humano: "/data-entry?department=capital_humano",
operaciones: "/data-entry?department=operaciones",
proyectos: "/data-entry?department=proyectos",
};
export default function DashboardPage() {
const router = useRouter();
const { data: session, status } = useSession();
const dateRange = useUIStore((state) => state.dateRange);
const role = (session?.user?.role ?? "employee") as UserRole;
const isOwner = role === "owner";
const [captureRollup, setCaptureRollup] = useState<OwnerCaptureRollupDTO | null>(null);
const [rollupError, setRollupError] = useState<string | null>(null);
const [isRollupLoading, setIsRollupLoading] = useState(false);
useEffect(() => {
if (status !== "authenticated") {
return;
}
if (session.user.role === "owner" || session.user.role === "leader") {
return;
}
const department = (session.user.department as DepartmentKey | null | undefined) ?? null;
router.replace(getDepartmentHomeRoute(department));
}, [router, session, status]);
useEffect(() => {
if (status !== "authenticated" || !isOwner) {
return;
}
let cancelled = false;
async function loadRollup() {
setIsRollupLoading(true);
try {
const response = await fetch("/api/owner/capture-rollup", {
method: "GET",
cache: "no-store",
});
const payload = (await response.json()) as OwnerCaptureRollupDTO & { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar capture rollup.");
}
if (!cancelled) {
setCaptureRollup(payload);
setRollupError(null);
}
} catch (error) {
if (!cancelled) {
setRollupError(error instanceof Error ? error.message : "No se pudo cargar capture rollup.");
}
} finally {
if (!cancelled) {
setIsRollupLoading(false);
}
}
}
void loadRollup();
return () => {
cancelled = true;
};
}, [isOwner, status]);
const ownerDepartmentCards = useMemo(() => {
const rollupByDepartment = new Map(captureRollup?.departments.map((department) => [department.department, department]) ?? []);
return OWNER_CARD_ORDER.map((department) => {
const rollup = rollupByDepartment.get(department);
if (!rollup) {
return null;
}
const summary =
rollup.automationHealth === "healthy"
? "Automation healthy"
: rollup.automationHealth === "attention"
? "Automation attention"
: "Automation down";
const card: DepartmentHealth = {
id: rollup.department,
name: rollup.label,
score: Math.round(rollup.capturePct),
summary,
metrics: [
{ label: "Captured", value: `${rollup.capturedRows}/${rollup.totalRows}` },
{ label: "Stale", value: String(rollup.staleRows) },
{ label: "At risk", value: String(rollup.atRiskRows) },
],
};
return {
...card,
href: OWNER_CARD_ROUTE[department],
};
}).filter((entry): entry is DepartmentHealth & { href: string } => Boolean(entry));
}, [captureRollup]);
const previewData = useMemo(
() => ({
nodes: financialPreview.nodes.map((node) => {
const value = applyRangeMultiplier(node.value, dateRange);
return {
...node,
value,
name:
node.name === "Ingreso"
? `${node.name} (${formatCurrencyK(value)})`
: node.name === "Utilidad"
? `${node.name} (${formatCurrencyK(value)})`
: `${node.name}`,
};
}),
links: financialPreview.links.map((link) => ({
...link,
value: applyRangeMultiplier(link.value, dateRange),
})),
}),
[dateRange]
);
if (status === "loading") {
return <p className="text-body text-benell-text-soft">Cargando...</p>;
}
if (status === "authenticated" && session?.user.role === "employee") {
return <p className="text-body text-benell-text-soft">Redirigiendo...</p>;
}
return (
<div className="space-y-8">
<header>
<h1 className="text-display text-benell-text">Hola, {session?.user?.name?.trim() || "Dueño"}</h1>
<p className="text-body-lg mt-1 text-benell-text-soft">
Resumen semanal de Casa Benell · Vista {ROLE_LABELS[role]}
</p>
</header>
{isOwner ? (
<>
<section aria-labelledby="financial-preview" className="rounded-benell border border-benell-stroke bg-benell-surface p-4 md:p-6">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<h2 id="financial-preview" className="text-h2 min-w-0 font-semibold">
Resumen de Sankey · Últimas 4 semanas
</h2>
<Link
href="/financial-flow"
className="text-label shrink-0 font-semibold text-benell-brown outline-none transition hover:text-black focus-visible:ring-2 focus-visible:ring-benell-brown"
>
Ver detalle completo
</Link>
</div>
<SankeyChart
compact
data={previewData}
onNodeSelect={(nodeName) => {
const selected = nodeName.split(" (")[0].toLowerCase();
router.push(`/financial-flow?node=${encodeURIComponent(selected)}`);
}}
/>
</section>
<WeeklyKpiBoard
mode="owner_summary"
middleContent={
<section aria-labelledby="owner-department-kpis" className="space-y-4">
<h3 id="owner-department-kpis" className="text-h3 font-semibold">
KPIs por área
</h3>
{isRollupLoading ? <p className="text-caption text-benell-text-soft">Cargando capture rollups...</p> : null}
{rollupError ? <p className="text-caption text-benell-red">{rollupError}</p> : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{ownerDepartmentCards.map((card) => (
<KPIStatCard key={card.id} data={card} href={card.href} />
))}
</div>
</section>
}
/>
</>
) : (
<WeeklyKpiBoard />
)}
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import PrintToolbar from "@/components/kpis/PrintToolbar";
import { authOptions } from "@/lib/auth";
import { getDepartmentHomeRoute } from "@/lib/access-control";
import { canViewWeeklyKpiBoard, getWeeklyKpiBoard } from "@/lib/kpis/persistence";
import type { KpiRowStatus } from "@/lib/kpis/types";
export const dynamic = "force-dynamic";
const STATUS_LABELS: Record<KpiRowStatus, string> = {
on_track: "En ruta",
watch: "Seguimiento",
risk: "Riesgo",
no_score: "Sin score",
};
function formatDate(isoDate: string | null): string {
if (!isoDate) {
return "-";
}
const parsed = new Date(`${isoDate}T00:00:00.000Z`);
return new Intl.DateTimeFormat("es-MX", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
}).format(parsed);
}
export default async function DashboardPrintPage({
searchParams,
}: {
searchParams?: { weekStart?: string };
}) {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login");
}
const viewer = {
role: session.user.role,
department: session.user.department ?? null,
};
if (!canViewWeeklyKpiBoard(viewer)) {
redirect(getDepartmentHomeRoute(viewer.department));
}
const board = await getWeeklyKpiBoard({
weekStartInput: searchParams?.weekStart,
viewer,
});
return (
<div className="mx-auto max-w-[1100px] bg-white p-4 text-black md:p-6">
<style>
{`@media print {
@page { size: A4 portrait; margin: 10mm; }
body { background: white; }
}`}
</style>
<PrintToolbar />
<header className="mb-4 border-b border-gray-300 pb-3">
<h1 className="text-2xl font-semibold">Weekly KPI Board</h1>
<p className="mt-1 text-sm text-gray-600">
Semana {board.weekStart} a {board.weekEnd} · Fuente: {board.source}
</p>
<p className="mt-1 text-sm text-gray-600">
Actualizado: {board.lastUpdatedAt ? new Date(board.lastUpdatedAt).toLocaleString("es-MX") : "Sin actualización"}
</p>
<p className="mt-1 text-sm text-gray-600">
Total KPIs: {board.summary.totalRows} · En ruta: {board.summary.onTrackPct.toFixed(1)}% · Riesgo: {board.summary.atRiskRows}
</p>
<p className="mt-1 text-sm text-gray-600">
Cobertura: {board.coverage.mappedSections}/{board.coverage.totalSections} secciones mapeadas ({board.coverage.mappedPct.toFixed(1)}%)
</p>
</header>
<section className="mb-4 grid grid-cols-2 gap-2 md:grid-cols-4">
<article className="rounded border border-gray-300 p-2">
<p className="text-xs text-gray-600">KPIs Totales</p>
<p className="text-sm font-semibold">{board.summary.totalRows}</p>
</article>
<article className="rounded border border-gray-300 p-2">
<p className="text-xs text-gray-600">En Ruta</p>
<p className="text-sm font-semibold">{board.summary.onTrackPct.toFixed(1)}%</p>
</article>
<article className="rounded border border-gray-300 p-2">
<p className="text-xs text-gray-600">Riesgo</p>
<p className="text-sm font-semibold">{board.summary.atRiskRows}</p>
</article>
<article className="rounded border border-gray-300 p-2">
<p className="text-xs text-gray-600">Vencen pronto</p>
<p className="text-sm font-semibold">{board.summary.dueSoonRows}</p>
</article>
</section>
<div className="space-y-4">
{board.sections.map((section) => (
<section key={section.id} className="break-inside-avoid rounded border border-gray-300 p-3">
<header className="mb-2 flex items-center justify-between">
<h2 className="text-base font-semibold">{section.rawSectionLabel}</h2>
<p className="text-xs text-gray-600">{section.ownerTeamLabel || "Sin owner"}</p>
</header>
<div className="overflow-x-auto">
<table className="min-w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100 text-left">
<th className="border border-gray-300 px-2 py-1">Responsabilidad</th>
<th className="border border-gray-300 px-2 py-1">Objetivo / Indicador</th>
<th className="border border-gray-300 px-2 py-1">Quantity & Quality</th>
<th className="border border-gray-300 px-2 py-1">% Cumplimiento</th>
<th className="border border-gray-300 px-2 py-1">Fecha / Compromiso</th>
<th className="border border-gray-300 px-2 py-1">Estatus</th>
</tr>
</thead>
<tbody>
{section.rows.map((row) => (
<tr key={row.id}>
<td className="border border-gray-300 px-2 py-1 align-top">{row.responsibility}</td>
<td className="border border-gray-300 px-2 py-1 align-top">{row.objectiveIndicator || "-"}</td>
<td className="border border-gray-300 px-2 py-1 align-top">{row.quantityQuality || "-"}</td>
<td className="border border-gray-300 px-2 py-1 align-top">
{row.compliancePct !== null ? `${row.compliancePct.toFixed(1)}%` : row.compliance || "-"}
</td>
<td className="border border-gray-300 px-2 py-1 align-top">
<p>{row.dueCommitment || "-"}</p>
<p className="text-gray-600">{formatDate(row.dueDate)}</p>
</td>
<td className="border border-gray-300 px-2 py-1 align-top">{STATUS_LABELS[row.status]}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import CaptureWorkspace from "@/components/capture/CaptureWorkspace";
export default function DataEntryPage({
searchParams,
}: {
searchParams?: {
department?: string;
};
}) {
return <CaptureWorkspace requestedDepartment={searchParams?.department ?? null} />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminDepartmentPage() {
redirect("/financial-flow");
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function FinanceDepartmentPage() {
redirect("/financial-flow");
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,633 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSession } from "next-auth/react";
import type {
HumanCapitalFileRequirementDTO,
HumanCapitalWorkspaceTabConfig,
HumanCapitalWorkspaceTabKey,
HumanCapitalWorkspaceVersion,
HumanCapitalWorkspaceWidgetKey,
} from "@/lib/human-capital/types";
const TAB_CATALOG: Array<{
key: HumanCapitalWorkspaceTabKey;
label: string;
description: string;
widgets: HumanCapitalWorkspaceWidgetKey[];
}> = [
{
key: "resumen",
label: "Resumen",
description: "Score, tendencias y vision general de Capital Humano.",
widgets: ["summary_metrics"],
},
{
key: "expedientes",
label: "Expedientes",
description: "Checklist de documentos por persona y estatus de completitud.",
widgets: ["people_files_table"],
},
{
key: "nomina",
label: "Nomina",
description: "Percepciones, deducciones y aportaciones por periodo y ubicacion.",
widgets: ["payroll_summary"],
},
{
key: "reclutamiento",
label: "Reclutamiento",
description: "Headcount actual, vacantes y organizacion ideal por sucursal/rol.",
widgets: ["recruitment_headcount", "recruitment_vacancies", "recruitment_org_target"],
},
{
key: "desarrollo",
label: "Desarrollo",
description: "Anuncios internos y cursos para crecimiento de carrera.",
widgets: ["career_feed"],
},
{
key: "cumplimiento",
label: "Cumplimiento",
description: "IMSS, Infonavit, Fonacot y seguimiento de pagos.",
widgets: ["compliance_overview"],
},
];
const WIDGET_OPTIONS: Array<{ key: HumanCapitalWorkspaceWidgetKey; label: string }> = [
{ key: "summary_metrics", label: "Resumen y KPI" },
{ key: "people_files_table", label: "Tabla de expedientes" },
{ key: "payroll_summary", label: "Resumen de nomina" },
{ key: "recruitment_headcount", label: "Headcount" },
{ key: "recruitment_vacancies", label: "Vacantes" },
{ key: "recruitment_org_target", label: "Organizacion ideal" },
{ key: "career_feed", label: "Anuncios y cursos" },
{ key: "compliance_overview", label: "Cumplimiento y pagos" },
];
const FIELD_TYPE_OPTIONS = ["document", "text", "email", "phone", "date", "number"] as const;
type RequirementDraft = HumanCapitalFileRequirementDTO & { isNew?: boolean };
function normalizeOrder(tabs: HumanCapitalWorkspaceTabConfig[]) {
return tabs.map((tab, idx) => ({ ...tab, order: idx }));
}
function sortByOrder(tabs: HumanCapitalWorkspaceTabConfig[]) {
return [...tabs].sort((a, b) => a.order - b.order);
}
export default function HumanCapitalWorkspaceConfigPage() {
const { data: session } = useSession();
const canManage =
session?.user?.role === "owner" ||
(session?.user?.role === "leader" &&
(session?.user?.department === "capital_humano" || session?.user?.department === "administracion"));
const [tabs, setTabs] = useState<HumanCapitalWorkspaceTabConfig[]>([]);
const [history, setHistory] = useState<HumanCapitalWorkspaceVersion[]>([]);
const [requirements, setRequirements] = useState<RequirementDraft[]>([]);
const [changeSummary, setChangeSummary] = useState("");
const [newTabKey, setNewTabKey] = useState<HumanCapitalWorkspaceTabKey | "">("");
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const unusedTabs = useMemo(() => {
const used = new Set(tabs.map((tab) => tab.key));
return TAB_CATALOG.filter((tab) => !used.has(tab.key));
}, [tabs]);
useEffect(() => {
if (unusedTabs.length > 0) {
setNewTabKey(unusedTabs[0].key);
} else {
setNewTabKey("");
}
}, [unusedTabs]);
useEffect(() => {
let cancelled = false;
async function load() {
if (!canManage) {
setLoading(false);
return;
}
try {
const [configResponse, historyResponse, requirementsResponse] = await Promise.all([
fetch("/api/human-capital/workspace/config?mode=draft", { method: "GET", cache: "no-store" }),
fetch("/api/human-capital/workspace/history", { method: "GET", cache: "no-store" }),
fetch("/api/human-capital/files/requirements", { method: "GET", cache: "no-store" }),
]);
const configPayload = (await configResponse.json()) as { config?: { tabs?: HumanCapitalWorkspaceTabConfig[] }; error?: string };
const historyPayload = (await historyResponse.json()) as { history?: HumanCapitalWorkspaceVersion[]; error?: string };
const requirementsPayload = (await requirementsResponse.json()) as { requirements?: HumanCapitalFileRequirementDTO[]; error?: string };
if (!configResponse.ok) {
throw new Error(configPayload.error ?? "No se pudo cargar configuracion.");
}
if (!historyResponse.ok) {
throw new Error(historyPayload.error ?? "No se pudo cargar historial.");
}
if (!requirementsResponse.ok) {
throw new Error(requirementsPayload.error ?? "No se pudieron cargar requisitos.");
}
if (!cancelled) {
setTabs(sortByOrder(configPayload.config?.tabs ?? []));
setHistory(historyPayload.history ?? []);
setRequirements(requirementsPayload.requirements ?? []);
}
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar configuracion.");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [canManage]);
const updateTab = (index: number, updates: Partial<HumanCapitalWorkspaceTabConfig>) => {
setTabs((prev) => {
const next = [...prev];
next[index] = { ...next[index], ...updates };
return next;
});
};
const moveTab = (index: number, direction: "up" | "down") => {
setTabs((prev) => {
const next = [...prev];
const target = direction === "up" ? index - 1 : index + 1;
if (target < 0 || target >= next.length) {
return prev;
}
const temp = next[index];
next[index] = next[target];
next[target] = temp;
return normalizeOrder(next);
});
};
const toggleWidget = (index: number, widget: HumanCapitalWorkspaceWidgetKey) => {
setTabs((prev) => {
const next = [...prev];
const widgets = new Set(next[index].widgets);
if (widgets.has(widget)) {
widgets.delete(widget);
} else {
widgets.add(widget);
}
next[index] = { ...next[index], widgets: Array.from(widgets) };
return next;
});
};
const addTab = () => {
if (!newTabKey) {
return;
}
const template = TAB_CATALOG.find((tab) => tab.key === newTabKey);
if (!template) {
return;
}
setTabs((prev) =>
normalizeOrder([
...prev,
{
key: template.key,
label: template.label,
description: template.description,
order: prev.length,
visible: true,
widgets: template.widgets,
},
])
);
};
const saveDraft = async () => {
setError(null);
setStatus(null);
setSaving(true);
try {
const response = await fetch("/api/human-capital/workspace/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tabs: normalizeOrder(tabs), changeSummary }),
});
const payload = (await response.json()) as { error?: string; config?: { tabs?: HumanCapitalWorkspaceTabConfig[] } };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo guardar el borrador.");
}
setTabs(sortByOrder(payload.config?.tabs ?? []));
setStatus("Borrador guardado.");
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "No se pudo guardar el borrador.");
} finally {
setSaving(false);
}
};
const publishDraft = async () => {
setError(null);
setStatus(null);
setSaving(true);
try {
const response = await fetch("/api/human-capital/workspace/publish", { method: "POST" });
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo publicar.");
}
setStatus("Workspace publicado.");
const historyResponse = await fetch("/api/human-capital/workspace/history", { method: "GET", cache: "no-store" });
const historyPayload = (await historyResponse.json()) as { history?: HumanCapitalWorkspaceVersion[] };
setHistory(historyPayload.history ?? []);
} catch (publishError) {
setError(publishError instanceof Error ? publishError.message : "No se pudo publicar.");
} finally {
setSaving(false);
}
};
const loadPublished = async () => {
setError(null);
setStatus(null);
setSaving(true);
try {
const response = await fetch("/api/human-capital/workspace/config?mode=published", { method: "GET", cache: "no-store" });
const payload = (await response.json()) as { error?: string; config?: { tabs?: HumanCapitalWorkspaceTabConfig[] } };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar configuracion publicada.");
}
setTabs(sortByOrder(payload.config?.tabs ?? []));
setStatus("Configuracion publicada cargada en el editor.");
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar configuracion publicada.");
} finally {
setSaving(false);
}
};
const applyHistoryVersion = (version: HumanCapitalWorkspaceVersion) => {
setTabs(sortByOrder(version.tabs));
setChangeSummary(`Rollback a version ${version.version}`);
setStatus(`Version ${version.version} cargada en el editor.`);
};
const updateRequirement = (index: number, updates: Partial<RequirementDraft>) => {
setRequirements((prev) => {
const next = [...prev];
next[index] = { ...next[index], ...updates };
return next;
});
};
const addRequirement = () => {
setRequirements((prev) => [
...prev,
{
id: "",
key: "",
label: "",
description: "",
fieldType: "document",
isRequired: true,
isActive: true,
sortOrder: prev.length * 10,
isNew: true,
},
]);
};
const saveRequirements = async () => {
setError(null);
setStatus(null);
setSaving(true);
try {
const response = await fetch("/api/human-capital/files/requirements", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requirements }),
});
const payload = (await response.json()) as { error?: string; requirements?: HumanCapitalFileRequirementDTO[] };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudieron guardar requisitos.");
}
setRequirements(payload.requirements ?? []);
setStatus("Requisitos guardados.");
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "No se pudieron guardar requisitos.");
} finally {
setSaving(false);
}
};
if (!canManage) {
return (
<div className="rounded-benell border border-benell-stroke bg-benell-surface p-6 shadow-benell">
<h1 className="text-h2 font-semibold">Configuracion de Capital Humano</h1>
<p className="text-body text-benell-text-soft">Solo lideres pueden editar el workspace.</p>
</div>
);
}
return (
<div className="space-y-6">
<header className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<h1 className="text-display">Configuracion de Capital Humano</h1>
<p className="text-body-lg text-benell-text-soft">Administra tabs, widgets y requisitos sin hardcode.</p>
</header>
{loading ? <p className="text-label text-benell-text-soft">Cargando configuracion...</p> : null}
{error ? <p className="text-label text-benell-red">{error}</p> : null}
{status ? <p className="text-label text-benell-text-soft">{status}</p> : null}
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-h2 font-semibold">Tabs y widgets</h2>
<p className="text-body text-benell-text-soft">Define visibilidad, orden y contenido de cada tab.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void saveDraft()}
disabled={saving}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Guardar borrador
</button>
<button
type="button"
onClick={() => void publishDraft()}
disabled={saving}
className="rounded-pill border border-benell-brown px-3 py-2 text-label font-semibold text-benell-brown disabled:opacity-50"
>
Publicar
</button>
<button
type="button"
onClick={() => void loadPublished()}
disabled={saving}
className="rounded-pill border border-benell-stroke px-3 py-2 text-label font-semibold text-benell-text-soft disabled:opacity-50"
>
Cargar publicado
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-4">
{tabs.map((tab, index) => (
<article key={tab.key} className="rounded-benell border border-benell-stroke bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[220px]">
<label className="text-xs font-semibold text-benell-text-soft">Titulo</label>
<input
value={tab.label}
onChange={(event) => updateTab(index, { label: event.target.value })}
className="mt-1 w-full rounded-lg border border-benell-stroke px-3 py-2 text-sm"
/>
</div>
<div className="flex-1 min-w-[220px]">
<label className="text-xs font-semibold text-benell-text-soft">Descripcion</label>
<input
value={tab.description ?? ""}
onChange={(event) => updateTab(index, { description: event.target.value })}
className="mt-1 w-full rounded-lg border border-benell-stroke px-3 py-2 text-sm"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={tab.visible}
onChange={(event) => updateTab(index, { visible: event.target.checked })}
/>
Visible
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => moveTab(index, "up")}
disabled={index === 0}
className="rounded-lg border border-benell-stroke px-2 py-1 text-xs text-benell-text-soft disabled:opacity-50"
>
Subir
</button>
<button
type="button"
onClick={() => moveTab(index, "down")}
disabled={index === tabs.length - 1}
className="rounded-lg border border-benell-stroke px-2 py-1 text-xs text-benell-text-soft disabled:opacity-50"
>
Bajar
</button>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2">
{WIDGET_OPTIONS.map((widget) => (
<label key={widget.key} className="flex items-center gap-2 text-sm text-benell-text">
<input
type="checkbox"
checked={tab.widgets.includes(widget.key)}
onChange={() => toggleWidget(index, widget.key)}
/>
{widget.label}
</label>
))}
</div>
</article>
))}
</div>
{unusedTabs.length > 0 ? (
<div className="mt-4 flex flex-wrap items-center gap-3">
<select
value={newTabKey}
onChange={(event) => setNewTabKey(event.target.value as HumanCapitalWorkspaceTabKey)}
className="rounded-lg border border-benell-stroke px-3 py-2 text-sm"
>
{unusedTabs.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
<button
type="button"
onClick={addTab}
className="rounded-pill border border-benell-stroke px-3 py-2 text-label font-semibold text-benell-text"
>
Agregar tab
</button>
</div>
) : null}
<div className="mt-4">
<label className="text-xs font-semibold text-benell-text-soft">Resumen del cambio</label>
<input
value={changeSummary}
onChange={(event) => setChangeSummary(event.target.value)}
placeholder="Ej: Ajuste de tabs por reclutamiento"
className="mt-1 w-full rounded-lg border border-benell-stroke px-3 py-2 text-sm"
/>
</div>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-h2 font-semibold">Requisitos de expediente</h2>
<p className="text-body text-benell-text-soft">Define documentos obligatorios y opcionales.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={addRequirement}
className="rounded-pill border border-benell-stroke px-3 py-2 text-label font-semibold text-benell-text"
>
Agregar requisito
</button>
<button
type="button"
onClick={() => void saveRequirements()}
disabled={saving}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Guardar requisitos
</button>
</div>
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[960px] text-sm">
<thead>
<tr className="border-b border-benell-stroke text-left text-label text-benell-text-soft">
<th className="px-2 py-2">Key</th>
<th className="px-2 py-2">Etiqueta</th>
<th className="px-2 py-2">Descripcion</th>
<th className="px-2 py-2">Tipo</th>
<th className="px-2 py-2">Requerido</th>
<th className="px-2 py-2">Activo</th>
<th className="px-2 py-2">Orden</th>
</tr>
</thead>
<tbody>
{requirements.map((req, index) => (
<tr key={`${req.id || "new"}-${index}`} className="border-b border-benell-stroke/70">
<td className="px-2 py-2">
<input
value={req.key}
onChange={(event) => updateRequirement(index, { key: event.target.value })}
className="w-full rounded border border-benell-stroke px-2 py-1 text-xs"
placeholder="curp"
/>
</td>
<td className="px-2 py-2">
<input
value={req.label}
onChange={(event) => updateRequirement(index, { label: event.target.value })}
className="w-full rounded border border-benell-stroke px-2 py-1 text-xs"
placeholder="CURP"
/>
</td>
<td className="px-2 py-2">
<input
value={req.description ?? ""}
onChange={(event) => updateRequirement(index, { description: event.target.value })}
className="w-full rounded border border-benell-stroke px-2 py-1 text-xs"
placeholder="Detalle opcional"
/>
</td>
<td className="px-2 py-2">
<select
value={req.fieldType}
onChange={(event) => updateRequirement(index, { fieldType: event.target.value })}
className="w-full rounded border border-benell-stroke px-2 py-1 text-xs"
>
{FIELD_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</td>
<td className="px-2 py-2">
<input
type="checkbox"
checked={req.isRequired}
onChange={(event) => updateRequirement(index, { isRequired: event.target.checked })}
/>
</td>
<td className="px-2 py-2">
<input
type="checkbox"
checked={req.isActive}
onChange={(event) => updateRequirement(index, { isActive: event.target.checked })}
/>
</td>
<td className="px-2 py-2">
<input
type="number"
value={req.sortOrder}
onChange={(event) => updateRequirement(index, { sortOrder: Number(event.target.value) })}
className="w-full rounded border border-benell-stroke px-2 py-1 text-xs"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<h2 className="text-h2 font-semibold">Historial</h2>
<p className="text-body text-benell-text-soft">Versiones publicadas y borradores recientes.</p>
<div className="mt-4 grid grid-cols-1 gap-3">
{history.map((version) => (
<div key={version.id} className="rounded-benell border border-benell-stroke bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-benell-text">Version {version.version}</p>
<p className="text-caption text-benell-text-soft">
Estado: {version.status} · Publicado: {version.publishedAt ?? "-"}
</p>
</div>
<button
type="button"
onClick={() => applyHistoryVersion(version)}
className="rounded-pill border border-benell-stroke px-3 py-1 text-xs font-semibold text-benell-text"
>
Cargar en borrador
</button>
</div>
{version.changeSummary ? (
<p className="mt-2 text-xs text-benell-text-soft">{version.changeSummary}</p>
) : null}
</div>
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import HumanCapitalWorkspaceTabs from "@/components/departments/HumanCapitalWorkspaceTabs";
export default function HumanCapitalLayout({ children }: { children: React.ReactNode }) {
return (
<div className="space-y-5">
<HumanCapitalWorkspaceTabs />
{children}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HumanCapitalIndexPage() {
redirect("/departments/human-capital/resumen");
}

View File

@@ -0,0 +1,14 @@
import DepartmentWorkspaceTabs from "@/components/layout/DepartmentWorkspaceTabs";
export default function DepartmentsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<DepartmentWorkspaceTabs />
{children}
</div>
);
}

View File

@@ -0,0 +1 @@
export { default } from "../page";

View File

@@ -0,0 +1 @@
export { default } from "../page";

View File

@@ -0,0 +1,935 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import {
departmentScore,
initiativeScore,
marketingMockData,
type BrandPulse,
type Employee,
type Initiative,
type Meeting,
type MetaSyncStatus,
type MarketingMetaConnection,
type MarketingRole,
type Milestone,
type SocialRange,
type SocialInsight,
type SocialSnapshot,
type Task,
} from "@/lib/marketing";
import { MARKETING_DATE_RANGE_LABELS } from "@/lib/marketing/labels";
import BrandPulseCard from "@/components/marketing/BrandPulseCard";
import EmployeePerformanceTable from "@/components/marketing/EmployeePerformanceTable";
import HealthScoreCard from "@/components/marketing/HealthScoreCard";
import InitiativeDrawer from "@/components/marketing/InitiativeDrawer";
import InitiativeTable from "@/components/marketing/InitiativeTable";
import DepartmentKpiTracker from "@/components/kpis/DepartmentKpiTracker";
import MarketingHeader from "@/components/marketing/MarketingHeader";
import MeetingsWidget from "@/components/marketing/MeetingsWidget";
import SocialPanel from "@/components/marketing/SocialPanel";
import { useMarketingUIStore } from "@/stores/uiStore";
const NOW = new Date("2026-02-17T12:00:00-06:00").getTime();
const DAY_MS = 24 * 60 * 60 * 1000;
function matchesDateRange(initiative: Initiative, range: "7d" | "30d" | "qtd"): boolean {
const referenceDate = new Date(initiative.completedAt ?? initiative.dueDate).getTime();
if (range === "7d") {
return Math.abs(referenceDate - NOW) <= 7 * DAY_MS;
}
if (range === "30d") {
return Math.abs(referenceDate - NOW) <= 30 * DAY_MS;
}
const year = new Date(NOW).getUTCFullYear();
const quarterStart = Date.UTC(year, 0, 1);
const quarterEnd = Date.UTC(year, 2, 31, 23, 59, 59);
return referenceDate >= quarterStart && referenceDate <= quarterEnd;
}
function getSocialRange(range: "7d" | "30d" | "qtd"): SocialRange {
return range === "7d" ? "7d" : "30d";
}
function toMarketingRole(role: string): MarketingRole {
if (role === "lead" || role === "leader") {
return "lead";
}
if (role === "employee") {
return "employee";
}
if (role === "owner") {
return "owner";
}
return "employee";
}
function addDays(base: number, days: number): string {
return new Date(base + days * DAY_MS).toISOString().slice(0, 10);
}
function matchesLocationScope(initiative: Initiative, selectedLocationId: "all" | string): boolean {
if (selectedLocationId === "all") {
return true;
}
return initiative.isGlobal || initiative.locationIds.includes(selectedLocationId);
}
function isLimitedTimeOffer(initiative: Initiative): boolean {
const normalizedName = initiative.name.toLowerCase();
return (
initiative.type === "campania" ||
normalizedName.includes("limited time") ||
normalizedName.includes("lto") ||
normalizedName.includes("oferta limitada") ||
normalizedName.includes("oferta por tiempo")
);
}
export default function MarketingDepartmentPage() {
const { data: session } = useSession();
const pathname = usePathname();
const { locationId, dateRange, setLocationId, setDateRange } = useMarketingUIStore();
const initiativesSectionRef = useRef<HTMLDivElement | null>(null);
const meetingsSectionRef = useRef<HTMLDivElement | null>(null);
const [employees, setEmployees] = useState<Employee[]>(marketingMockData.employees);
const [initiatives, setInitiatives] = useState<Initiative[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [socialSnapshots, setSocialSnapshots] = useState<SocialSnapshot[]>(marketingMockData.socialSnapshots);
const [socialInsights, setSocialInsights] = useState<SocialInsight[]>([]);
const [metaConnection, setMetaConnection] = useState<MarketingMetaConnection | null>(null);
const [metaSyncStatus, setMetaSyncStatus] = useState<MetaSyncStatus>("disconnected");
const [latestSyncMessage, setLatestSyncMessage] = useState<string | null>(null);
const [isSyncingMeta, setIsSyncingMeta] = useState(false);
const [meetings, setMeetings] = useState<Meeting[]>(marketingMockData.meetings);
const [selectedInitiativeId, setSelectedInitiativeId] = useState<string | null>(null);
const [brandPulse, setBrandPulse] = useState<BrandPulse>(marketingMockData.brandPulse);
const [isLoadingMarketing, setIsLoadingMarketing] = useState(true);
const [isCreatingInitiative, setIsCreatingInitiative] = useState(false);
const [deletingInitiativeId, setDeletingInitiativeId] = useState<string | null>(null);
const [isSavingMeeting, setIsSavingMeeting] = useState(false);
const [meetingsError, setMeetingsError] = useState<string | null>(null);
const [marketingError, setMarketingError] = useState<string | null>(null);
const [marketingNotice, setMarketingNotice] = useState<{ kind: "success" | "error"; message: string } | null>(null);
const normalizedRole = toMarketingRole(session?.user?.role ?? "");
const employeesById = useMemo(() => Object.fromEntries(employees.map((employee) => [employee.id, employee])), [employees]);
const locationsById = useMemo(
() => Object.fromEntries(marketingMockData.locations.map((location) => [location.id, location])),
[]
);
const tasksById = useMemo(() => Object.fromEntries(tasks.map((task) => [task.id, task])), [tasks]);
const filteredInitiatives = useMemo(
() =>
initiatives.filter((initiative) => {
const matchesLocation = matchesLocationScope(initiative, locationId);
const matchesRange = matchesDateRange(initiative, dateRange);
return matchesLocation && matchesRange;
}),
[dateRange, initiatives, locationId]
);
const comparativeInitiatives = useMemo(() => {
const comparisonRange = dateRange === "7d" ? "30d" : "7d";
return initiatives.filter((initiative) => {
const matchesLocation = matchesLocationScope(initiative, locationId);
const matchesRange = matchesDateRange(initiative, comparisonRange);
return matchesLocation && matchesRange;
});
}, [dateRange, initiatives, locationId]);
const filteredTaskSet = useMemo(() => new Set(filteredInitiatives.map((initiative) => initiative.id)), [filteredInitiatives]);
const filteredTasks = useMemo(() => tasks.filter((task) => filteredTaskSet.has(task.initiativeId)), [filteredTaskSet, tasks]);
const participantOptions = useMemo(() => Array.from(new Set(employees.map((employee) => employee.name))), [employees]);
const initiativeScoresById = useMemo(
() =>
Object.fromEntries(
filteredInitiatives.map((initiative) => {
return [initiative.id, initiativeScore(initiative)];
})
),
[filteredInitiatives]
);
const limitedTimeOfferRanking = useMemo(
() =>
filteredInitiatives
.filter(isLimitedTimeOffer)
.map((initiative) => ({
initiative,
score: initiativeScoresById[initiative.id] ?? initiativeScore(initiative),
}))
.sort((a, b) => b.score - a.score),
[filteredInitiatives, initiativeScoresById]
);
const currentScore = useMemo(
() =>
departmentScore({
initiatives: filteredInitiatives,
socialSnapshots,
brandPulse,
socialRange: getSocialRange(dateRange),
}),
[brandPulse, dateRange, filteredInitiatives, socialSnapshots]
);
const comparisonScore = useMemo(
() =>
departmentScore({
initiatives: comparativeInitiatives,
socialSnapshots,
brandPulse,
socialRange: dateRange === "7d" ? "30d" : "7d",
}),
[brandPulse, comparativeInitiatives, dateRange, socialSnapshots]
);
const trendDelta = currentScore.total - comparisonScore.total;
const selectedInitiative = useMemo(
() => initiatives.find((initiative) => initiative.id === selectedInitiativeId) ?? null,
[initiatives, selectedInitiativeId]
);
useEffect(() => {
if (!selectedInitiativeId) {
return;
}
const stillExists = initiatives.some((initiative) => initiative.id === selectedInitiativeId);
if (!stillExists) {
setSelectedInitiativeId(null);
}
}, [initiatives, selectedInitiativeId]);
useEffect(() => {
if (pathname.startsWith("/departments/marketing/initiatives")) {
initiativesSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (pathname.startsWith("/departments/marketing/meetings")) {
meetingsSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [pathname]);
const subtitleDateRange = MARKETING_DATE_RANGE_LABELS[dateRange];
const brandPulseEditorName = brandPulse.updatedByName ?? employeesById[brandPulse.updatedById]?.name ?? "Lider de Marketing";
const loadMarketingData = useCallback(async (options?: { showLoading?: boolean }) => {
const shouldShowLoading = options?.showLoading ?? true;
if (shouldShowLoading) {
setIsLoadingMarketing(true);
}
try {
const response = await fetch("/api/marketing/dashboard", {
method: "GET",
cache: "no-store",
});
const payload = (await response.json()) as {
error?: string;
employees?: Employee[];
initiatives?: Initiative[];
tasks?: Task[];
socialSnapshots?: SocialSnapshot[];
socialInsights?: SocialInsight[];
brandPulse?: BrandPulse;
metaConnection?: MarketingMetaConnection | null;
metaSyncStatus?: MetaSyncStatus;
latestSyncMessage?: string | null;
};
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar Marketing.");
}
setEmployees(payload.employees ?? []);
setInitiatives(payload.initiatives ?? []);
setTasks(payload.tasks ?? []);
setSocialSnapshots(payload.socialSnapshots ?? marketingMockData.socialSnapshots);
setSocialInsights(payload.socialInsights ?? []);
setBrandPulse(payload.brandPulse ?? marketingMockData.brandPulse);
setMetaConnection(payload.metaConnection ?? null);
setMetaSyncStatus(payload.metaSyncStatus ?? "disconnected");
setLatestSyncMessage(payload.latestSyncMessage ?? null);
setMarketingError(null);
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo cargar Marketing.");
} finally {
if (shouldShowLoading) {
setIsLoadingMarketing(false);
}
}
}, []);
const loadMeetings = useCallback(async () => {
const response = await fetch("/api/marketing/meetings", {
method: "GET",
cache: "no-store",
});
if (!response.ok) {
const payload = (await response.json()) as { error?: string };
throw new Error(payload.error ?? "No se pudieron cargar reuniones.");
}
const payload = (await response.json()) as { meetings: Meeting[] };
setMeetings(payload.meetings);
setMeetingsError(null);
}, []);
useEffect(() => {
loadMarketingData().catch(() => {
// handled in callback
});
loadMeetings().catch((error) => {
setMeetingsError(error instanceof Error ? error.message : "No se pudieron cargar reuniones.");
});
}, [loadMarketingData, loadMeetings]);
const handleCreateInitiative = () => {
if (isCreatingInitiative) {
return;
}
const isGlobal = locationId === "all";
const locationScope = isGlobal ? [] : [locationId];
const defaultDueInDays = dateRange === "7d" ? 6 : dateRange === "30d" ? 14 : 21;
void (async () => {
setIsCreatingInitiative(true);
setMarketingError(null);
try {
const response = await fetch("/api/marketing/initiatives", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Nueva iniciativa",
type: "otro",
dueDate: addDays(NOW, defaultDueInDays),
isGlobal,
locationIds: locationScope,
}),
});
const payload = (await response.json()) as { error?: string; initiative?: Initiative };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear la iniciativa.");
}
if (payload.initiative?.id) {
setInitiatives((prev) => [payload.initiative!, ...prev.filter((initiative) => initiative.id !== payload.initiative!.id)]);
setSelectedInitiativeId(payload.initiative.id);
}
void loadMarketingData();
setMarketingError(null);
setMarketingNotice({
kind: "success",
message: `Iniciativa creada: ${payload.initiative?.name ?? "Nueva iniciativa"}.`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "No se pudo crear la iniciativa.";
setMarketingError(message);
setMarketingNotice({ kind: "error", message });
} finally {
setIsCreatingInitiative(false);
}
})();
};
const handleSaveInitiative = async (nextInitiative: Initiative): Promise<boolean> => {
try {
const response = await fetch(`/api/marketing/initiatives/${nextInitiative.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ initiative: nextInitiative }),
});
const payload = (await response.json()) as { error?: string; initiative?: Initiative | null };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo guardar la iniciativa.");
}
setInitiatives((prev) =>
prev.map((initiative) => (initiative.id === nextInitiative.id ? payload.initiative ?? nextInitiative : initiative))
);
void loadMarketingData({ showLoading: false });
setMarketingError(null);
setMarketingNotice({
kind: "success",
message: `Iniciativa guardada: ${payload.initiative?.name ?? nextInitiative.name}.`,
});
return true;
} catch (error) {
const message = error instanceof Error ? error.message : "No se pudo guardar la iniciativa.";
setMarketingError(message);
setMarketingNotice({ kind: "error", message });
return false;
}
};
const handleDeleteInitiative = (initiativeId: string) => {
if (deletingInitiativeId === initiativeId) {
return;
}
const confirmed = window.confirm("¿Eliminar esta iniciativa? Esta acción también elimina tareas y milestones ligados.");
if (!confirmed) {
return;
}
void (async () => {
setDeletingInitiativeId(initiativeId);
setMarketingError(null);
try {
const response = await fetch(`/api/marketing/initiatives/${initiativeId}`, {
method: "DELETE",
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo eliminar la iniciativa.");
}
setInitiatives((prev) => prev.filter((initiative) => initiative.id !== initiativeId));
setTasks((prev) => prev.filter((task) => task.initiativeId !== initiativeId));
if (selectedInitiativeId === initiativeId) {
setSelectedInitiativeId(null);
}
void loadMarketingData();
setMarketingError(null);
setMarketingNotice({ kind: "success", message: "Iniciativa eliminada." });
} catch (error) {
const message = error instanceof Error ? error.message : "No se pudo eliminar la iniciativa.";
setMarketingError(message);
setMarketingNotice({ kind: "error", message });
} finally {
setDeletingInitiativeId(null);
}
})();
};
const handleUpdateTask = (taskId: string, patch: Partial<Pick<Task, "status" | "evidenceLinks">>) => {
void (async () => {
try {
const response = await fetch(`/api/marketing/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
const payload = (await response.json()) as { error?: string; task?: Task };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo actualizar la tarea.");
}
if (payload.task) {
setTasks((prev) => prev.map((task) => (task.id === payload.task?.id ? payload.task : task)));
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo actualizar la tarea.");
}
})();
};
const handleCreateTask = (input: { initiativeId: string; title: string; description: string; assigneeId: string; dueDate: string }) => {
void (async () => {
try {
const response = await fetch("/api/marketing/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const payload = (await response.json()) as { error?: string; task?: Task };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear la tarea.");
}
if (payload.task) {
setTasks((prev) => [...prev, payload.task as Task]);
setInitiatives((prev) =>
prev.map((initiative) =>
initiative.id === input.initiativeId
? {
...initiative,
taskIds: [...initiative.taskIds, payload.task!.id],
}
: initiative
)
);
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo crear la tarea.");
}
})();
};
const handleUpdateBrandPulse = (nextPulse: BrandPulse) => {
setBrandPulse(nextPulse);
void (async () => {
try {
const response = await fetch("/api/marketing/brand-pulse", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rating1to5: nextPulse.rating1to5,
notes: nextPulse.notes,
}),
});
const payload = (await response.json()) as { error?: string; brandPulse?: BrandPulse };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo actualizar Brand Pulse.");
}
if (payload.brandPulse) {
setBrandPulse(payload.brandPulse);
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo actualizar Brand Pulse.");
}
})();
};
const handleCreateMilestone = (input: { initiativeId: string; title: string; description: string; dueDate: string }) => {
void (async () => {
try {
const response = await fetch(`/api/marketing/initiatives/${input.initiativeId}/milestones`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const payload = (await response.json()) as { error?: string; milestone?: Milestone };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear el milestone.");
}
if (payload.milestone) {
setInitiatives((prev) =>
prev.map((initiative) => {
if (initiative.id !== input.initiativeId) {
return initiative;
}
const nextMilestones = [...(initiative.milestones ?? []), payload.milestone!].sort(
(a, b) => a.sortOrder - b.sortOrder
);
return {
...initiative,
milestones: nextMilestones,
};
})
);
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo crear el milestone.");
}
})();
};
const handleUpdateMilestone = (milestoneId: string, patch: { status?: "pending" | "in_progress" | "completed" }) => {
void (async () => {
try {
const response = await fetch(`/api/marketing/milestones/${milestoneId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
const payload = (await response.json()) as { error?: string; milestone?: Milestone };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo actualizar el milestone.");
}
if (payload.milestone) {
setInitiatives((prev) =>
prev.map((initiative) => {
const milestones = initiative.milestones ?? [];
if (!milestones.some((milestone) => milestone.id === payload.milestone?.id)) {
return initiative;
}
return {
...initiative,
milestones: milestones
.map((milestone) => (milestone.id === payload.milestone?.id ? payload.milestone! : milestone))
.sort((a, b) => a.sortOrder - b.sortOrder),
};
})
);
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo actualizar el milestone.");
}
})();
};
const handleAddMilestoneCheckpoint = (input: { milestoneId: string; note: string }) => {
void (async () => {
try {
const response = await fetch(`/api/marketing/milestones/${input.milestoneId}/checkpoints`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: input.note }),
});
const payload = (await response.json()) as { error?: string; milestone?: Milestone };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo registrar el checkpoint.");
}
if (payload.milestone) {
setInitiatives((prev) =>
prev.map((initiative) => {
const milestones = initiative.milestones ?? [];
if (!milestones.some((milestone) => milestone.id === payload.milestone?.id)) {
return initiative;
}
return {
...initiative,
milestones: milestones
.map((milestone) => (milestone.id === payload.milestone?.id ? payload.milestone! : milestone))
.sort((a, b) => a.sortOrder - b.sortOrder),
};
})
);
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo registrar el checkpoint.");
}
})();
};
const handleRequestMeeting = async (input: {
title: string;
agenda: string;
suggestedTimes: string[];
participantNames: string[];
}) => {
setIsSavingMeeting(true);
try {
const response = await fetch("/api/marketing/meetings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "request",
...input,
}),
});
if (!response.ok) {
const payload = (await response.json()) as { error?: string };
throw new Error(payload.error ?? "No se pudo solicitar reunión.");
}
await loadMeetings();
setMeetingsError(null);
} catch (error) {
setMeetingsError(error instanceof Error ? error.message : "No se pudo solicitar reunión.");
} finally {
setIsSavingMeeting(false);
}
};
const handleScheduleMeeting = async (input: {
title: string;
agenda: string;
scheduledFor: string;
participantNames: string[];
}) => {
setIsSavingMeeting(true);
try {
const response = await fetch("/api/marketing/meetings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "schedule",
...input,
}),
});
if (!response.ok) {
const payload = (await response.json()) as { error?: string };
throw new Error(payload.error ?? "No se pudo agendar reunión.");
}
await loadMeetings();
setMeetingsError(null);
} catch (error) {
setMeetingsError(error instanceof Error ? error.message : "No se pudo agendar reunión.");
} finally {
setIsSavingMeeting(false);
}
};
const handleCompleteMeeting = async (input: {
meetingId: string;
commitments: Array<{
title: string;
description?: string;
ownerName?: string;
dueDate?: string;
}>;
}) => {
setIsSavingMeeting(true);
try {
const response = await fetch(`/api/marketing/meetings/${input.meetingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "complete",
commitments: input.commitments,
}),
});
if (!response.ok) {
const payload = (await response.json()) as { error?: string };
throw new Error(payload.error ?? "No se pudo completar reunión.");
}
await loadMeetings();
setMeetingsError(null);
} catch (error) {
setMeetingsError(error instanceof Error ? error.message : "No se pudo completar reunión.");
} finally {
setIsSavingMeeting(false);
}
};
const handleConnectMeta = async (input: {
accountId: string;
pageId: string;
pageName: string;
pageAccessToken: string;
tokenExpiresAt?: string | null;
}) => {
setMarketingError(null);
try {
const response = await fetch("/api/marketing/meta/connection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo guardar la conexión de Meta.");
}
await loadMarketingData({ showLoading: false });
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo guardar la conexión de Meta.");
}
};
const handleDisconnectMeta = async () => {
setMarketingError(null);
try {
const response = await fetch("/api/marketing/meta/disconnect", {
method: "POST",
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo desconectar Meta.");
}
await loadMarketingData({ showLoading: false });
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo desconectar Meta.");
}
};
const handleSyncMeta = async () => {
setMarketingError(null);
setIsSyncingMeta(true);
try {
const response = await fetch("/api/marketing/meta/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo sincronizar Meta.");
}
await loadMarketingData({ showLoading: false });
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo sincronizar Meta.");
} finally {
setIsSyncingMeta(false);
}
};
return (
<div className="space-y-5">
<MarketingHeader
role={normalizedRole}
dateRange={dateRange}
locationId={locationId}
locations={marketingMockData.locations}
onDateRangeChange={setDateRange}
onLocationChange={setLocationId}
/>
<p className="text-label px-1 text-benell-text-soft">
Mostrando {filteredInitiatives.length} iniciativas para {locationId === "all" ? "todas las ubicaciones" : locationsById[locationId]?.name} · {subtitleDateRange}
</p>
{marketingError ? <p className="text-label text-benell-red">{marketingError}</p> : null}
{marketingNotice ? (
<p className={marketingNotice.kind === "success" ? "text-label text-benell-green" : "text-label text-benell-red"}>
{marketingNotice.message}
</p>
) : null}
{isLoadingMarketing ? <p className="text-label text-benell-text-soft">Cargando marketing...</p> : null}
<DepartmentKpiTracker
department="marketing"
title="KPIs del área: Marketing"
description="Define medición, evidencia, score y seguimiento semanal para los KPIs de Marketing."
/>
<HealthScoreCard score={currentScore} trendDelta={trendDelta} />
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-h2 font-semibold">Ranking Limited Time Offers</h2>
<p className="text-label text-benell-text-soft">Campañas/LTO visibles en el board</p>
</div>
{limitedTimeOfferRanking.length === 0 ? (
<p className="text-body mt-3 text-benell-text-soft">
No hay iniciativas LTO para los filtros activos. Usa tipo Campaña o incluye &quot;LTO&quot;/&quot;Limited Time&quot; en el nombre.
</p>
) : (
<div className="mt-3 overflow-x-auto">
<table className="min-w-[720px] w-full border-separate border-spacing-y-2">
<thead>
<tr className="text-left text-label text-benell-text-soft">
<th className="px-3 py-1">Rank</th>
<th className="px-3 py-1">Initiative</th>
<th className="px-3 py-1">Score</th>
<th className="px-3 py-1">Status</th>
<th className="px-3 py-1">Due</th>
</tr>
</thead>
<tbody>
{limitedTimeOfferRanking.map(({ initiative, score }, index) => (
<tr key={initiative.id} className="rounded-xl bg-white">
<td className="rounded-l-xl px-3 py-3 text-body font-semibold">#{index + 1}</td>
<td className="px-3 py-3 text-body">{initiative.name}</td>
<td className="px-3 py-3 text-body font-semibold text-tabular">{score.toFixed(1)}</td>
<td className="px-3 py-3 text-body">{initiative.status}</td>
<td className="rounded-r-xl px-3 py-3 text-body text-tabular">{initiative.dueDate}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<div ref={meetingsSectionRef} className="grid gap-5 xl:grid-cols-2">
<SocialPanel
insights={socialInsights}
metaConnection={metaConnection}
metaSyncStatus={metaSyncStatus}
latestSyncMessage={latestSyncMessage}
onConnect={handleConnectMeta}
onDisconnect={handleDisconnectMeta}
onSync={handleSyncMeta}
isSyncing={isSyncingMeta}
/>
<BrandPulseCard
brandPulse={brandPulse}
role={normalizedRole}
updatedByName={brandPulseEditorName}
onUpdate={handleUpdateBrandPulse}
/>
</div>
<section ref={initiativesSectionRef} id="initiatives-section">
<InitiativeTable
initiatives={filteredInitiatives}
scoresById={initiativeScoresById}
employeesById={employeesById}
locationsById={locationsById}
selectedInitiativeId={selectedInitiativeId}
role={normalizedRole}
onSelectInitiative={setSelectedInitiativeId}
isCreatingInitiative={isCreatingInitiative}
onCreateInitiative={normalizedRole === "lead" || normalizedRole === "owner" ? handleCreateInitiative : undefined}
/>
</section>
<div className="grid gap-5 xl:grid-cols-2">
<div className="space-y-2">
{meetingsError ? <p className="text-label text-benell-red">{meetingsError}</p> : null}
<MeetingsWidget
meetings={meetings}
role={normalizedRole}
participantOptions={participantOptions}
isSaving={isSavingMeeting}
onRequestMeeting={handleRequestMeeting}
onScheduleMeeting={handleScheduleMeeting}
onCompleteMeeting={handleCompleteMeeting}
/>
</div>
<EmployeePerformanceTable
employees={employees}
initiatives={filteredInitiatives}
tasks={filteredTasks}
role={normalizedRole}
currentEmployeeId={session?.user?.id}
/>
</div>
<InitiativeDrawer
initiative={selectedInitiative}
tasksById={tasksById}
employeesById={employeesById}
locationsById={locationsById}
role={normalizedRole}
currentEmployeeId={session?.user?.id}
isOpen={Boolean(selectedInitiative)}
onClose={() => setSelectedInitiativeId(null)}
onSaveInitiative={handleSaveInitiative}
onDeleteInitiative={normalizedRole === "lead" || normalizedRole === "owner" ? handleDeleteInitiative : undefined}
isDeletingInitiative={selectedInitiativeId ? deletingInitiativeId === selectedInitiativeId : false}
onUpdateTask={handleUpdateTask}
onCreateTask={handleCreateTask}
onCreateMilestone={handleCreateMilestone}
onUpdateMilestone={handleUpdateMilestone}
onAddMilestoneCheckpoint={handleAddMilestoneCheckpoint}
/>
</div>
);
}

View File

@@ -0,0 +1,521 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { useSession } from "next-auth/react";
import DepartmentKpiTracker from "@/components/kpis/DepartmentKpiTracker";
import type { OperationsDashboardResponse } from "@/lib/operations";
type AssetPayload = {
name: string;
category: string;
criticality: number;
};
type WorkOrderPayload = {
assetId: string;
title: string;
dueBy: string;
estimatedCost: number;
expectedDowntimeHours: number;
};
type ForecastPayload = {
lastWeekSales: number;
multiplier: number;
};
type PlanPayload = {
lineName: string;
plannedUnits: number;
capacityUnits: number;
};
type AssetOption = {
id: string;
name: string;
assetCode: string;
};
export default function OperationsDepartmentPage() {
const { data: session } = useSession();
const [dashboard, setDashboard] = useState<OperationsDashboardResponse | null>(null);
const [assets, setAssets] = useState<AssetOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [assetDraft, setAssetDraft] = useState<AssetPayload>({ name: "", category: "", criticality: 3 });
const [workOrderDraft, setWorkOrderDraft] = useState<WorkOrderPayload>({
assetId: "",
title: "",
dueBy: "",
estimatedCost: 0,
expectedDowntimeHours: 0,
});
const [forecastDraft, setForecastDraft] = useState<ForecastPayload>({ lastWeekSales: 0, multiplier: 1.2 });
const [planDraft, setPlanDraft] = useState<PlanPayload>({ lineName: "Línea principal", plannedUnits: 0, capacityUnits: 0 });
const canManage = session?.user?.role === "owner" || (session?.user?.role === "leader" && session?.user?.department === "operaciones");
const isOwner = session?.user?.role === "owner";
const load = async () => {
setIsLoading(true);
setError(null);
try {
const [dashboardResponse, assetsResponse] = await Promise.all([
fetch("/api/operations/dashboard", { method: "GET", cache: "no-store" }),
fetch("/api/operations/assets", { method: "GET", cache: "no-store" }),
]);
const dashboardPayload = (await dashboardResponse.json()) as { error?: string } & OperationsDashboardResponse;
if (!dashboardResponse.ok) {
throw new Error(dashboardPayload.error ?? "No se pudo cargar Operaciones.");
}
const assetsPayload = (await assetsResponse.json()) as { error?: string; assets?: Array<{ id: string; name: string; assetCode: string }> };
if (!assetsResponse.ok) {
throw new Error(assetsPayload.error ?? "No se pudieron cargar activos.");
}
setDashboard(dashboardPayload);
const nextAssets = (assetsPayload.assets ?? []).map((asset) => ({ id: asset.id, name: asset.name, assetCode: asset.assetCode }));
setAssets(nextAssets);
setWorkOrderDraft((prev) => ({ ...prev, assetId: prev.assetId || nextAssets[0]?.id || "" }));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar Operaciones.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const submitAsset = async () => {
if (!assetDraft.name.trim() || !assetDraft.category.trim()) {
return;
}
setError(null);
try {
const response = await fetch("/api/operations/assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(assetDraft),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear activo.");
}
setAssetDraft({ name: "", category: "", criticality: 3 });
await load();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo crear activo.");
}
};
const submitWorkOrder = async () => {
if (!workOrderDraft.assetId || !workOrderDraft.title.trim()) {
return;
}
setError(null);
try {
const response = await fetch("/api/operations/work-orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(workOrderDraft),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear work order.");
}
setWorkOrderDraft((prev) => ({
...prev,
title: "",
dueBy: "",
estimatedCost: 0,
expectedDowntimeHours: 0,
}));
await load();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo crear work order.");
}
};
const submitForecast = async () => {
setError(null);
try {
const response = await fetch("/api/operations/forecast", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(forecastDraft),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo guardar forecast.");
}
await load();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo guardar forecast.");
}
};
const submitPlan = async () => {
if (!planDraft.lineName.trim()) {
return;
}
setError(null);
try {
const response = await fetch("/api/operations/production-plans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(planDraft),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear plan de producción.");
}
await load();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo crear plan de producción.");
}
};
const publishKpis = async () => {
setError(null);
setIsPublishing(true);
try {
const response = await fetch("/api/operations/kpi/publish", { method: "POST" });
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo publicar KPI de operaciones.");
}
await load();
} catch (publishError) {
setError(publishError instanceof Error ? publishError.message : "No se pudo publicar KPI de operaciones.");
} finally {
setIsPublishing(false);
}
};
const resolveApproval = async (approvalId: string, action: "approve" | "reject") => {
setError(null);
try {
const response = await fetch(`/api/operations/approvals/${approvalId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo procesar la aprobación.");
}
await load();
} catch (approvalError) {
setError(approvalError instanceof Error ? approvalError.message : "No se pudo procesar la aprobación.");
}
};
const workOrderChart = useMemo(() => dashboard?.workOrdersByState ?? [], [dashboard]);
return (
<div className="space-y-5">
<header className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-display">Operaciones · Mantenimiento y Fábrica</h1>
<p className="text-body-lg text-benell-text-soft">Activos, PM, aprobaciones y planeación de producción basada en forecast.</p>
</div>
<button
type="button"
disabled={!canManage || isPublishing}
onClick={() => void publishKpis()}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
{isPublishing ? "Publicando..." : "Publicar KPI owner"}
</button>
</div>
</header>
{error ? <p className="text-label text-benell-red">{error}</p> : null}
{isLoading ? <p className="text-label text-benell-text-soft">Cargando Operaciones...</p> : null}
<DepartmentKpiTracker
department="operaciones"
title="KPIs del área: Operaciones"
description="Centraliza definición, evidencia, score y actualización de KPIs para Compras, Mantenimiento y CEDIS."
/>
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Activos</p>
<p className="text-h2 mt-1 font-semibold text-benell-text">{dashboard?.summary.totalAssets ?? 0}</p>
<p className="text-caption text-benell-text-soft">
Críticos: {dashboard?.summary.criticalAssets ?? 0} · Down: {dashboard?.summary.downAssets ?? 0}
</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Work orders abiertas</p>
<p className="text-h2 mt-1 font-semibold text-benell-text">{dashboard?.summary.openWorkOrders ?? 0}</p>
<p className="text-caption text-benell-text-soft">Overdue: {dashboard?.summary.overdueWorkOrders ?? 0}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Aprobaciones pendientes</p>
<p className="text-h2 mt-1 font-semibold text-benell-text">{dashboard?.summary.pendingApprovals ?? 0}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Forecast (semana)</p>
<p className="text-h2 mt-1 font-semibold text-benell-text">{dashboard?.summary.forecastUnits ?? 0}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Plan vs forecast</p>
<p className="text-h2 mt-1 font-semibold text-benell-text">{dashboard?.summary.actualVarianceUnits ?? 0}</p>
<p className="text-caption text-benell-text-soft">Plan: {dashboard?.summary.plannedUnits ?? 0}</p>
</article>
</section>
<section className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Work orders por estado</h2>
<div className="mt-3 h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={workOrderChart}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="state" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#8a6a3d" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Aprobación owner inbox</h2>
<ul className="mt-3 space-y-2">
{(dashboard?.approvalInbox ?? []).map((approval) => (
<li key={approval.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<p className="text-sm font-semibold text-benell-text">{approval.requestType}</p>
<p className="text-caption text-benell-text-soft">
WO: {approval.workOrderId ?? "-"} · Plan: {approval.productionPlanId ?? "-"}
</p>
<p className="text-caption text-benell-text-soft">{approval.state} · {new Date(approval.createdAt).toLocaleString("es-MX")}</p>
{approval.reason ? <p className="text-caption text-benell-text-soft">{approval.reason}</p> : null}
{isOwner && (approval.state === "pending_owner" || approval.state === "submitted") ? (
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => void resolveApproval(approval.id, "approve")}
className="rounded-pill bg-benell-green px-3 py-1.5 text-label font-semibold text-white"
>
Aprobar
</button>
<button
type="button"
onClick={() => void resolveApproval(approval.id, "reject")}
className="rounded-pill bg-benell-red px-3 py-1.5 text-label font-semibold text-white"
>
Rechazar
</button>
</div>
) : null}
</li>
))}
</ul>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">PM próximo y vencido</h2>
<ul className="mt-3 space-y-2">
{(dashboard?.upcomingMaintenance ?? []).map((item) => (
<li key={item.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<p className="text-sm font-semibold text-benell-text">{item.assetName} · {item.title}</p>
<p className="text-caption text-benell-text-soft">
Due: {new Date(item.dueBy).toLocaleDateString("es-MX")} · Estado: {item.state}
</p>
</li>
))}
</ul>
</article>
</section>
<section className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Nuevo activo</h2>
<div className="mt-3 grid grid-cols-1 gap-2">
<input
value={assetDraft.name}
onChange={(event) => setAssetDraft((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Nombre del activo"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
value={assetDraft.category}
onChange={(event) => setAssetDraft((prev) => ({ ...prev, category: event.target.value }))}
placeholder="Categoría"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
min={1}
max={5}
value={assetDraft.criticality}
onChange={(event) => setAssetDraft((prev) => ({ ...prev, criticality: Number(event.target.value) || 3 }))}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<button
type="button"
onClick={() => void submitAsset()}
disabled={!canManage}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Crear activo
</button>
</div>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Nuevo work order</h2>
<div className="mt-3 grid grid-cols-1 gap-2">
<select
value={workOrderDraft.assetId}
onChange={(event) => setWorkOrderDraft((prev) => ({ ...prev, assetId: event.target.value }))}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
>
<option value="">Selecciona activo</option>
{assets.map((asset) => (
<option key={asset.id} value={asset.id}>
{asset.name} ({asset.assetCode})
</option>
))}
</select>
<input
value={workOrderDraft.title}
onChange={(event) => setWorkOrderDraft((prev) => ({ ...prev, title: event.target.value }))}
placeholder="Título"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="date"
value={workOrderDraft.dueBy}
onChange={(event) => setWorkOrderDraft((prev) => ({ ...prev, dueBy: event.target.value }))}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
value={workOrderDraft.estimatedCost}
onChange={(event) => setWorkOrderDraft((prev) => ({ ...prev, estimatedCost: Number(event.target.value) || 0 }))}
placeholder="Costo estimado"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
value={workOrderDraft.expectedDowntimeHours}
onChange={(event) => setWorkOrderDraft((prev) => ({ ...prev, expectedDowntimeHours: Number(event.target.value) || 0 }))}
placeholder="Downtime esperado (h)"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<button
type="button"
onClick={() => void submitWorkOrder()}
disabled={!canManage}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Crear work order
</button>
</div>
</article>
</section>
<section className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Forecast semanal (manual)</h2>
<div className="mt-3 grid grid-cols-1 gap-2">
<input
type="number"
value={forecastDraft.lastWeekSales}
onChange={(event) => setForecastDraft((prev) => ({ ...prev, lastWeekSales: Number(event.target.value) || 0 }))}
placeholder="Ventas semana pasada"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
step="0.1"
value={forecastDraft.multiplier}
onChange={(event) => setForecastDraft((prev) => ({ ...prev, multiplier: Number(event.target.value) || 1.2 }))}
placeholder="Multiplicador"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<button
type="button"
onClick={() => void submitForecast()}
disabled={!canManage}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Guardar forecast
</button>
</div>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Plan de producción</h2>
<div className="mt-3 grid grid-cols-1 gap-2">
<input
value={planDraft.lineName}
onChange={(event) => setPlanDraft((prev) => ({ ...prev, lineName: event.target.value }))}
placeholder="Línea / célula"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
value={planDraft.plannedUnits}
onChange={(event) => setPlanDraft((prev) => ({ ...prev, plannedUnits: Number(event.target.value) || 0 }))}
placeholder="Unidades planeadas"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
value={planDraft.capacityUnits}
onChange={(event) => setPlanDraft((prev) => ({ ...prev, capacityUnits: Number(event.target.value) || 0 }))}
placeholder="Capacidad"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<button
type="button"
onClick={() => void submitPlan()}
disabled={!canManage}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Crear plan
</button>
</div>
</article>
</section>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getDepartmentHomeRoute } from "@/lib/access-control";
import { DEPARTMENT_OPTIONS } from "@/lib/departments";
import type { DepartmentKey } from "@/lib/types";
export default async function DepartmentsHubPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login");
}
if (session.user.role !== "owner") {
redirect(getDepartmentHomeRoute((session.user.department as DepartmentKey | null | undefined) ?? null));
}
return (
<div className="space-y-5">
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Departamento</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Selecciona un departamento</h1>
<p className="mt-1 text-sm text-benell-text-soft">Elige una vista para revisar indicadores, reuniones y ejecución por área.</p>
</section>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{DEPARTMENT_OPTIONS.map((option) => {
const href = getDepartmentHomeRoute(option.value);
return (
<Link
key={option.value}
href={href}
className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell transition hover:border-benell-brown/50"
>
<p className="text-sm font-semibold text-benell-text">{option.label}</p>
<p className="mt-1 text-xs text-benell-text-soft">Entrar a vista de {option.label.toLowerCase()}.</p>
</Link>
);
})}
</section>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import CaptureWorkspace from "@/components/capture/CaptureWorkspace";
export default function ProjectsCapturePage() {
return <CaptureWorkspace forcedDepartment="proyectos" />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function ProjectsInitiativesAliasRedirect() {
redirect("/departments/projects/projects");
}

View File

@@ -0,0 +1,14 @@
import ProjectsWorkspaceTabs from "@/components/departments/ProjectsWorkspaceTabs";
export default function ProjectsDepartmentLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="space-y-5">
<ProjectsWorkspaceTabs />
{children}
</div>
);
}

View File

@@ -0,0 +1,615 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { useSession } from "next-auth/react";
import { cn } from "@/lib/utils";
import { formatDate, formatDateTime, getStatusPillClass } from "@/lib/marketing/labels";
import type { Employee, Meeting, ProjectCalendarEvent } from "@/lib/projects/types";
type MeetingFilter = "all" | "mine" | "team" | "pending";
type CreateMode = "meeting" | "personal_event";
const SLOT_START_HOUR = 7;
const SLOT_END_HOUR = 21;
function startOfWeekMonday(source: Date): Date {
const date = new Date(source);
const day = date.getDay();
const diff = day === 0 ? -6 : 1 - day;
date.setDate(date.getDate() + diff);
date.setHours(0, 0, 0, 0);
return date;
}
function addDays(base: Date, days: number): Date {
const next = new Date(base);
next.setDate(base.getDate() + days);
return next;
}
function formatDateTimeLocalInput(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
const hours = String(value.getHours()).padStart(2, "0");
const minutes = String(value.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function toIsoOrNull(value: string): string | null {
if (!value) {
return null;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.toISOString();
}
function meetingIsMine(meeting: Meeting, userId: string): boolean {
if (meeting.requestedById === userId) {
return true;
}
return meeting.participants.some((participant) => participant.userId === userId);
}
function meetingHasPendingResponse(meeting: Meeting, userId: string): boolean {
return meeting.participants.some((participant) => participant.userId === userId && participant.responseStatus === "pending");
}
function canManageProjectsWorkspace(role: string | undefined, department: string | null | undefined): boolean {
if (role === "owner") {
return true;
}
return (role === "leader" || role === "lead") && department === "proyectos";
}
export default function ProjectsMeetingsPage() {
const { data: session } = useSession();
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeekMonday(new Date()));
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [events, setEvents] = useState<ProjectCalendarEvent[]>([]);
const [employees, setEmployees] = useState<Employee[]>([]);
const [filter, setFilter] = useState<MeetingFilter>("all");
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createMode, setCreateMode] = useState<CreateMode>("meeting");
const [title, setTitle] = useState("");
const [agenda, setAgenda] = useState("");
const [startAtInput, setStartAtInput] = useState(() => {
const base = addDays(startOfWeekMonday(new Date()), 1);
base.setHours(9, 0, 0, 0);
return formatDateTimeLocalInput(base);
});
const [endAtInput, setEndAtInput] = useState(() => {
const base = addDays(startOfWeekMonday(new Date()), 1);
base.setHours(10, 0, 0, 0);
return formatDateTimeLocalInput(base);
});
const [visibility, setVisibility] = useState<"personal" | "team">("personal");
const [selectedParticipantIds, setSelectedParticipantIds] = useState<string[]>([]);
const weekDays = useMemo(() => Array.from({ length: 7 }, (_, dayOffset) => addDays(weekStart, dayOffset)), [weekStart]);
const weekStartIso = weekDays[0].toISOString();
const weekEnd = addDays(weekStart, 7);
const weekEndIso = weekEnd.toISOString();
const timeSlots = useMemo(() => {
const slots: Array<{ key: string; label: string }> = [];
for (let hour = SLOT_START_HOUR; hour < SLOT_END_HOUR; hour += 1) {
for (const minute of [0, 30]) {
const key = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
const label = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
slots.push({ key, label });
}
}
return slots;
}, []);
const loadMeetingsWorkspace = useCallback(async () => {
setIsLoading(true);
try {
const [meetingsResponse, eventsResponse, dashboardResponse] = await Promise.all([
fetch("/api/projects/meetings", { method: "GET", cache: "no-store" }),
fetch(`/api/projects/calendar/events?start=${encodeURIComponent(weekStartIso)}&end=${encodeURIComponent(weekEndIso)}`, {
method: "GET",
cache: "no-store",
}),
fetch("/api/projects/dashboard", { method: "GET", cache: "no-store" }),
]);
const meetingsPayload = (await meetingsResponse.json()) as { error?: string; meetings?: Meeting[] };
if (!meetingsResponse.ok) {
throw new Error(meetingsPayload.error ?? "No se pudieron cargar reuniones.");
}
const eventsPayload = (await eventsResponse.json()) as { error?: string; events?: ProjectCalendarEvent[] };
if (!eventsResponse.ok) {
throw new Error(eventsPayload.error ?? "No se pudieron cargar eventos de calendario.");
}
const dashboardPayload = (await dashboardResponse.json()) as { error?: string; employees?: Employee[] };
if (!dashboardResponse.ok) {
throw new Error(dashboardPayload.error ?? "No se pudieron cargar participantes.");
}
setMeetings(meetingsPayload.meetings ?? []);
setEvents(eventsPayload.events ?? []);
setEmployees(dashboardPayload.employees ?? []);
setError(null);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar el calendario de reuniones.");
} finally {
setIsLoading(false);
}
}, [weekEndIso, weekStartIso]);
useEffect(() => {
void loadMeetingsWorkspace();
}, [loadMeetingsWorkspace]);
const userId = session?.user?.id ?? "";
const canManage = canManageProjectsWorkspace(session?.user?.role, session?.user?.department);
useEffect(() => {
if (canManage) {
return;
}
if (createMode === "meeting") {
setCreateMode("personal_event");
}
if (visibility === "team") {
setVisibility("personal");
}
}, [canManage, createMode, visibility]);
const filteredMeetings = useMemo(() => {
if (!userId) {
return meetings;
}
if (filter === "mine") {
return meetings.filter((meeting) => meetingIsMine(meeting, userId));
}
if (filter === "pending") {
return meetings.filter((meeting) => meetingHasPendingResponse(meeting, userId));
}
if (filter === "team") {
return meetings.filter((meeting) => meeting.status !== "cancelled");
}
return meetings;
}, [filter, meetings, userId]);
const meetingsById = useMemo(() => Object.fromEntries(filteredMeetings.map((meeting) => [meeting.id, meeting])), [filteredMeetings]);
const calendarItems = useMemo(() => {
const items = events
.map((event) => {
const meeting = event.meetingId ? meetingsById[event.meetingId] : undefined;
if (event.meetingId && !meeting) {
return null;
}
return {
id: event.id,
title: meeting?.title ?? event.title,
startAt: event.startAt,
endAt: event.endAt,
meetingId: event.meetingId,
status: meeting?.status ?? null,
visibility: event.visibility,
mine: event.ownerUserId === userId || Boolean(meeting && meetingIsMine(meeting, userId)),
pending: Boolean(meeting && meetingHasPendingResponse(meeting, userId)),
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
return items.filter((item) => {
if (filter === "mine") {
return item.mine;
}
if (filter === "team") {
return item.visibility === "team" || Boolean(item.meetingId);
}
if (filter === "pending") {
return item.pending;
}
return true;
});
}, [events, filter, meetingsById, userId]);
const slotAssignments = useMemo(() => {
const map = new Map<string, typeof calendarItems>();
for (const item of calendarItems) {
const date = new Date(item.startAt);
const dayKey = date.toISOString().slice(0, 10);
const slotKey = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
const key = `${dayKey}_${slotKey}`;
const current = map.get(key) ?? [];
current.push(item);
map.set(key, current);
}
return map;
}, [calendarItems]);
const weekTitle = `${formatDate(weekDays[0].toISOString())} - ${formatDate(weekDays[6].toISOString())}`;
const handleCreate = async () => {
const startAt = toIsoOrNull(startAtInput);
const endAt = toIsoOrNull(endAtInput);
if (!title.trim() || !startAt || !endAt) {
setError("Completa título, inicio y fin.");
return;
}
if (new Date(endAt).getTime() <= new Date(startAt).getTime()) {
setError("La hora de fin debe ser posterior al inicio.");
return;
}
setIsSaving(true);
try {
if (createMode === "meeting") {
if (!canManage) {
throw new Error("Solo owners o líderes pueden crear reuniones de equipo.");
}
const response = await fetch("/api/projects/meetings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "schedule",
title,
agenda,
startAt,
endAt,
participants: selectedParticipantIds.map((participantId) => ({ userId: participantId })),
}),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear la reunión.");
}
} else {
const response = await fetch("/api/projects/calendar/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
notes: agenda,
startAt,
endAt,
visibility: canManage ? visibility : "personal",
}),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear el evento personal.");
}
}
setTitle("");
setAgenda("");
setSelectedParticipantIds([]);
setError(null);
await loadMeetingsWorkspace();
} catch (createError) {
setError(createError instanceof Error ? createError.message : "No se pudo guardar el evento.");
} finally {
setIsSaving(false);
}
};
const handleRespond = async (meetingId: string, responseStatus: "accepted" | "declined") => {
setIsSaving(true);
try {
const response = await fetch(`/api/projects/meetings/${meetingId}/respond`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ responseStatus }),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo responder la invitación.");
}
await loadMeetingsWorkspace();
} catch (respondError) {
setError(respondError instanceof Error ? respondError.message : "No se pudo responder la invitación.");
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-5">
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Projects Meetings</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Calendario semanal</h1>
<p className="mt-1 text-sm text-benell-text-soft">Planeación de reuniones y eventos personales/equipo.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg border border-benell-stroke px-3 py-1.5 text-sm font-semibold text-benell-text"
onClick={() => setWeekStart((prev) => addDays(prev, -7))}
>
<ChevronLeft className="size-4" aria-hidden /> Semana anterior
</button>
<span className="rounded-lg border border-benell-stroke bg-white px-3 py-1.5 text-sm text-benell-text">{weekTitle}</span>
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg border border-benell-stroke px-3 py-1.5 text-sm font-semibold text-benell-text"
onClick={() => setWeekStart((prev) => addDays(prev, 7))}
>
Semana siguiente <ChevronRight className="size-4" aria-hidden />
</button>
</div>
</div>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-wrap items-center gap-2">
{([
["all", "All"],
["mine", "Mine"],
["team", "Team"],
["pending", "Pending response"],
] as Array<[MeetingFilter, string]>).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setFilter(key)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wide",
filter === key ? "border-benell-brown bg-benell-brown text-white" : "border-benell-stroke bg-white text-benell-text"
)}
>
{label}
</button>
))}
</div>
</section>
{error ? <section className="rounded-benell border border-benell-red/40 bg-benell-red/10 p-4 text-sm text-benell-red">{error}</section> : null}
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<header className="mb-4 flex items-center gap-2">
<CalendarClock className="size-4 text-benell-brown" aria-hidden />
<h2 className="text-base font-semibold text-benell-text">Semana (07:00 - 21:00, intervalos de 30 min)</h2>
</header>
<div className="overflow-x-auto">
<div className="grid min-w-[900px] grid-cols-[84px_repeat(7,minmax(100px,1fr))] border border-benell-stroke">
<div className="border-b border-r border-benell-stroke bg-benell-surface-muted px-2 py-2 text-xs font-semibold uppercase text-benell-text-soft">
Hora
</div>
{weekDays.map((day) => (
<div key={day.toISOString()} className="border-b border-r border-benell-stroke bg-benell-surface-muted px-2 py-2 text-xs font-semibold uppercase text-benell-text-soft">
{new Intl.DateTimeFormat("es-MX", { weekday: "short", day: "2-digit", month: "short" }).format(day)}
</div>
))}
{timeSlots.map((slot) => (
<div key={`row_${slot.key}`} className="contents">
<div className="border-r border-t border-benell-stroke bg-white px-2 py-2 text-xs text-benell-text-soft">
{slot.label}
</div>
{weekDays.map((day) => {
const dayKey = day.toISOString().slice(0, 10);
const key = `${dayKey}_${slot.key}`;
const slotItems = slotAssignments.get(key) ?? [];
return (
<div key={key} className="min-h-[52px] border-r border-t border-benell-stroke bg-white p-1">
{slotItems.map((item) => (
<div
key={item.id}
className={cn(
"mb-1 rounded-md border px-2 py-1 text-[11px]",
item.meetingId ? "border-benell-brown/40 bg-benell-brown/10 text-benell-brown" : "border-benell-stroke bg-benell-surface-muted text-benell-text"
)}
>
<p className="line-clamp-2 font-semibold">{item.title}</p>
<p className="text-[10px]">{new Date(item.startAt).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" })}</p>
</div>
))}
</div>
);
})}
</div>
))}
</div>
</div>
</section>
<section className="grid gap-5 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<header className="mb-3 flex items-center gap-2">
<Plus className="size-4 text-benell-brown" aria-hidden />
<h2 className="text-base font-semibold text-benell-text">Crear evento</h2>
</header>
<div className="space-y-3">
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Tipo
<select className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text" value={createMode} onChange={(event) => setCreateMode(event.target.value as CreateMode)}>
{canManage ? <option value="meeting">meeting</option> : null}
<option value="personal_event">personal_event</option>
</select>
{!canManage ? <span className="text-xs text-benell-text-soft">Como colaborador, solo puedes crear eventos personales.</span> : null}
</label>
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Título
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
placeholder="Planeación semanal"
/>
</label>
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Agenda / notas
<textarea
value={agenda}
onChange={(event) => setAgenda(event.target.value)}
className="min-h-[88px] rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
placeholder="Objetivos y puntos clave"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Inicio
<input
type="datetime-local"
value={startAtInput}
onChange={(event) => setStartAtInput(event.target.value)}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
/>
</label>
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Fin
<input
type="datetime-local"
value={endAtInput}
onChange={(event) => setEndAtInput(event.target.value)}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
/>
</label>
</div>
{createMode === "meeting" ? (
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Participantes
<select
multiple
value={selectedParticipantIds}
onChange={(event) => {
const values = Array.from(event.target.selectedOptions).map((option) => option.value);
setSelectedParticipantIds(values);
}}
className="min-h-[110px] rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
>
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{employee.name}
</option>
))}
</select>
</label>
) : (
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Visibilidad
<select
value={visibility}
onChange={(event) => setVisibility(event.target.value as "personal" | "team")}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
>
<option value="personal">personal</option>
{canManage ? <option value="team">team</option> : null}
</select>
</label>
)}
<button
type="button"
onClick={() => void handleCreate()}
disabled={isSaving}
className="inline-flex items-center justify-center rounded-lg bg-benell-brown px-4 py-2 text-sm font-semibold text-white transition disabled:cursor-not-allowed disabled:opacity-70"
>
{isSaving ? "Guardando..." : "Crear"}
</button>
</div>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<h2 className="text-base font-semibold text-benell-text">Reuniones y confirmaciones</h2>
<p className="mt-1 text-sm text-benell-text-soft">Estado de asistencia y pendientes de confirmación.</p>
<div className="mt-3 space-y-2">
{filteredMeetings.slice(0, 14).map((meeting) => {
const myParticipant = meeting.participants.find((participant) => participant.userId === userId) ?? null;
const pendingCount = meeting.participantCounts.pending;
return (
<article key={meeting.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-benell-text">{meeting.title}</p>
<p className="text-xs text-benell-text-soft">{formatDateTime(meeting.scheduledFor)}</p>
</div>
<span className={cn("rounded-full px-2 py-0.5 text-xs font-semibold", getStatusPillClass(meeting.status))}>{meeting.status}</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-benell-text-soft">
<span>Confirmados: {meeting.participantCounts.accepted}</span>
<span>Pendientes: {pendingCount}</span>
<span>Declinados: {meeting.participantCounts.declined}</span>
</div>
{myParticipant ? (
<div className="mt-2 flex gap-2">
<button
type="button"
disabled={isSaving || myParticipant.responseStatus === "accepted"}
onClick={() => void handleRespond(meeting.id, "accepted")}
className="rounded-md border border-benell-green/40 bg-benell-green/10 px-2 py-1 text-xs font-semibold text-benell-green disabled:opacity-60"
>
Accept
</button>
<button
type="button"
disabled={isSaving || myParticipant.responseStatus === "declined"}
onClick={() => void handleRespond(meeting.id, "declined")}
className="rounded-md border border-benell-red/40 bg-benell-red/10 px-2 py-1 text-xs font-semibold text-benell-red disabled:opacity-60"
>
Decline
</button>
</div>
) : null}
</article>
);
})}
{filteredMeetings.length === 0 ? <p className="text-sm text-benell-text-soft">Sin reuniones para el filtro actual.</p> : null}
</div>
</article>
</section>
{isLoading ? <section className="rounded-benell border border-benell-stroke bg-benell-surface p-4 text-sm text-benell-text-soft">Actualizando calendario...</section> : null}
</div>
);
}

View File

@@ -0,0 +1,434 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { AlertTriangle, CalendarClock, FolderKanban } from "lucide-react";
import { useSession } from "next-auth/react";
import DepartmentKpiTracker from "@/components/kpis/DepartmentKpiTracker";
import { formatCurrency, formatDate, formatDateTime, getStatusPillClass } from "@/lib/marketing/labels";
import { projectLocations } from "@/lib/projects/mock";
import type { Employee, Initiative, Meeting, Task } from "@/lib/projects/types";
import { PROJECT_DATE_RANGE_LABELS } from "@/lib/projects/labels";
import { useProjectsUIStore } from "@/stores/projectsUIStore";
import { cn } from "@/lib/utils";
const NOW = new Date("2026-03-04T12:00:00-06:00").getTime();
const DAY_MS = 24 * 60 * 60 * 1000;
function matchesDateRange(initiative: Initiative, range: "7d" | "30d" | "qtd"): boolean {
const referenceDate = new Date(initiative.completedAt ?? initiative.dueDate).getTime();
if (range === "7d") {
return Math.abs(referenceDate - NOW) <= 7 * DAY_MS;
}
if (range === "30d") {
return Math.abs(referenceDate - NOW) <= 30 * DAY_MS;
}
const year = new Date(NOW).getUTCFullYear();
const quarterStart = Date.UTC(year, 0, 1);
const quarterEnd = Date.UTC(year, 2, 31, 23, 59, 59);
return referenceDate >= quarterStart && referenceDate <= quarterEnd;
}
function matchesLocationScope(initiative: Initiative, selectedLocationId: "all" | string): boolean {
if (selectedLocationId === "all") {
return true;
}
return initiative.isGlobal || initiative.locationIds.includes(selectedLocationId);
}
export default function ProjectsOverviewPage() {
const { data: session } = useSession();
const { locationId, dateRange, setLocationId, setDateRange } = useProjectsUIStore();
const [employees, setEmployees] = useState<Employee[]>([]);
const [initiatives, setInitiatives] = useState<Initiative[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setIsLoading(true);
try {
const [dashboardResponse, meetingsResponse] = await Promise.all([
fetch("/api/projects/dashboard", { method: "GET", cache: "no-store" }),
fetch("/api/projects/meetings", { method: "GET", cache: "no-store" }),
]);
const dashboardPayload = (await dashboardResponse.json()) as {
error?: string;
employees?: Employee[];
initiatives?: Initiative[];
tasks?: Task[];
};
if (!dashboardResponse.ok) {
throw new Error(dashboardPayload.error ?? "No se pudo cargar el dashboard de Projects.");
}
const meetingsPayload = (await meetingsResponse.json()) as {
error?: string;
meetings?: Meeting[];
};
if (!meetingsResponse.ok) {
throw new Error(meetingsPayload.error ?? "No se pudieron cargar las reuniones de Projects.");
}
if (cancelled) {
return;
}
setEmployees(dashboardPayload.employees ?? []);
setInitiatives(dashboardPayload.initiatives ?? []);
setTasks(dashboardPayload.tasks ?? []);
setMeetings(meetingsPayload.meetings ?? []);
setError(null);
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar Projects.");
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const employeesById = useMemo(() => Object.fromEntries(employees.map((employee) => [employee.id, employee])), [employees]);
const filteredInitiatives = useMemo(
() =>
initiatives.filter((initiative) => {
const byDate = matchesDateRange(initiative, dateRange);
const byLocation = matchesLocationScope(initiative, locationId);
return byDate && byLocation;
}),
[dateRange, initiatives, locationId]
);
const filteredInitiativeIds = useMemo(() => new Set(filteredInitiatives.map((initiative) => initiative.id)), [filteredInitiatives]);
const filteredTasks = useMemo(() => tasks.filter((task) => filteredInitiativeIds.has(task.initiativeId)), [filteredInitiativeIds, tasks]);
const activeProjects = useMemo(
() => filteredInitiatives.filter((initiative) => initiative.status !== "results" && initiative.status !== "evaluation").length,
[filteredInitiatives]
);
const allMilestones = useMemo(() => filteredInitiatives.flatMap((initiative) => initiative.milestones ?? []), [filteredInitiatives]);
const onTimeMilestonesPct = useMemo(() => {
if (allMilestones.length === 0) {
return 0;
}
const onTimeCount = allMilestones.filter((milestone) => {
if (milestone.status === "completed") {
return true;
}
return new Date(milestone.dueDate).getTime() >= NOW;
}).length;
return Math.round((onTimeCount / allMilestones.length) * 100);
}, [allMilestones]);
const blockedTasksCount = useMemo(() => filteredTasks.filter((task) => task.status === "blocked").length, [filteredTasks]);
const overdueTasksCount = useMemo(
() => filteredTasks.filter((task) => task.status !== "done" && new Date(task.dueDate).getTime() < NOW).length,
[filteredTasks]
);
const actualRevenueTotal = useMemo(
() => filteredInitiatives.reduce((acc, initiative) => acc + initiative.actualRevenue, 0),
[filteredInitiatives]
);
const actualProfitTotal = useMemo(() => filteredInitiatives.reduce((acc, initiative) => acc + initiative.actualProfit, 0), [filteredInitiatives]);
const plannedCostTotal = useMemo(() => filteredInitiatives.reduce((acc, initiative) => acc + initiative.plannedCost, 0), [filteredInitiatives]);
const actualCostTotal = useMemo(() => filteredInitiatives.reduce((acc, initiative) => acc + initiative.actualCost, 0), [filteredInitiatives]);
const costVariancePctTotal = useMemo(() => {
if (plannedCostTotal <= 0) {
return 0;
}
return ((actualCostTotal - plannedCostTotal) / plannedCostTotal) * 100;
}, [actualCostTotal, plannedCostTotal]);
const marginPctTotal = useMemo(() => {
if (actualRevenueTotal <= 0) {
return 0;
}
return (actualProfitTotal / actualRevenueTotal) * 100;
}, [actualProfitTotal, actualRevenueTotal]);
const alertItems = useMemo(() => {
const blocked = filteredTasks
.filter((task) => task.status === "blocked")
.slice(0, 4)
.map((task) => ({
id: `blocked:${task.id}`,
title: `Bloqueo: ${task.title}`,
detail: `Responsable: ${employeesById[task.assigneeId]?.name ?? "Sin asignar"}`,
}));
const overdueMilestones = allMilestones
.filter((milestone) => milestone.status !== "completed" && new Date(milestone.dueDate).getTime() < NOW)
.slice(0, 4)
.map((milestone) => ({
id: `milestone:${milestone.id}`,
title: `Hito vencido: ${milestone.title}`,
detail: `Fecha objetivo: ${formatDate(milestone.dueDate)}`,
}));
const budgetRisk = filteredInitiatives
.filter((initiative) => initiative.costVariancePct >= 10)
.slice(0, 4)
.map((initiative) => ({
id: `budget:${initiative.id}`,
title: `Riesgo de costo: ${initiative.name}`,
detail: `Variación: ${initiative.costVariancePct.toFixed(1)}%`,
}));
return [...blocked, ...overdueMilestones, ...budgetRisk].slice(0, 8);
}, [allMilestones, employeesById, filteredInitiatives, filteredTasks]);
const myTasks = useMemo(() => {
const userId = session?.user?.id;
if (!userId) {
return [];
}
return filteredTasks
.filter((task) => task.assigneeId === userId)
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())
.slice(0, 8);
}, [filteredTasks, session?.user?.id]);
const upcomingMeetings = useMemo(
() =>
meetings
.filter((meeting) => meeting.status === "scheduled" || meeting.status === "requested")
.sort((a, b) => new Date(a.scheduledFor ?? "2999-12-31T00:00:00Z").getTime() - new Date(b.scheduledFor ?? "2999-12-31T00:00:00Z").getTime())
.slice(0, 6),
[meetings]
);
return (
<div className="space-y-5">
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Projects Overview</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Resumen operativo</h1>
<p className="mt-1 text-sm text-benell-text-soft">Visibilidad de ejecución, alertas y carga operativa.</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label className="flex min-w-[180px] flex-col gap-1 text-sm text-benell-text-soft">
Rango
<select
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
value={dateRange}
onChange={(event) => setDateRange(event.target.value as "7d" | "30d" | "qtd")}
>
<option value="7d">{PROJECT_DATE_RANGE_LABELS["7d"]}</option>
<option value="30d">{PROJECT_DATE_RANGE_LABELS["30d"]}</option>
<option value="qtd">{PROJECT_DATE_RANGE_LABELS.qtd}</option>
</select>
</label>
<label className="flex min-w-[180px] flex-col gap-1 text-sm text-benell-text-soft">
Ubicación
<select
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
value={locationId}
onChange={(event) => setLocationId(event.target.value)}
>
<option value="all">Todas</option>
{projectLocations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
<Link
href="/departments/projects/projects"
className="inline-flex items-center justify-center rounded-lg bg-benell-brown px-4 py-2 text-sm font-semibold text-white transition hover:brightness-95"
>
Nuevo proyecto
</Link>
</div>
</div>
</section>
{error ? (
<section className="rounded-benell border border-benell-red/40 bg-benell-red/10 p-4 text-sm text-benell-red">{error}</section>
) : null}
<DepartmentKpiTracker
department="proyectos"
title="KPIs del área: Projects (OOH / Comunidad)"
description="Gestiona medición, evidencia, score y actualización de KPIs de Uber Eats, Grab & Go, eventos y nuevos proyectos."
/>
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-6">
{[
{ label: "Proyectos activos", value: activeProjects.toString() },
{ label: "Hitos en tiempo", value: `${onTimeMilestonesPct}%` },
{ label: "Tareas bloqueadas", value: blockedTasksCount.toString() },
{ label: "Tareas vencidas", value: overdueTasksCount.toString() },
{ label: "Variación de costo", value: `${costVariancePctTotal.toFixed(1)}%` },
{ label: "Margen", value: `${marginPctTotal.toFixed(1)}%` },
].map((card) => (
<article key={card.label} className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-xs uppercase tracking-wide text-benell-text-soft">{card.label}</p>
<p className="mt-2 text-2xl font-semibold text-benell-text">{card.value}</p>
</article>
))}
</section>
<section className="grid gap-5 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<header className="mb-3 flex items-center gap-2 text-benell-red">
<AlertTriangle className="size-4" aria-hidden />
<h2 className="text-base font-semibold text-benell-text">Alertas prioritarias</h2>
</header>
{alertItems.length === 0 ? (
<p className="text-sm text-benell-text-soft">Sin alertas críticas en el rango actual.</p>
) : (
<ul className="space-y-2">
{alertItems.map((item) => (
<li key={item.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<p className="text-sm font-semibold text-benell-text">{item.title}</p>
<p className="text-xs text-benell-text-soft">{item.detail}</p>
</li>
))}
</ul>
)}
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<header className="mb-3 flex items-center gap-2 text-benell-brown">
<CalendarClock className="size-4" aria-hidden />
<h2 className="text-base font-semibold text-benell-text">Próximas reuniones</h2>
</header>
{upcomingMeetings.length === 0 ? (
<p className="text-sm text-benell-text-soft">Sin reuniones próximas.</p>
) : (
<ul className="space-y-2">
{upcomingMeetings.map((meeting) => (
<li key={meeting.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-benell-text">{meeting.title}</p>
<p className="text-xs text-benell-text-soft">{formatDateTime(meeting.scheduledFor)}</p>
</div>
<span className={cn("rounded-full px-2 py-0.5 text-xs font-semibold", getStatusPillClass(meeting.status))}>
{meeting.status}
</span>
</div>
</li>
))}
</ul>
)}
<div className="mt-4">
<Link href="/departments/projects/meetings" className="text-sm font-semibold text-benell-brown hover:underline">
Ir a calendario semanal
</Link>
</div>
</article>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<header className="mb-3 flex items-center gap-2 text-benell-brown">
<FolderKanban className="size-4" aria-hidden />
<h2 className="text-base font-semibold text-benell-text">Portafolio (vista rápida)</h2>
</header>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-benell-stroke text-sm">
<thead className="bg-benell-surface-muted text-left text-xs uppercase tracking-wide text-benell-text-soft">
<tr>
<th className="px-3 py-2">Proyecto</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Owner</th>
<th className="px-3 py-2">Due date</th>
<th className="px-3 py-2">Variación costo</th>
</tr>
</thead>
<tbody className="divide-y divide-benell-stroke">
{filteredInitiatives.slice(0, 10).map((initiative) => (
<tr key={initiative.id} className="hover:bg-benell-surface-muted/70">
<td className="px-3 py-2">
<Link href="/departments/projects/projects" className="font-semibold text-benell-brown hover:underline">
{initiative.name}
</Link>
</td>
<td className="px-3 py-2">
<span className={cn("rounded-full px-2 py-0.5 text-xs font-semibold", getStatusPillClass(initiative.status))}>
{initiative.status}
</span>
</td>
<td className="px-3 py-2 text-benell-text-soft">{employeesById[initiative.ownerId]?.name ?? "Sin asignar"}</td>
<td className="px-3 py-2 text-benell-text-soft">{formatDate(initiative.dueDate)}</td>
<td className="px-3 py-2 text-benell-text-soft">{initiative.costVariancePct.toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<h2 className="text-base font-semibold text-benell-text">Mi carga de trabajo</h2>
<p className="mt-1 text-sm text-benell-text-soft">Asignaciones y compromisos del usuario actual.</p>
{myTasks.length === 0 ? (
<p className="mt-3 text-sm text-benell-text-soft">Sin tareas asignadas en el rango seleccionado.</p>
) : (
<ul className="mt-3 space-y-2">
{myTasks.map((task) => (
<li key={task.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-benell-text">{task.title}</p>
<p className="text-xs text-benell-text-soft">Vence {formatDate(task.dueDate)}</p>
</div>
<span className={cn("rounded-full px-2 py-0.5 text-xs font-semibold", getStatusPillClass(task.status))}>{task.status}</span>
</div>
</li>
))}
</ul>
)}
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-4 text-sm text-benell-text-soft">
<p>
Totales financieros del rango: costo plan {formatCurrency(plannedCostTotal)}, costo real {formatCurrency(actualCostTotal)}, utilidad real
{" "}
{formatCurrency(actualProfitTotal)}.
</p>
{isLoading ? <p className="mt-2">Actualizando datos...</p> : null}
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Users } from "lucide-react";
import { formatDate } from "@/lib/marketing/labels";
import type { Employee, Initiative, Task } from "@/lib/projects/types";
export default function ProjectsTeamPage() {
const [employees, setEmployees] = useState<Employee[]>([]);
const [initiatives, setInitiatives] = useState<Initiative[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setIsLoading(true);
try {
const response = await fetch("/api/projects/dashboard", {
method: "GET",
cache: "no-store",
});
const payload = (await response.json()) as {
error?: string;
employees?: Employee[];
initiatives?: Initiative[];
tasks?: Task[];
};
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar Team.");
}
if (cancelled) {
return;
}
setEmployees(payload.employees ?? []);
setInitiatives(payload.initiatives ?? []);
setTasks(payload.tasks ?? []);
setError(null);
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar Team.");
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const initiativeById = useMemo(() => Object.fromEntries(initiatives.map((initiative) => [initiative.id, initiative])), [initiatives]);
const rows = useMemo(() => {
return employees.map((employee) => {
const assignedTasks = tasks.filter((task) => task.assigneeId === employee.id);
const blockedItems = assignedTasks.filter((task) => task.status === "blocked");
const assignedProjects = Array.from(new Set(assignedTasks.map((task) => initiativeById[task.initiativeId]?.name).filter(Boolean))).slice(0, 4);
return {
employee,
assignedTasks,
blockedItems,
assignedProjects,
};
});
}, [employees, initiativeById, tasks]);
return (
<div className="space-y-5">
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex items-start gap-3">
<span className="mt-1 inline-flex size-8 items-center justify-center rounded-full bg-benell-brown/10 text-benell-brown">
<Users className="size-4" aria-hidden />
</span>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Projects Team</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Visibilidad de equipo</h1>
<p className="mt-1 text-sm text-benell-text-soft">Vista de sólo lectura de roles, carga activa y bloqueos.</p>
</div>
</div>
</section>
{error ? <section className="rounded-benell border border-benell-red/40 bg-benell-red/10 p-4 text-sm text-benell-red">{error}</section> : null}
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-benell-stroke text-sm">
<thead className="bg-benell-surface-muted text-left text-xs uppercase tracking-wide text-benell-text-soft">
<tr>
<th className="px-3 py-2">Miembro</th>
<th className="px-3 py-2">Rol</th>
<th className="px-3 py-2">Tareas asignadas</th>
<th className="px-3 py-2">Bloqueos</th>
<th className="px-3 py-2">Proyectos</th>
<th className="px-3 py-2">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-benell-stroke">
{rows.map(({ employee, assignedTasks, blockedItems, assignedProjects }) => (
<tr key={employee.id} className="hover:bg-benell-surface-muted/70">
<td className="px-3 py-2 font-semibold text-benell-text">{employee.name}</td>
<td className="px-3 py-2 text-benell-text-soft">{employee.roleTitle}</td>
<td className="px-3 py-2 text-benell-text-soft">{assignedTasks.length}</td>
<td className="px-3 py-2 text-benell-red">{blockedItems.length}</td>
<td className="px-3 py-2 text-benell-text-soft">
{assignedProjects.length > 0 ? assignedProjects.join(", ") : "-"}
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-2">
<Link href={`/people/${employee.id}`} className="text-xs font-semibold text-benell-brown hover:underline">
Ver persona
</Link>
<Link href="/departments/projects/projects" className="text-xs font-semibold text-benell-brown hover:underline">
Ver proyectos
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{rows.slice(0, 4).map(({ employee, blockedItems }) => (
<article key={`blocked_${employee.id}`} className="rounded-lg border border-benell-stroke bg-white p-3">
<p className="text-sm font-semibold text-benell-text">Bloqueos de {employee.name}</p>
{blockedItems.length === 0 ? (
<p className="mt-1 text-xs text-benell-text-soft">Sin bloqueos activos.</p>
) : (
<ul className="mt-2 space-y-1 text-xs text-benell-text-soft">
{blockedItems.slice(0, 3).map((task) => (
<li key={task.id}>
{task.title} · vence {formatDate(task.dueDate)}
</li>
))}
</ul>
)}
</article>
))}
</div>
{isLoading ? <p className="mt-4 text-sm text-benell-text-soft">Actualizando equipo...</p> : null}
</section>
</div>
);
}

View File

@@ -0,0 +1,535 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import ExperienciometroTabs from "@/components/experienciometro/ExperienciometroTabs";
import type { ExperiencePolicyDTO, ExperienceTemplateCategoryDTO, ExperienceTemplateDTO } from "@/lib/experienciometro/types";
function cloneTemplate(template: ExperienceTemplateDTO): ExperienceTemplateDTO {
return {
...template,
categories: template.categories.map((category) => ({
...category,
items: category.items.map((item) => ({ ...item })),
})),
};
}
export default function ExperienciometroConfigPage() {
const [templates, setTemplates] = useState<ExperienceTemplateDTO[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [editingTemplate, setEditingTemplate] = useState<ExperienceTemplateDTO | null>(null);
const [policy, setPolicy] = useState<ExperiencePolicyDTO | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
const [isSavingPolicy, setIsSavingPolicy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const currentTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) ?? templates[0] ?? null,
[selectedTemplateId, templates]
);
useEffect(() => {
const controller = new AbortController();
const load = async () => {
setIsLoading(true);
setError(null);
try {
const [templatesResponse, policyResponse] = await Promise.all([
fetch("/api/experienciometro/templates", {
method: "GET",
cache: "no-store",
signal: controller.signal,
}),
fetch("/api/experienciometro/scoring-policy", {
method: "GET",
cache: "no-store",
signal: controller.signal,
}),
]);
const templatesPayload = (await templatesResponse.json()) as { error?: string; templates?: ExperienceTemplateDTO[] };
const policyPayload = (await policyResponse.json()) as { error?: string; policy?: ExperiencePolicyDTO };
if (!templatesResponse.ok) {
throw new Error(templatesPayload.error ?? "No se pudieron cargar plantillas.");
}
if (!policyResponse.ok) {
throw new Error(policyPayload.error ?? "No se pudo cargar política de score.");
}
const list = templatesPayload.templates ?? [];
setTemplates(list);
setSelectedTemplateId((current) => current || list[0]?.id || "");
setPolicy(policyPayload.policy ?? null);
} catch (loadError) {
if (controller.signal.aborted) {
return;
}
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar configuración.");
} finally {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
};
void load();
return () => controller.abort();
}, []);
useEffect(() => {
if (currentTemplate) {
setEditingTemplate(cloneTemplate(currentTemplate));
}
}, [currentTemplate]);
const upsertCategory = () => {
if (!editingTemplate) {
return;
}
const nextCategory: ExperienceTemplateCategoryDTO = {
id: `tmp-cat-${Date.now()}`,
key: `nueva_categoria_${editingTemplate.categories.length + 1}`,
name: "Nueva categoría",
weight: 1,
sortOrder: editingTemplate.categories.length,
items: [
{
id: `tmp-item-${Date.now()}`,
key: "nuevo_item",
label: "Nuevo reactivo",
weight: 1,
sortOrder: 0,
allowsComment: true,
requiresObservation: false,
allowsEvidence: true,
isActive: true,
},
],
};
setEditingTemplate({
...editingTemplate,
categories: [...editingTemplate.categories, nextCategory],
});
};
const saveTemplate = async (publish: boolean) => {
if (!editingTemplate) {
return;
}
setIsSavingTemplate(true);
setError(null);
setMessage(null);
try {
const response = await fetch(`/api/experienciometro/templates/${editingTemplate.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: editingTemplate.name,
isDefault: editingTemplate.isDefault,
categories: editingTemplate.categories.map((category, categoryIndex) => ({
key: category.key,
name: category.name,
weight: category.weight,
sortOrder: categoryIndex,
items: category.items.map((item, itemIndex) => ({
key: item.key,
label: item.label,
weight: item.weight,
sortOrder: itemIndex,
allowsComment: item.allowsComment,
requiresObservation: item.requiresObservation,
allowsEvidence: item.allowsEvidence,
isActive: item.isActive,
})),
})),
publish,
}),
});
const payload = (await response.json()) as { error?: string; template?: ExperienceTemplateDTO };
if (!response.ok || !payload.template) {
throw new Error(payload.error ?? "No se pudo guardar plantilla.");
}
setTemplates((prev) => {
const updated = prev.map((template) => (template.id === payload.template!.id ? payload.template! : template));
return updated.sort((a, b) => (b.updatedAt > a.updatedAt ? 1 : -1));
});
setEditingTemplate(cloneTemplate(payload.template));
setMessage(publish ? "Plantilla publicada correctamente." : "Plantilla guardada correctamente.");
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "No se pudo guardar plantilla.");
} finally {
setIsSavingTemplate(false);
}
};
const createDraft = async () => {
if (!currentTemplate) {
return;
}
setError(null);
setMessage(null);
try {
const response = await fetch("/api/experienciometro/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: `${currentTemplate.name} (borrador)`,
fromTemplateId: currentTemplate.id,
}),
});
const payload = (await response.json()) as { error?: string; template?: ExperienceTemplateDTO };
if (!response.ok || !payload.template) {
throw new Error(payload.error ?? "No se pudo crear borrador.");
}
setTemplates((prev) => [payload.template!, ...prev]);
setSelectedTemplateId(payload.template.id);
setMessage("Borrador creado.");
} catch (createError) {
setError(createError instanceof Error ? createError.message : "No se pudo crear borrador.");
}
};
const savePolicy = async () => {
if (!policy) {
return;
}
setIsSavingPolicy(true);
setError(null);
setMessage(null);
try {
const response = await fetch("/api/experienciometro/scoring-policy", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: policy.name,
aggregationMode: policy.aggregationMode,
recentWindow: policy.recentWindow,
recentWeights: policy.recentWeights,
greenThreshold: policy.greenThreshold,
yellowThreshold: policy.yellowThreshold,
isActive: true,
}),
});
const payload = (await response.json()) as { error?: string; policy?: ExperiencePolicyDTO };
if (!response.ok || !payload.policy) {
throw new Error(payload.error ?? "No se pudo guardar política.");
}
setPolicy(payload.policy);
setMessage("Política de score actualizada.");
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "No se pudo guardar política.");
} finally {
setIsSavingPolicy(false);
}
};
return (
<div className="space-y-5">
<header className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Configuración</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Constructor de checklist y scoring</h1>
<p className="mt-1 text-sm text-benell-text-soft">Administra versiones de plantilla y parámetros de cálculo del Experienciómetro.</p>
</header>
<ExperienciometroTabs />
{error ? <p className="text-sm font-semibold text-benell-red">{error}</p> : null}
{message ? <p className="text-sm font-semibold text-benell-green">{message}</p> : null}
{isLoading ? <p className="text-sm text-benell-text-soft">Cargando configuración...</p> : null}
{editingTemplate && policy ? (
<>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<div className="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)_auto] md:items-end">
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Plantilla</span>
<select
value={selectedTemplateId}
onChange={(event) => setSelectedTemplateId(event.target.value)}
className="w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm"
>
{templates.map((template) => (
<option key={template.id} value={template.id}>
v{template.version} · {template.name} · {template.state}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Nombre plantilla</span>
<input
value={editingTemplate.name}
onChange={(event) => setEditingTemplate({ ...editingTemplate, name: event.target.value })}
className="w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm"
/>
</label>
<button type="button" onClick={() => void createDraft()} className="rounded-pill border border-benell-stroke px-3 py-2 text-sm font-semibold">
Nuevo borrador
</button>
</div>
<div className="mt-4 space-y-3">
{editingTemplate.categories.map((category, categoryIndex) => (
<article key={category.id} className="rounded-xl border border-benell-stroke bg-white p-3">
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_120px]">
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Categoría</span>
<input
value={category.name}
onChange={(event) =>
setEditingTemplate((prev) => {
if (!prev) {
return prev;
}
const next = cloneTemplate(prev);
next.categories[categoryIndex].name = event.target.value;
next.categories[categoryIndex].key = event.target.value.toLowerCase().replace(/\s+/g, "_");
return next;
})
}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Peso</span>
<input
type="number"
min={0.1}
step={0.1}
value={category.weight}
onChange={(event) =>
setEditingTemplate((prev) => {
if (!prev) {
return prev;
}
const next = cloneTemplate(prev);
next.categories[categoryIndex].weight = Number(event.target.value) || 1;
return next;
})
}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-sm"
/>
</label>
</div>
<div className="mt-2 space-y-2">
{category.items.map((item, itemIndex) => (
<div key={item.id} className="grid gap-2 md:grid-cols-[minmax(0,1fr)_80px_120px]">
<input
value={item.label}
onChange={(event) =>
setEditingTemplate((prev) => {
if (!prev) {
return prev;
}
const next = cloneTemplate(prev);
next.categories[categoryIndex].items[itemIndex].label = event.target.value;
next.categories[categoryIndex].items[itemIndex].key = event.target.value.toLowerCase().replace(/\s+/g, "_");
return next;
})
}
className="rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-sm"
/>
<input
type="number"
min={0.1}
step={0.1}
value={item.weight}
onChange={(event) =>
setEditingTemplate((prev) => {
if (!prev) {
return prev;
}
const next = cloneTemplate(prev);
next.categories[categoryIndex].items[itemIndex].weight = Number(event.target.value) || 1;
return next;
})
}
className="rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-sm"
/>
<label className="inline-flex items-center gap-2 text-xs font-semibold text-benell-text-soft">
<input
type="checkbox"
checked={item.requiresObservation}
onChange={(event) =>
setEditingTemplate((prev) => {
if (!prev) {
return prev;
}
const next = cloneTemplate(prev);
next.categories[categoryIndex].items[itemIndex].requiresObservation = event.target.checked;
return next;
})
}
/>
Requiere obs.
</label>
</div>
))}
</div>
<button
type="button"
onClick={() =>
setEditingTemplate((prev) => {
if (!prev) {
return prev;
}
const next = cloneTemplate(prev);
const bucket = next.categories[categoryIndex].items;
bucket.push({
id: `tmp-item-${Date.now()}`,
key: `nuevo_item_${bucket.length + 1}`,
label: "Nuevo reactivo",
weight: 1,
sortOrder: bucket.length,
allowsComment: true,
requiresObservation: false,
allowsEvidence: true,
isActive: true,
});
return next;
})
}
className="mt-3 rounded-pill border border-benell-stroke px-3 py-1.5 text-xs font-semibold"
>
+ Agregar reactivo
</button>
</article>
))}
</div>
<div className="mt-3 flex flex-wrap justify-between gap-2">
<button type="button" onClick={() => upsertCategory()} className="rounded-pill border border-benell-stroke px-3 py-2 text-sm font-semibold">
+ Agregar categoría
</button>
<div className="flex gap-2">
<button
type="button"
disabled={isSavingTemplate}
onClick={() => void saveTemplate(false)}
className="rounded-pill border border-benell-stroke px-3 py-2 text-sm font-semibold disabled:opacity-60"
>
Guardar borrador
</button>
<button
type="button"
disabled={isSavingTemplate}
onClick={() => void saveTemplate(true)}
className="rounded-pill bg-benell-brown px-3 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
Publicar versión
</button>
</div>
</div>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold text-benell-text">Política de scoring</h2>
<div className="mt-3 grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Modo</span>
<select
value={policy.aggregationMode}
onChange={(event) => setPolicy({ ...policy, aggregationMode: event.target.value as ExperiencePolicyDTO["aggregationMode"] })}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-sm"
>
<option value="weighted_recent">Weighted recent</option>
<option value="moving_average">Moving average</option>
<option value="full_average">Full average</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Ventana reciente</span>
<input
type="number"
min={1}
value={policy.recentWindow}
onChange={(event) => setPolicy({ ...policy, recentWindow: Number(event.target.value) || 1 })}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Umbral verde</span>
<input
type="number"
min={0}
max={100}
value={policy.greenThreshold}
onChange={(event) => setPolicy({ ...policy, greenThreshold: Number(event.target.value) || 85 })}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Umbral amarillo</span>
<input
type="number"
min={0}
max={100}
value={policy.yellowThreshold}
onChange={(event) => setPolicy({ ...policy, yellowThreshold: Number(event.target.value) || 70 })}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-sm"
/>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Pesos recientes (coma separada)</span>
<input
value={policy.recentWeights.join(",")}
onChange={(event) => {
const values = event.target.value
.split(",")
.map((value) => Number(value.trim()))
.filter((value) => Number.isFinite(value) && value > 0);
setPolicy({ ...policy, recentWeights: values.length > 0 ? values : [0.5, 0.3, 0.2] });
}}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-sm"
/>
</label>
<div className="mt-3 flex justify-end">
<button
type="button"
disabled={isSavingPolicy}
onClick={() => void savePolicy()}
className="rounded-pill bg-benell-brown px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isSavingPolicy ? "Guardando..." : "Guardar política"}
</button>
</div>
</section>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,485 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import ExperienciometroTabs from "@/components/experienciometro/ExperienciometroTabs";
type EvaluationBootstrap = {
template: {
id: string;
categories: Array<{
id: string;
key: string;
name: string;
items: Array<{
id: string;
label: string;
requiresObservation: boolean;
}>;
}>;
};
locations: Array<{ id: string; name: string; code: string }>;
users: Array<{ id: string; name: string }>;
};
type ResponseDraft = {
score: number;
comment: string;
observation: string;
createFinding: boolean;
findingPriority: "low" | "medium" | "high" | "critical";
responsibleUserId: string;
};
export default function ExperienciometroEvaluacionesPage() {
const [bootstrap, setBootstrap] = useState<EvaluationBootstrap | null>(null);
const [locationId, setLocationId] = useState("");
const [responses, setResponses] = useState<Record<string, ResponseDraft>>({});
const [filesByItemId, setFilesByItemId] = useState<Record<string, File | null>>({});
const [generalObservations, setGeneralObservations] = useState("");
const [strengths, setStrengths] = useState("");
const [improvementAreas, setImprovementAreas] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [resultMessage, setResultMessage] = useState<string | null>(null);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const fromQuery = new URLSearchParams(window.location.search).get("locationId") ?? "";
if (fromQuery) {
setLocationId(fromQuery);
}
}, []);
useEffect(() => {
const controller = new AbortController();
const load = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/experienciometro/evaluations", {
method: "GET",
cache: "no-store",
signal: controller.signal,
});
const payload = (await response.json()) as { error?: string } & EvaluationBootstrap;
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar la plantilla de evaluación.");
}
setBootstrap(payload);
const initialResponses: Record<string, ResponseDraft> = {};
const initialFiles: Record<string, File | null> = {};
for (const category of payload.template.categories) {
for (const item of category.items) {
initialResponses[item.id] = {
score: 2,
comment: "",
observation: "",
createFinding: false,
findingPriority: "medium",
responsibleUserId: "",
};
initialFiles[item.id] = null;
}
}
setResponses(initialResponses);
setFilesByItemId(initialFiles);
} catch (loadError) {
if (controller.signal.aborted) {
return;
}
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar la plantilla de evaluación.");
} finally {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
};
void load();
return () => controller.abort();
}, []);
const totalScorePreview = useMemo(() => {
const values = Object.values(responses);
if (values.length === 0) {
return 0;
}
const avgRaw = values.reduce((acc, response) => acc + response.score, 0) / values.length;
return Math.round(((avgRaw / 3) * 100) * 10) / 10;
}, [responses]);
const handleSubmit = async () => {
if (!bootstrap) {
return;
}
if (!locationId) {
setError("Selecciona una sucursal.");
return;
}
setIsSaving(true);
setError(null);
setResultMessage(null);
try {
const responsePayload = Object.entries(responses).map(([itemId, draft]) => ({
itemId,
score: draft.score,
comment: draft.comment,
observation: draft.observation,
createFinding: draft.createFinding,
findingPriority: draft.findingPriority,
responsibleUserId: draft.responsibleUserId || undefined,
}));
const response = await fetch("/api/experienciometro/evaluations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
locationId,
templateId: bootstrap.template.id,
generalObservations,
strengths,
improvementAreas,
responses: responsePayload,
}),
});
const payload = (await response.json()) as {
error?: string;
evaluation?: {
id: string;
responses: Array<{
itemId: string;
id: string;
itemLabelSnapshot: string;
}>;
};
};
if (!response.ok || !payload.evaluation) {
throw new Error(payload.error ?? "No se pudo guardar la evaluación.");
}
const pendingFiles = Object.entries(filesByItemId).filter(([, file]) => Boolean(file));
let uploadErrors = 0;
if (pendingFiles.length > 0) {
const responseByItemId = new Map(payload.evaluation.responses.map((entry) => [entry.itemId, entry]));
await Promise.all(
pendingFiles.map(async ([itemId, file]) => {
const related = responseByItemId.get(itemId);
if (!file || !related) {
return;
}
const formData = new FormData();
formData.set("title", `Evidencia ${related.itemLabelSnapshot}`);
formData.set("locationId", locationId);
formData.set("evaluationId", payload.evaluation!.id);
formData.set("responseId", related.id);
formData.set("file", file);
const uploadResponse = await fetch("/api/experienciometro/evidence/upload", {
method: "POST",
body: formData,
});
if (!uploadResponse.ok) {
uploadErrors += 1;
}
})
);
}
setResultMessage(
uploadErrors > 0
? `Evaluación guardada. ${uploadErrors} evidencia(s) no se pudieron subir.`
: "Evaluación guardada y evidencias cargadas correctamente."
);
setGeneralObservations("");
setStrengths("");
setImprovementAreas("");
setResponses((prev) =>
Object.fromEntries(
Object.entries(prev).map(([itemId]) => [
itemId,
{
score: 2,
comment: "",
observation: "",
createFinding: false,
findingPriority: "medium",
responsibleUserId: "",
},
])
)
);
setFilesByItemId((prev) => Object.fromEntries(Object.keys(prev).map((key) => [key, null])));
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo guardar la evaluación.");
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-5">
<header className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Nueva evaluación</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Formulario de experiencia</h1>
<p className="mt-1 text-sm text-benell-text-soft">Completa todos los reactivos en escala 0-3 y adjunta evidencia cuando aplique.</p>
</header>
<ExperienciometroTabs />
{error ? <p className="text-sm font-semibold text-benell-red">{error}</p> : null}
{resultMessage ? <p className="text-sm font-semibold text-benell-green">{resultMessage}</p> : null}
{isLoading ? <p className="text-sm text-benell-text-soft">Cargando formulario...</p> : null}
{bootstrap ? (
<div className="space-y-4">
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<label className="space-y-1">
<span className="text-sm font-semibold text-benell-text">Sucursal</span>
<select
value={locationId}
onChange={(event) => setLocationId(event.target.value)}
className="w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-benell-brown"
>
<option value="">Selecciona una sucursal</option>
{bootstrap.locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
<article className="rounded-xl border border-benell-stroke bg-white px-3 py-2">
<p className="text-xs text-benell-text-soft">Score automático (preview)</p>
<p className="text-h2 font-semibold text-benell-text">{totalScorePreview.toFixed(1)}</p>
</article>
</div>
</section>
{bootstrap.template.categories.map((category) => (
<section key={category.id} className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold text-benell-text">{category.name}</h2>
<div className="mt-3 space-y-3">
{category.items.map((item) => {
const draft = responses[item.id];
if (!draft) {
return null;
}
return (
<article key={item.id} className="rounded-xl border border-benell-stroke bg-white p-3">
<p className="text-sm font-semibold text-benell-text">{item.label}</p>
<div className="mt-2 grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Score</span>
<select
value={draft.score}
onChange={(event) =>
setResponses((prev) => ({
...prev,
[item.id]: {
...prev[item.id],
score: Number(event.target.value),
},
}))
}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-xs"
>
<option value={0}>0 · Mal</option>
<option value={1}>1 · Regular</option>
<option value={2}>2 · Bien</option>
<option value={3}>3 · Excelente</option>
</select>
</label>
<label className="space-y-1 md:col-span-2">
<span className="text-xs font-semibold text-benell-text-soft">Comentario</span>
<input
value={draft.comment}
onChange={(event) =>
setResponses((prev) => ({
...prev,
[item.id]: {
...prev[item.id],
comment: event.target.value,
},
}))
}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-xs"
placeholder="Comentario opcional"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Evidencia</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
onChange={(event) =>
setFilesByItemId((prev) => ({
...prev,
[item.id]: event.target.files?.[0] ?? null,
}))
}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-xs"
/>
</label>
</div>
<div className="mt-2 grid gap-2 md:grid-cols-3">
<label className="space-y-1 md:col-span-2">
<span className="text-xs font-semibold text-benell-text-soft">Observación / hallazgo</span>
<input
value={draft.observation}
onChange={(event) =>
setResponses((prev) => ({
...prev,
[item.id]: {
...prev[item.id],
observation: event.target.value,
},
}))
}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-xs"
placeholder="Describe el hallazgo"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Prioridad</span>
<select
value={draft.findingPriority}
onChange={(event) =>
setResponses((prev) => ({
...prev,
[item.id]: {
...prev[item.id],
findingPriority: event.target.value as ResponseDraft["findingPriority"],
},
}))
}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-xs"
>
<option value="low">Baja</option>
<option value="medium">Media</option>
<option value="high">Alta</option>
<option value="critical">Crítica</option>
</select>
</label>
<label className="inline-flex items-center gap-2 text-xs font-semibold text-benell-text-soft">
<input
type="checkbox"
checked={draft.createFinding}
onChange={(event) =>
setResponses((prev) => ({
...prev,
[item.id]: {
...prev[item.id],
createFinding: event.target.checked,
},
}))
}
/>
Crear hallazgo
</label>
<label className="space-y-1 md:col-span-2">
<span className="text-xs font-semibold text-benell-text-soft">Responsable</span>
<select
value={draft.responsibleUserId}
onChange={(event) =>
setResponses((prev) => ({
...prev,
[item.id]: {
...prev[item.id],
responsibleUserId: event.target.value,
},
}))
}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-xs"
>
<option value="">Sin asignar</option>
{bootstrap.users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</label>
</div>
</article>
);
})}
</div>
</section>
))}
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold text-benell-text">Cierre de evaluación</h2>
<div className="mt-3 grid gap-2">
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Observaciones generales</span>
<textarea
value={generalObservations}
onChange={(event) => setGeneralObservations(event.target.value)}
className="min-h-20 w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Fortalezas detectadas</span>
<textarea
value={strengths}
onChange={(event) => setStrengths(event.target.value)}
className="min-h-20 w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-semibold text-benell-text-soft">Áreas de mejora</span>
<textarea
value={improvementAreas}
onChange={(event) => setImprovementAreas(event.target.value)}
className="min-h-20 w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm"
/>
</label>
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => void handleSubmit()}
disabled={isSaving}
className="rounded-pill bg-benell-brown px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isSaving ? "Guardando..." : "Guardar evaluación"}
</button>
</div>
</section>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,260 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import ExperienciometroTabs from "@/components/experienciometro/ExperienciometroTabs";
import type { ExperienceFindingDTO } from "@/lib/experienciometro/types";
type HallazgosBootstrap = {
locations: Array<{ id: string; name: string; code: string }>;
users: Array<{ id: string; name: string }>;
};
type FindingStatus = "all" | "open" | "in_progress" | "resolved" | "closed";
type FindingPriority = "all" | "low" | "medium" | "high" | "critical";
export default function ExperienciometroHallazgosPage() {
const [bootstrap, setBootstrap] = useState<HallazgosBootstrap>({
locations: [],
users: [],
});
const [findings, setFindings] = useState<ExperienceFindingDTO[]>([]);
const [search, setSearch] = useState("");
const [locationId, setLocationId] = useState("all");
const [status, setStatus] = useState<FindingStatus>("all");
const [priority, setPriority] = useState<FindingPriority>("all");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
const loadBootstrap = async () => {
try {
const [overviewResponse, evaluationsResponse] = await Promise.all([
fetch("/api/experienciometro/overview", { method: "GET", cache: "no-store", signal: controller.signal }),
fetch("/api/experienciometro/evaluations", { method: "GET", cache: "no-store", signal: controller.signal }),
]);
const overview = (await overviewResponse.json()) as {
error?: string;
locations?: Array<{ id: string; name: string; code: string }>;
};
const evaluations = (await evaluationsResponse.json()) as {
error?: string;
users?: Array<{ id: string; name: string }>;
};
if (!overviewResponse.ok) {
throw new Error(overview.error ?? "No se pudo cargar sucursales.");
}
if (!evaluationsResponse.ok) {
throw new Error(evaluations.error ?? "No se pudo cargar usuarios.");
}
setBootstrap({
locations: overview.locations ?? [],
users: evaluations.users ?? [],
});
} catch (loadError) {
if (controller.signal.aborted) {
return;
}
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar filtros de hallazgos.");
}
};
void loadBootstrap();
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
const loadFindings = async () => {
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (search.trim()) {
params.set("q", search.trim());
}
if (locationId !== "all") {
params.set("locationId", locationId);
}
if (status !== "all") {
params.set("status", status);
}
if (priority !== "all") {
params.set("priority", priority);
}
const response = await fetch(`/api/experienciometro/findings?${params.toString()}`, {
method: "GET",
cache: "no-store",
signal: controller.signal,
});
const payload = (await response.json()) as { error?: string; findings?: ExperienceFindingDTO[] };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudieron cargar hallazgos.");
}
setFindings(payload.findings ?? []);
} catch (loadError) {
if (controller.signal.aborted) {
return;
}
setError(loadError instanceof Error ? loadError.message : "No se pudieron cargar hallazgos.");
} finally {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
};
void loadFindings();
return () => controller.abort();
}, [locationId, priority, search, status]);
const usersById = useMemo(() => Object.fromEntries(bootstrap.users.map((user) => [user.id, user.name])), [bootstrap.users]);
const patchFinding = async (findingId: string, patch: Record<string, unknown>) => {
const response = await fetch(`/api/experienciometro/findings/${findingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
const payload = (await response.json()) as { error?: string; finding?: ExperienceFindingDTO };
if (!response.ok || !payload.finding) {
throw new Error(payload.error ?? "No se pudo actualizar hallazgo.");
}
setFindings((prev) => prev.map((finding) => (finding.id === findingId ? payload.finding! : finding)));
};
return (
<div className="space-y-5">
<header className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Hallazgos</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Seguimiento de observaciones</h1>
<p className="mt-1 text-sm text-benell-text-soft">Gestiona prioridad, estatus y responsables vinculados a evaluaciones de sucursal.</p>
</header>
<ExperienciometroTabs />
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<div className="grid gap-2 md:grid-cols-4">
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
className="rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm"
placeholder="Buscar hallazgo"
/>
<select value={locationId} onChange={(event) => setLocationId(event.target.value)} className="rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm">
<option value="all">Todas las sucursales</option>
{bootstrap.locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
<select value={status} onChange={(event) => setStatus(event.target.value as FindingStatus)} className="rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm">
<option value="all">Todos los estatus</option>
<option value="open">Open</option>
<option value="in_progress">In progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<select value={priority} onChange={(event) => setPriority(event.target.value as FindingPriority)} className="rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm">
<option value="all">Todas las prioridades</option>
<option value="low">Baja</option>
<option value="medium">Media</option>
<option value="high">Alta</option>
<option value="critical">Crítica</option>
</select>
</div>
</section>
{error ? <p className="text-sm font-semibold text-benell-red">{error}</p> : null}
{isLoading ? <p className="text-sm text-benell-text-soft">Cargando hallazgos...</p> : null}
<section className="space-y-2">
{findings.map((finding) => (
<article key={finding.id} className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<div className="grid gap-2 xl:grid-cols-[minmax(0,2fr)_150px_150px_220px]">
<div>
<p className="text-sm font-semibold text-benell-text">{finding.title}</p>
<p className="text-xs text-benell-text-soft">{finding.categoryLabel ?? "General"} · {finding.description || "Sin descripción"}</p>
<p className="text-xs text-benell-text-soft">Creado: {new Date(finding.createdAt).toLocaleString("es-MX")}</p>
</div>
<label className="space-y-1 text-xs font-semibold text-benell-text-soft">
Prioridad
<select
value={finding.priority}
onChange={(event) => {
void patchFinding(finding.id, { priority: event.target.value }).catch((patchError) => {
setError(patchError instanceof Error ? patchError.message : "No se pudo actualizar.");
});
}}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-xs"
>
<option value="low">Baja</option>
<option value="medium">Media</option>
<option value="high">Alta</option>
<option value="critical">Crítica</option>
</select>
</label>
<label className="space-y-1 text-xs font-semibold text-benell-text-soft">
Estatus
<select
value={finding.status}
onChange={(event) => {
void patchFinding(finding.id, { status: event.target.value }).catch((patchError) => {
setError(patchError instanceof Error ? patchError.message : "No se pudo actualizar.");
});
}}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-xs"
>
<option value="open">Open</option>
<option value="in_progress">In progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
</label>
<label className="space-y-1 text-xs font-semibold text-benell-text-soft">
Responsable
<select
value={finding.responsibleUserId ?? ""}
onChange={(event) => {
void patchFinding(finding.id, { responsibleUserId: event.target.value || null }).catch((patchError) => {
setError(patchError instanceof Error ? patchError.message : "No se pudo actualizar.");
});
}}
className="w-full rounded-lg border border-benell-stroke bg-white px-2 py-1.5 text-xs"
>
<option value="">Sin asignar</option>
{bootstrap.users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
<span className="block text-[11px] text-benell-text-soft">Actual: {finding.responsibleName ?? usersById[finding.responsibleUserId ?? ""] ?? "Sin asignar"}</span>
</label>
</div>
</article>
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import ExperienciometroTabs from "@/components/experienciometro/ExperienciometroTabs";
import { SignalPill, TrendPill } from "@/components/experienciometro/ScorePills";
import type { ExperienceOverviewResponse } from "@/lib/experienciometro/types";
export default function ExperienciometroOverviewPage() {
const [data, setData] = useState<ExperienceOverviewResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [signal, setSignal] = useState<"all" | "green" | "yellow" | "red">("all");
useEffect(() => {
const controller = new AbortController();
const load = async () => {
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (search.trim()) {
params.set("q", search.trim());
}
if (signal !== "all") {
params.set("signal", signal);
}
const response = await fetch(`/api/experienciometro/overview?${params.toString()}`, {
method: "GET",
cache: "no-store",
signal: controller.signal,
});
const payload = (await response.json()) as { error?: string } & ExperienceOverviewResponse;
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar Experienciómetro.");
}
setData(payload);
} catch (loadError) {
if (controller.signal.aborted) {
return;
}
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar Experienciómetro.");
} finally {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
};
void load();
return () => controller.abort();
}, [search, signal]);
const summary = useMemo(() => {
const locations = data?.locations ?? [];
const avgScore =
locations.length > 0 ? Math.round((locations.reduce((acc, location) => acc + location.score, 0) / locations.length) * 10) / 10 : 0;
const openFindings = locations.reduce((acc, location) => acc + location.openFindings, 0);
return {
locations: locations.length,
avgScore,
openFindings,
};
}, [data]);
return (
<div className="space-y-5">
<header className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Experienciómetro</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Dashboard de experiencia por sucursal</h1>
<p className="mt-1 text-sm text-benell-text-soft">Monitorea score, tendencia, evaluaciones y hallazgos abiertos en tiempo real.</p>
</header>
<ExperienciometroTabs />
<section className="grid gap-3 md:grid-cols-3">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Sucursales activas</p>
<p className="mt-1 text-h2 font-semibold text-benell-text">{summary.locations}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Score promedio</p>
<p className="mt-1 text-h2 font-semibold text-benell-text">{summary.avgScore}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Hallazgos abiertos</p>
<p className="mt-1 text-h2 font-semibold text-benell-text">{summary.openFindings}</p>
</article>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<label className="space-y-1">
<span className="text-sm font-semibold text-benell-text">Buscar sucursal</span>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-benell-brown"
placeholder="Nombre o código"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-semibold text-benell-text">Semáforo</span>
<select
value={signal}
onChange={(event) => setSignal(event.target.value as typeof signal)}
className="w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-benell-brown"
>
<option value="all">Todos</option>
<option value="green">Excelente</option>
<option value="yellow">Atención</option>
<option value="red">Crítico</option>
</select>
</label>
</div>
</section>
{error ? <p className="text-sm font-semibold text-benell-red">{error}</p> : null}
{isLoading ? <p className="text-sm text-benell-text-soft">Cargando Experienciómetro...</p> : null}
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{(data?.locations ?? []).map((location) => (
<article key={location.id} className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="text-h3 font-semibold text-benell-text">{location.name}</h2>
<p className="text-xs text-benell-text-soft">{location.city ?? "Sin ciudad"}</p>
</div>
<SignalPill signal={location.signal} />
</div>
<p className="mt-3 text-4xl font-semibold leading-none text-benell-text">{location.score.toFixed(1)}</p>
<div className="mt-2">
<TrendPill trendDirection={location.trendDirection} delta={location.trendDelta} />
</div>
<div className="mt-3 space-y-1 text-xs text-benell-text-soft">
<p>Evaluaciones: {location.totalEvaluations}</p>
<p>Hallazgos abiertos: {location.openFindings}</p>
<p>Última evaluación: {location.lastEvaluationAt ? new Date(location.lastEvaluationAt).toLocaleString("es-MX") : "Sin registros"}</p>
<p>Responsable: {location.managerName ?? "Sin asignar"}</p>
</div>
<div className="mt-4 flex gap-2">
<Link
href={`/experienciometro/sucursales/${location.id}`}
className="rounded-pill border border-benell-stroke px-3 py-1.5 text-xs font-semibold text-benell-text hover:bg-benell-surface-muted"
>
Ver detalle
</Link>
<Link
href={`/experienciometro/evaluaciones?locationId=${encodeURIComponent(location.id)}`}
className="rounded-pill bg-benell-brown px-3 py-1.5 text-xs font-semibold text-white"
>
Nueva evaluación
</Link>
</div>
</article>
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import ExperienciometroTabs from "@/components/experienciometro/ExperienciometroTabs";
import { SignalPill, TrendPill } from "@/components/experienciometro/ScorePills";
import type { ExperienceLocationDetailResponse } from "@/lib/experienciometro/types";
export default function ExperienciometroBranchDetailPage() {
const params = useParams<{ id: string }>();
const locationId = String(params?.id ?? "");
const [data, setData] = useState<ExperienceLocationDetailResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!locationId) {
return;
}
const controller = new AbortController();
const load = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/experienciometro/locations/${locationId}`, {
method: "GET",
cache: "no-store",
signal: controller.signal,
});
const payload = (await response.json()) as { error?: string } & ExperienceLocationDetailResponse;
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar la sucursal.");
}
setData(payload);
} catch (loadError) {
if (controller.signal.aborted) {
return;
}
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar la sucursal.");
} finally {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
};
void load();
return () => controller.abort();
}, [locationId]);
const trendData = (data?.trend ?? []).map((point) => ({
...point,
date: new Date(point.evaluatedAt).toLocaleDateString("es-MX", { month: "short", day: "numeric" }),
}));
return (
<div className="space-y-5">
<header className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Detalle de sucursal</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">{data?.location.name ?? "Sucural"}</h1>
<p className="mt-1 text-sm text-benell-text-soft">
{data?.location.city ?? "Sin ciudad"} · Responsable: {data?.location.managerName ?? "Sin asignar"}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{data?.location ? <SignalPill signal={data.location.signal} /> : null}
{data?.location ? <TrendPill trendDirection={data.location.trendDirection} delta={data.location.trendDelta} /> : null}
<Link
href={`/experienciometro/evaluaciones?locationId=${encodeURIComponent(locationId)}`}
className="rounded-pill bg-benell-brown px-3 py-2 text-sm font-semibold text-white"
>
Nueva evaluación
</Link>
</div>
</div>
</header>
<ExperienciometroTabs />
{error ? <p className="text-sm font-semibold text-benell-red">{error}</p> : null}
{isLoading ? <p className="text-sm text-benell-text-soft">Cargando detalle...</p> : null}
{data ? (
<>
<section className="grid gap-3 md:grid-cols-4">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Score actual</p>
<p className="mt-1 text-h2 font-semibold text-benell-text">{data.location.score.toFixed(1)}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Evaluaciones</p>
<p className="mt-1 text-h2 font-semibold text-benell-text">{data.location.totalEvaluations}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Hallazgos abiertos</p>
<p className="mt-1 text-h2 font-semibold text-benell-text">{data.location.openFindings}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Última evaluación</p>
<p className="mt-1 text-sm font-semibold text-benell-text">
{data.location.lastEvaluationAt ? new Date(data.location.lastEvaluationAt).toLocaleString("es-MX") : "Sin registros"}
</p>
</article>
</section>
<section className="grid gap-4 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Breakdown por categoría</h2>
<div className="mt-3 h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.categoryBreakdown} layout="vertical" margin={{ left: 24 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" domain={[0, 100]} />
<YAxis dataKey="categoryName" type="category" width={150} />
<Tooltip formatter={(value) => `${value}%`} />
<Bar dataKey="avgScorePct" fill="#8a6a3d" radius={[4, 4, 4, 4]} />
</BarChart>
</ResponsiveContainer>
</div>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Tendencia histórica</h2>
<div className="mt-3 h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 100]} />
<Tooltip formatter={(value) => `${value}`} />
<Line dataKey="score" stroke="#3A2A1E" strokeWidth={2} dot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
</div>
</article>
</section>
<section className="grid gap-4 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Últimas evaluaciones</h2>
<ul className="mt-3 space-y-2">
{data.evaluations.slice(0, 10).map((evaluation) => (
<li key={evaluation.id} className="rounded-xl border border-benell-stroke bg-white p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-benell-text">{new Date(evaluation.evaluatedAt).toLocaleString("es-MX")}</p>
<SignalPill signal={evaluation.signal} />
</div>
<p className="text-xs text-benell-text-soft">Score: {evaluation.totalScore.toFixed(1)} · Evaluó: {evaluation.createdByName ?? "N/A"}</p>
</li>
))}
</ul>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="text-h2 font-semibold">Hallazgos recientes</h2>
<Link href="/experienciometro/hallazgos" className="text-xs font-semibold text-benell-brown">
Ver todos
</Link>
</div>
<ul className="space-y-2">
{data.findings.slice(0, 10).map((finding) => (
<li key={finding.id} className="rounded-xl border border-benell-stroke bg-white p-3">
<p className="text-sm font-semibold text-benell-text">{finding.title}</p>
<p className="text-xs text-benell-text-soft">{finding.categoryLabel ?? "General"} · {finding.priority} · {finding.status}</p>
<p className="text-xs text-benell-text-soft">Responsable: {finding.responsibleName ?? "Sin asignar"}</p>
</li>
))}
</ul>
</article>
</section>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function ExperienciometroBranchesPage() {
redirect("/experienciometro");
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import SankeyChart from "@/components/SankeyChart";
import RightDetailPanel from "@/components/RightDetailPanel";
import { financialFlowMain, financialNodeDetails } from "@/lib/mock";
import { useUIStore } from "@/lib/store/ui-store";
import { applyRangeMultiplier } from "@/lib/utils";
const slugMap: Record<string, string> = {
ingreso: "Ingreso Wansoft",
costos: "Costos",
nomina: "Nómina",
insumos: "Insumos",
impuestos: "Impuestos",
renta: "Renta",
servicios: "Servicios",
logistica: "Logística",
utilidad: "Utilidad",
};
function slugifyName(value: string): string {
return value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export default function FinancialFlowPage() {
const dateRange = useUIStore((state) => state.dateRange);
const [selectedNode, setSelectedNode] = useState<string>("Nómina");
useEffect(() => {
const incomingNode = new URLSearchParams(window.location.search).get("node");
if (!incomingNode) {
return;
}
const mapped = slugMap[incomingNode] ?? Object.values(slugMap).find((item) => slugifyName(item) === incomingNode);
if (mapped) {
setSelectedNode(mapped);
}
}, []);
const adjustedData = useMemo(
() => ({
nodes: financialFlowMain.nodes.map((node) => ({
...node,
value: applyRangeMultiplier(node.value, dateRange),
})),
links: financialFlowMain.links.map((link) => ({
...link,
value: applyRangeMultiplier(link.value, dateRange),
})),
}),
[dateRange]
);
const detail = financialNodeDetails[selectedNode] ?? financialNodeDetails["Nómina"];
return (
<div className="space-y-5">
<header className="space-y-1">
<Link
href="/dashboard"
className="text-body inline-flex items-center gap-2 font-semibold text-benell-brown outline-none transition hover:text-black focus-visible:ring-2 focus-visible:ring-benell-brown"
>
Volver a Dashboard
</Link>
<h1 className="text-display">Flujo financiero</h1>
<p className="text-body-lg text-benell-text-soft">Desglose detallado · Últimas 4 semanas</p>
</header>
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(0,390px)]">
<section className="min-w-0 rounded-benell border border-benell-stroke bg-benell-surface p-4 md:p-6">
<h2 className="text-h2 mb-4 font-semibold">Diagrama de Sankey</h2>
<SankeyChart
data={adjustedData}
selectedNode={selectedNode}
onNodeSelect={(nodeName) => setSelectedNode(nodeName)}
/>
</section>
<RightDetailPanel detail={detail} />
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import EmptyState from "@/components/EmptyState";
import { initiatives } from "@/lib/mock";
import StatusPill from "@/components/StatusPill";
const statusOrder = ["en_ruta", "riesgo", "vencido", "completado"] as const;
export default function InitiativesPage() {
return (
<div className="space-y-7">
<header>
<h1 className="text-display">Iniciativas</h1>
<p className="text-body-lg text-benell-text-soft">Seguimiento semanal por estatus y fecha compromiso</p>
</header>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{statusOrder.map((status) => {
const list = initiatives.filter((initiative) => initiative.status === status);
return (
<article key={status} className="rounded-benell border border-benell-stroke bg-benell-surface p-4">
<div className="mb-3 flex items-center justify-between">
<StatusPill status={status} />
<span className="text-label rounded-pill bg-benell-surface-muted px-2 py-1 font-semibold">{list.length}</span>
</div>
<ul className="space-y-3">
{list.map((initiative) => (
<li key={initiative.id} className="rounded-xl border border-benell-stroke bg-white p-3">
<p className="text-h3 break-words font-semibold">{initiative.title}</p>
<p className="text-body text-benell-text-soft">
{initiative.owner} · {initiative.dueDate}
</p>
<p className="text-label mt-2 text-benell-text-soft text-tabular">Avance: {initiative.progress}%</p>
</li>
))}
</ul>
</article>
);
})}
</section>
<EmptyState
title="Módulo de riesgos cruzados"
description="Próximamente se habilitará la vista de dependencias e impactos entre iniciativas."
/>
</div>
);
}

9
src/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,9 @@
import AppShell from "@/components/layout/AppShell";
export default function AuthenticatedLayout({
children,
}: {
children: React.ReactNode;
}) {
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,48 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { meetings } from "@/lib/mock";
import StatusPill from "@/components/StatusPill";
export default async function MeetingDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const meeting = meetings.find((item) => item.id === id);
if (!meeting) {
notFound();
}
return (
<div className="space-y-6">
<Link href="/meetings" className="text-body font-semibold text-benell-brown hover:underline">
Volver a Reuniones
</Link>
<header>
<h1 className="text-display">{meeting.department}</h1>
<p className="text-body-lg text-benell-text-soft">{meeting.title}</p>
</header>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-6">
<h2 className="text-h2 font-semibold">Última reunión</h2>
<p className="text-body mt-4 text-benell-text-soft">
Campaña San Valentín va muy bien, con 95% de avance. Se acordó reforzar delivery apps esta semana.
</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-6">
<h2 className="text-h2 font-semibold">Próxima reunión</h2>
<p className="text-h2 mt-4 font-semibold">{meeting.schedule}</p>
<p className="text-body mt-2 text-benell-text-soft text-tabular">{meeting.commitments} compromisos abiertos</p>
<div className="mt-4">
<StatusPill status={meeting.status} />
</div>
</article>
</section>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { Calendar, Clock3, Users } from "lucide-react";
import { meetings } from "@/lib/mock";
import StatusPill from "@/components/StatusPill";
import Link from "next/link";
const departments = ["Mercadotecnia", "Finanzas", "Capital Humano", "Operaciones", "Projects"];
export default function MeetingsPage() {
return (
<div className="space-y-6">
<header>
<h1 className="text-display">Reuniones semanales</h1>
<p className="text-body-lg text-benell-text-soft">Control de juntas departamentales y compromisos</p>
</header>
<div className="inline-flex flex-wrap gap-2 rounded-pill bg-benell-tan/70 p-1">
{departments.map((department, index) => (
<button
key={department}
type="button"
className={`text-label rounded-pill px-4 py-1.5 font-semibold ${
index === 0 ? "bg-benell-surface" : "text-benell-brown"
}`}
>
{department}
</button>
))}
</div>
<section className="space-y-4">
{meetings.map((meeting) => (
<article key={meeting.id} className="rounded-benell border border-benell-stroke bg-benell-surface p-5">
<div className="flex flex-wrap items-center gap-4">
<div className="text-h2 min-w-0 flex items-center gap-2 font-semibold">
<Calendar className="size-6 text-benell-text-soft" aria-hidden />
<span className="truncate">{meeting.title}</span>
</div>
<StatusPill status={meeting.status} />
</div>
<div className="text-body mt-3 flex flex-wrap gap-5 text-benell-text-soft">
<p className="inline-flex items-center gap-2 text-tabular">
<Users className="size-5" aria-hidden />
{meeting.attendees} asistentes
</p>
<p className="inline-flex items-center gap-2 text-tabular">
<Clock3 className="size-5" aria-hidden />
{meeting.commitments} compromisos
</p>
</div>
<Link
href={`/meetings/${meeting.id}`}
className="text-label mt-4 inline-flex font-semibold text-benell-caramel outline-none transition hover:text-benell-brown focus-visible:ring-2 focus-visible:ring-benell-brown"
>
Ver detalle de reunión
</Link>
</article>
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import EmptyState from "@/components/EmptyState";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getDepartmentLabel } from "@/lib/departments";
import { canManageHumanCapital } from "@/lib/human-capital";
import { locationOptions } from "@/lib/mock";
import { ROLE_LABELS } from "@/lib/utils";
import type { UserRole } from "@/lib/types";
function toUserRole(value: string | null | undefined): UserRole {
if (value === "owner" || value === "leader" || value === "employee") {
return value;
}
return "employee";
}
export default async function PersonDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await getServerSession(authOptions);
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
department: true,
departmentRole: true,
userRoles: {
include: { role: true },
orderBy: { assignedAt: "asc" },
take: 1,
},
},
});
if (!user) {
notFound();
}
const resolvedRole = toUserRole(user.userRoles[0]?.role.key);
const roleLabel = ROLE_LABELS[resolvedRole];
const displayName = user.name?.trim() || user.email;
const departmentLabel = user.department ? getDepartmentLabel(user.department) : "Sin departamento";
const departmentRole = user.departmentRole ?? "Sin rol de departamento";
const canViewLifecycle = canManageHumanCapital(session?.user.role, session?.user.department);
const profile = canViewLifecycle
? await prisma.employeeProfile.findUnique({
where: { userId: user.id },
})
: null;
const lifecycleEvents = canViewLifecycle
? await prisma.employeeLifecycleEvent.findMany({
where: { userId: user.id },
orderBy: [{ effectiveAt: "desc" }, { createdAt: "desc" }],
take: 60,
})
: [];
const profileLocationName = profile?.locationId
? locationOptions.find((location) => location.id === profile.locationId)?.name ?? profile.locationId
: "-";
return (
<div className="space-y-6">
<Link href="/people" className="text-body font-semibold text-benell-brown hover:underline">
Volver a Personas
</Link>
<header className="rounded-benell border border-benell-stroke bg-benell-surface p-6">
<h1 className="text-display">{displayName}</h1>
<p className="text-body-lg text-benell-text-soft">{roleLabel}</p>
<p className="text-body mt-2 text-benell-text-soft text-tabular">
{departmentLabel} · {departmentRole} · {user.email}
</p>
</header>
{canViewLifecycle ? (
<>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5">
<h2 className="text-h2 font-semibold">Ficha de Capital Humano</h2>
<div className="mt-3 grid grid-cols-1 gap-2 text-body text-benell-text-soft sm:grid-cols-2">
<p>Estatus laboral: {profile?.employmentStatus ?? "sin perfil"}</p>
<p>Tipo: {profile?.employmentType ?? "-"}</p>
<p>FTE: {profile?.fte ?? "-"}</p>
<p>Fecha de ingreso: {profile?.hireDate ? profile.hireDate.toISOString().slice(0, 10) : "-"}</p>
<p>Manager ID: {profile?.managerUserId ?? "-"}</p>
<p>Ubicación: {profileLocationName}</p>
</div>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5">
<h2 className="text-h2 font-semibold">Timeline de lifecycle</h2>
{lifecycleEvents.length === 0 ? (
<p className="text-body mt-3 text-benell-text-soft">No hay eventos registrados.</p>
) : (
<ul className="mt-3 space-y-2">
{lifecycleEvents.map((event) => (
<li key={event.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<p className="text-body font-semibold text-benell-text">{event.eventType}</p>
<p className="text-caption text-benell-text-soft">
{event.effectiveAt.toISOString().slice(0, 10)} · {event.reason ?? "sin motivo"}
</p>
</li>
))}
</ul>
)}
</section>
</>
) : (
<EmptyState
title="Vista de seguimiento individual"
description="El detalle de lifecycle está visible solo para Owner y líderes de Capital Humano."
/>
)}
</div>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { Search } from "lucide-react";
import { useUIStore } from "@/lib/store/ui-store";
import { ROLE_LABELS } from "@/lib/utils";
import type { UserRole } from "@/lib/types";
type PersonRecord = {
id: string;
name: string;
email: string;
role: UserRole;
roleLabel: string;
departmentLabel: string | null;
departmentRole: string | null;
status: string;
};
function getInitials(name: string) {
const trimmed = name.trim();
if (!trimmed) {
return "U";
}
const chunks = trimmed.split(/\s+/).filter(Boolean);
if (chunks.length === 1) {
return chunks[0].slice(0, 2).toUpperCase();
}
return `${chunks[0][0] ?? ""}${chunks[1][0] ?? ""}`.toUpperCase();
}
export default function PeoplePage() {
const { data: session } = useSession();
const search = useUIStore((state) => state.search.trim().toLowerCase());
const [people, setPeople] = useState<PersonRecord[]>([]);
const [draftRoleByUserId, setDraftRoleByUserId] = useState<Record<string, UserRole>>({});
const [isLoading, setIsLoading] = useState(true);
const [isUpdatingByUserId, setIsUpdatingByUserId] = useState<Record<string, boolean>>({});
const [isDeletingByUserId, setIsDeletingByUserId] = useState<Record<string, boolean>>({});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const canManagePeople = session?.user?.role === "owner" || Boolean(session?.user?.canElevate);
const loadPeople = useCallback(async () => {
setIsLoading(true);
setErrorMessage(null);
try {
const response = await fetch("/api/people", {
method: "GET",
cache: "no-store",
});
const payload = (await response.json()) as { people?: PersonRecord[]; error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar el directorio.");
}
const records = payload.people ?? [];
setPeople(records);
setDraftRoleByUserId(
Object.fromEntries(records.map((person) => [person.id, person.role]))
);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "No se pudo cargar el directorio.");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadPeople();
}, [loadPeople]);
const filteredPeople = useMemo(
() =>
people.filter((person) => {
const matchesSearch =
!search ||
person.name.toLowerCase().includes(search) ||
person.roleLabel.toLowerCase().includes(search) ||
person.departmentLabel?.toLowerCase().includes(search) ||
person.email.toLowerCase().includes(search);
return matchesSearch;
}),
[people, search]
);
const handleSaveRole = async (person: PersonRecord) => {
const draftRole = draftRoleByUserId[person.id];
if (!draftRole || draftRole === person.role) {
return;
}
setErrorMessage(null);
setIsUpdatingByUserId((prev) => ({ ...prev, [person.id]: true }));
try {
const response = await fetch(`/api/people/${person.id}/role`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: draftRole }),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo actualizar el rol.");
}
await loadPeople();
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "No se pudo actualizar el rol.");
} finally {
setIsUpdatingByUserId((prev) => ({ ...prev, [person.id]: false }));
}
};
const handleDeletePerson = async (person: PersonRecord) => {
if (!canManagePeople) {
return;
}
const shouldDelete = window.confirm(`Eliminar usuario ${person.name} (${person.email})? Esta accion no se puede deshacer.`);
if (!shouldDelete) {
return;
}
setErrorMessage(null);
setIsDeletingByUserId((prev) => ({ ...prev, [person.id]: true }));
try {
const response = await fetch(`/api/people/${person.id}`, {
method: "DELETE",
});
const payload = (await response.json().catch(() => ({}))) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo eliminar el usuario.");
}
await loadPeople();
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "No se pudo eliminar el usuario.");
} finally {
setIsDeletingByUserId((prev) => ({ ...prev, [person.id]: false }));
}
};
return (
<div className="space-y-6">
<header>
<h1 className="text-display">Personas</h1>
<p className="text-body-lg text-benell-text-soft">Directorio de empleados y seguimiento</p>
</header>
<label className="relative block max-w-2xl" htmlFor="people-search-readonly">
<Search className="pointer-events-none absolute left-3 top-2.5 size-5 text-benell-text-soft" aria-hidden />
<input
id="people-search-readonly"
readOnly
value={search}
placeholder="Buscar por nombre o rol..."
className="text-body w-full rounded-xl border border-benell-stroke bg-benell-surface px-10 py-2 text-benell-text-soft"
/>
</label>
{errorMessage ? <p className="text-label text-benell-red">{errorMessage}</p> : null}
{isLoading ? <p className="text-body text-benell-text-soft">Cargando personas...</p> : null}
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredPeople.map((person) => (
<article key={person.id} className="rounded-benell border border-benell-stroke bg-benell-surface p-5">
<div className="flex gap-4">
<span className="text-h3 inline-flex size-14 shrink-0 items-center justify-center rounded-full bg-benell-caramel font-semibold text-white md:size-16">
{getInitials(person.name)}
</span>
<div className="min-w-0">
<h2 className="text-h2 truncate font-semibold">{person.name}</h2>
<p className="text-body text-benell-text-soft">{person.roleLabel}</p>
<p className="text-label truncate text-benell-text-soft">{person.email}</p>
<div className="text-caption mt-2 flex flex-wrap gap-2">
{person.departmentLabel ? (
<span className="rounded-pill border border-benell-stroke px-2 py-0.5 font-semibold">{person.departmentLabel}</span>
) : null}
{person.departmentRole ? (
<span className="rounded-pill border border-benell-stroke px-2 py-0.5">{person.departmentRole}</span>
) : null}
<span className="text-benell-text-soft">{person.status}</span>
</div>
</div>
</div>
{canManagePeople ? (
<div className="mt-4 border-t border-benell-stroke pt-3">
<label className="block space-y-1">
<span className="text-label font-semibold">Acceso de plataforma</span>
<div className="flex items-center gap-2">
<select
value={draftRoleByUserId[person.id] ?? person.role}
onChange={(event) =>
setDraftRoleByUserId((prev) => ({
...prev,
[person.id]: event.target.value as UserRole,
}))
}
className="text-body flex-1 rounded-xl border border-benell-stroke bg-white px-3 py-2"
>
<option value="owner">{ROLE_LABELS.owner}</option>
<option value="leader">{ROLE_LABELS.leader}</option>
<option value="employee">{ROLE_LABELS.employee}</option>
</select>
<button
type="button"
disabled={isUpdatingByUserId[person.id] || (draftRoleByUserId[person.id] ?? person.role) === person.role}
onClick={() => handleSaveRole(person)}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Guardar
</button>
</div>
</label>
<button
type="button"
onClick={() => void handleDeletePerson(person)}
disabled={isDeletingByUserId[person.id] || person.id === session?.user?.id}
className="mt-3 rounded-pill border border-benell-red/40 bg-benell-red/10 px-3 py-2 text-label font-semibold text-benell-red disabled:opacity-50"
>
{isDeletingByUserId[person.id] ? "Eliminando..." : person.id === session?.user?.id ? "No puedes eliminarte" : "Eliminar usuario"}
</button>
</div>
) : null}
<Link
href={`/people/${person.id}`}
className="text-label mt-3 inline-flex font-semibold text-benell-caramel hover:text-benell-brown"
>
Ver perfil
</Link>
</article>
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import EmptyState from "@/components/EmptyState";
import InviteUserPanel from "@/components/InviteUserPanel";
import AccountSettingsPanel from "@/components/settings/AccountSettingsPanel";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { canInviteUsers } from "@/lib/roles";
export default async function SettingsPage() {
const session = await getServerSession(authOptions);
const canManageInvites = canInviteUsers(session?.user?.role);
return (
<div className="space-y-5">
<header>
<h1 className="text-display">Configuración</h1>
<p className="text-body-lg text-benell-text-soft">Cuenta personal, notificaciones y permisos</p>
</header>
<AccountSettingsPanel />
{canManageInvites ? <InviteUserPanel /> : null}
<EmptyState
title="Configuración avanzada"
description="Próximamente podrás administrar reglas detalladas por área y preferencias operativas."
/>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import AcceptInvitationForm from "@/components/AcceptInvitationForm";
import { hashInvitationToken } from "@/lib/invitations";
import { prisma } from "@/lib/prisma";
import { getDepartmentLabel } from "@/lib/departments";
import { ROLE_LABELS } from "@/lib/utils";
export default async function AcceptInvitationPage({
searchParams,
}: {
searchParams?: { token?: string; email?: string };
}) {
const tokenFromUrl = searchParams?.token?.trim() ?? "";
const emailFromUrl = searchParams?.email?.trim() ?? "";
if (!tokenFromUrl || !emailFromUrl) {
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="text-display">Invitacion invalida</h1>
<p className="text-body mt-2">Faltan parametros en el enlace de invitacion.</p>
</main>
);
}
const invitation = await prisma.invitation.findFirst({
where: {
email: emailFromUrl.toLowerCase(),
tokenHash: hashInvitationToken(tokenFromUrl),
acceptedAt: null,
expiresAt: { gt: new Date() },
},
select: {
inviteeName: true,
department: true,
departmentRole: true,
roleKey: true,
},
});
if (!invitation) {
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="text-display">Invitacion expirada</h1>
<p className="text-body mt-2">Este enlace no es valido o ya fue utilizado.</p>
</main>
);
}
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="text-display">Aceptar invitacion</h1>
<p className="text-body mt-2">Completa tus datos para activar tu cuenta de Casa Benell.</p>
<AcceptInvitationForm
token={tokenFromUrl}
email={emailFromUrl}
defaultName={invitation.inviteeName}
departmentLabel={getDepartmentLabel(invitation.department)}
departmentRole={invitation.departmentRole}
platformRoleLabel={ROLE_LABELS[invitation.roleKey]}
/>
</main>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import Link from "next/link";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="text-display">Recuperar contrasena</h1>
<p className="text-body mt-2 text-benell-text-soft">
Ingresa tu correo y te enviaremos un enlace para restablecer tu contrasena.
</p>
<form
className="mt-6 space-y-4 rounded-benell border border-benell-stroke bg-benell-surface p-5"
onSubmit={async (event) => {
event.preventDefault();
setErrorMessage(null);
setMessage(null);
setIsSubmitting(true);
try {
const response = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const payload = (await response.json()) as { message?: string; error?: string };
if (!response.ok) {
setErrorMessage(payload.error ?? "No se pudo procesar la solicitud.");
return;
}
setMessage(payload.message ?? "Si el correo existe, enviaremos un enlace para restablecer la contrasena.");
} catch {
setErrorMessage("No se pudo procesar la solicitud.");
} finally {
setIsSubmitting(false);
}
}}
>
<label className="block space-y-1.5">
<span className="text-label font-semibold">Correo electronico</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
className="text-body w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-benell-brown"
required
/>
</label>
{errorMessage ? <p className="text-label text-benell-red">{errorMessage}</p> : null}
{message ? <p className="text-label text-benell-green">{message}</p> : null}
<button
type="submit"
disabled={isSubmitting}
className="text-body w-full rounded-pill bg-benell-brown py-3 font-semibold text-white"
>
{isSubmitting ? "Enviando..." : "Enviar enlace"}
</button>
</form>
<p className="text-label mt-4 text-benell-text-soft">
<Link href="/login" className="font-semibold text-benell-brown hover:underline">
Volver a login
</Link>
</p>
</main>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
import { useEffect, useState } from "react";
import { signIn, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { getDepartmentHomeRoute } from "@/lib/access-control";
import type { DepartmentKey } from "@/lib/types";
export default function LoginPage() {
const router = useRouter();
const { status, data: session } = useSession();
const [email, setEmail] = useState("owner@casabenell.com");
const [password, setPassword] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [verificationNotice, setVerificationNotice] = useState<string | null>(null);
useEffect(() => {
if (status === "authenticated") {
const role = session?.user?.role;
const department = (session?.user?.department as DepartmentKey | null | undefined) ?? null;
const nextPath = role === "owner" ? "/dashboard" : getDepartmentHomeRoute(department);
router.replace(nextPath);
}
}, [router, session?.user?.department, session?.user?.role, status]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const verified = params.get("verified");
const reset = params.get("reset");
if (reset === "1") {
setVerificationNotice("Contrasena actualizada. Ya puedes iniciar sesion.");
return;
}
if (verified === "1") {
setVerificationNotice("Correo verificado. Ya puedes iniciar sesión.");
return;
}
if (verified === "0") {
setVerificationNotice("No se pudo verificar el correo. Solicita un nuevo registro.");
return;
}
setVerificationNotice(null);
}, []);
return (
<main className="grid min-h-screen grid-cols-1 bg-benell-bg md:grid-cols-2">
<section className="flex items-center justify-center p-6 md:p-12">
<div className="w-full max-w-md">
<Image
src="/brand/logo.webp"
alt="Casa Benell"
width={110}
height={76}
className="h-20 w-auto rounded-lg border border-benell-stroke object-contain object-center"
priority
/>
<h1 className="text-display mt-8 text-benell-text">
Bienvenido
</h1>
<div className="mt-7 rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell md:p-7">
<form
onSubmit={async (event) => {
event.preventDefault();
setErrorMessage(null);
setIsSubmitting(true);
const result = await signIn("credentials", {
redirect: false,
email,
password,
});
setIsSubmitting(false);
if (result?.error) {
setErrorMessage("Credenciales inválidas, cuenta inactiva o correo no verificado.");
return;
}
}}
className="space-y-6"
>
<label className="block space-y-1.5">
<span className="text-label font-semibold">Correo electrónico</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
className="text-body w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-benell-brown"
required
/>
</label>
<label className="block space-y-1.5">
<span className="text-label font-semibold">Contraseña</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="text-body w-full rounded-xl border border-benell-stroke bg-white px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-benell-brown"
required
/>
</label>
{errorMessage ? <p className="text-label text-benell-red">{errorMessage}</p> : null}
{verificationNotice ? <p className="text-label text-benell-green">{verificationNotice}</p> : null}
<button
type="submit"
disabled={isSubmitting}
className="text-body w-full rounded-pill bg-benell-brown py-3 font-semibold text-white outline-none transition hover:bg-black focus-visible:ring-2 focus-visible:ring-benell-brown focus-visible:ring-offset-2"
>
{isSubmitting ? "Ingresando..." : "Entrar"}
</button>
</form>
<Link href="/forgot-password" className="text-label mt-3 inline-flex text-benell-text-soft hover:underline">
Olvidé mi contraseña
</Link>
<p className="text-label mt-3 text-benell-text-soft">
El acceso es por invitación. Revisa tu correo para activar tu cuenta.
</p>
</div>
</div>
</section>
<section className="relative hidden overflow-hidden bg-[#f2ece0] md:block">
<Image
src="/brand/mascot.png"
alt="Mascota Casa Benell"
width={500}
height={500}
className="absolute bottom-8 right-8 h-72 w-72 rounded-benell border border-benell-stroke object-contain object-bottom"
/>
</section>
</main>
);
}

View File

@@ -0,0 +1,42 @@
import Link from "next/link";
import Image from "next/image";
export default function RegisterPage() {
return (
<main className="grid min-h-screen grid-cols-1 bg-benell-bg md:grid-cols-2">
<section className="flex items-center justify-center p-6 md:p-12">
<div className="w-full max-w-md">
<Image
src="/brand/logo.webp"
alt="Casa Benell"
width={110}
height={76}
className="h-20 w-auto rounded-lg border border-benell-stroke object-contain object-center"
priority
/>
<h1 className="text-display mt-8 text-benell-text">Registro deshabilitado</h1>
<div className="mt-7 rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell md:p-7">
<p className="text-body text-benell-text-soft">
El alta de cuentas se gestiona por invitación.
Cuando un dueño o líder te invite, recibirás por correo el enlace para crear tu contraseña y activar tu acceso.
</p>
<div className="mt-4">
<Link href="/login" className="text-label font-semibold text-benell-brown hover:underline">
Volver a login
</Link>
</div>
</div>
</div>
</section>
<section className="relative hidden overflow-hidden bg-[#f2ece0] md:block">
<Image
src="/brand/mascot.png"
alt="Mascota Casa Benell"
width={500}
height={500}
className="absolute bottom-8 right-8 h-72 w-72 rounded-benell border border-benell-stroke object-contain object-bottom"
/>
</section>
</main>
);
}

View File

@@ -0,0 +1,33 @@
import Link from "next/link";
import ResetPasswordForm from "@/components/auth/ResetPasswordForm";
export default function ResetPasswordPage({
searchParams,
}: {
searchParams?: { token?: string; email?: string };
}) {
const token = searchParams?.token?.trim() ?? "";
const email = searchParams?.email?.toLowerCase().trim() ?? "";
if (!token || !email) {
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="text-display">Enlace invalido</h1>
<p className="text-body mt-2">Faltan parametros para restablecer la contrasena.</p>
<p className="text-label mt-4 text-benell-text-soft">
<Link href="/forgot-password" className="font-semibold text-benell-brown hover:underline">
Solicitar nuevo enlace
</Link>
</p>
</main>
);
}
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="text-display">Nueva contrasena</h1>
<p className="text-body mt-2 text-benell-text-soft">Define una nueva contrasena para {email}.</p>
<ResetPasswordForm token={token} email={email} />
</main>
);
}

Some files were not shown because too many files have changed in this diff Show More