merca y ch
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
41
.gitignore
vendored
Normal 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
99
README.md
Normal 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.
|
||||
55
Reference KPI dashboard plan.md
Normal 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 16–22, 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 Monday–Sunday 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).
|
||||
15
deploy/systemd/benell-marketing-sync.service
Normal 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
|
||||
11
deploy/systemd/benell-marketing-sync.timer
Normal 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
|
||||
21
deploy/systemd/benell.service
Normal 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
@@ -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.
|
||||
BIN
example_ui_img/Logo Benito.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214334.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214357.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214412.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214420.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214427.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214441.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214448.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214454.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214459.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214506.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214515.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214522.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
example_ui_img/Screenshot 2026-02-16 214533.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
example_ui_img/benell_logo.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
293
experiencometro.md
Normal 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
@@ -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
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7117
package-lock.json
generated
Normal file
47
package.json
Normal 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
@@ -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
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
135
prisma/migrations/0001_init/migration.sql
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
17
prisma/migrations/0004_password_reset_tokens/migration.sql
Normal 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");
|
||||
@@ -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;
|
||||
12
prisma/migrations/0006_add_projects_department/migration.sql
Normal 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 $$;
|
||||
49
prisma/migrations/0006_marketing_milestones/migration.sql
Normal 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;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "public"."MarketingInitiative"
|
||||
ADD COLUMN "isGlobal" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
88
prisma/migrations/0010_projects_navigation_v12/migration.sql
Normal 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");
|
||||
60
prisma/migrations/0011_weekly_kpi_board_v1/migration.sql
Normal 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;
|
||||
@@ -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;
|
||||
327
prisma/migrations/20260322183442_experienciometro/migration.sql
Normal 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;
|
||||
158
prisma/migrations/20260323195000_capture_v1/migration.sql
Normal 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;
|
||||
399
prisma/migrations/20260330001000_hc_workspace_v1/migration.sql
Normal 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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal 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
76
prisma/seed.ts
Normal 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
@@ -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 owner’s 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 department’s 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
|
After Width: | Height: | Size: 132 KiB |
BIN
public/brand/logo.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/brand/mascot.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
1
public/file.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 |
218
src/app/(app)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/app/(app)/dashboard/print/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/(app)/data-entry/page.tsx
Normal 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} />;
|
||||
}
|
||||
5
src/app/(app)/departments/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminDepartmentPage() {
|
||||
redirect("/financial-flow");
|
||||
}
|
||||
5
src/app/(app)/departments/finance/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function FinanceDepartmentPage() {
|
||||
redirect("/financial-flow");
|
||||
}
|
||||
1341
src/app/(app)/departments/human-capital/[tab]/page.tsx
Normal file
633
src/app/(app)/departments/human-capital/configuracion/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/(app)/departments/human-capital/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/(app)/departments/human-capital/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HumanCapitalIndexPage() {
|
||||
redirect("/departments/human-capital/resumen");
|
||||
}
|
||||
14
src/app/(app)/departments/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import DepartmentWorkspaceTabs from "@/components/layout/DepartmentWorkspaceTabs";
|
||||
|
||||
export default function DepartmentsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<DepartmentWorkspaceTabs />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/app/(app)/departments/marketing/initiatives/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "../page";
|
||||
1
src/app/(app)/departments/marketing/meetings/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "../page";
|
||||
935
src/app/(app)/departments/marketing/page.tsx
Normal 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 "LTO"/"Limited Time" 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>
|
||||
);
|
||||
}
|
||||
521
src/app/(app)/departments/operations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/app/(app)/departments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/(app)/departments/projects/capture/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import CaptureWorkspace from "@/components/capture/CaptureWorkspace";
|
||||
|
||||
export default function ProjectsCapturePage() {
|
||||
return <CaptureWorkspace forcedDepartment="proyectos" />;
|
||||
}
|
||||
5
src/app/(app)/departments/projects/initiatives/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ProjectsInitiativesAliasRedirect() {
|
||||
redirect("/departments/projects/projects");
|
||||
}
|
||||
14
src/app/(app)/departments/projects/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
615
src/app/(app)/departments/projects/meetings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
434
src/app/(app)/departments/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1396
src/app/(app)/departments/projects/projects/page.tsx
Normal file
160
src/app/(app)/departments/projects/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
535
src/app/(app)/experienciometro/configuracion/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
485
src/app/(app)/experienciometro/evaluaciones/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
src/app/(app)/experienciometro/hallazgos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/app/(app)/experienciometro/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
src/app/(app)/experienciometro/sucursales/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/(app)/experienciometro/sucursales/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ExperienciometroBranchesPage() {
|
||||
redirect("/experienciometro");
|
||||
}
|
||||
91
src/app/(app)/financial-flow/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/app/(app)/initiatives/page.tsx
Normal 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
@@ -0,0 +1,9 @@
|
||||
import AppShell from "@/components/layout/AppShell";
|
||||
|
||||
export default function AuthenticatedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
48
src/app/(app)/meetings/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/app/(app)/meetings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src/app/(app)/people/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
src/app/(app)/people/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/(app)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/app/(auth)/accept-invitation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/app/(auth)/forgot-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/(auth)/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||