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>
|
||||||
|
);
|
||||||
|
}
|
||||||