feat: implement Phase 3 - Compensation Plan Creation, Versioning Logic, Goals, and E2E Tests

This commit is contained in:
Luis Gabriel Ramos Robles 2026-06-11 18:03:44 +00:00
parent 9ea98f8d76
commit f79d7db793
36 changed files with 3378 additions and 9 deletions

49
.gitignore vendored Normal file
View file

@ -0,0 +1,49 @@
# 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
/src/generated/prisma
# agents and keys
.agents/
# test artifacts
prisma/screenshots/

View file

@ -0,0 +1,82 @@
# Phase 3 Implementation & Security Design Document
**Variable Remuneration, Compensation, and Commissions System - Hoteles Estelar**
---
This document outlines the design decisions, schema details, API routing, and security/RLS validations implemented for **Phase 3: Compensation Configuration**.
## 1. Architectural Strategy & Logic Flows
### 1.1. Plan Creation & Mandatory Field Validation
The system allows Administrators to define compensation structures. The database structure ensures strict alignment with commercial dimensions:
- Plans are tied to hotels, regions, roles, or campaigns.
- Mandatory fields are verified at both the frontend schema layer (UI form validation) and the backend API layer.
### 1.2. Plan Versioning Logic (Audit Integrity)
To maintain historical reproducibility:
- Direct updates to a plan in `DRAFT` status are performed in-place (same record).
- Updates to a plan in `ACTIVE` status trigger the versioning engine:
1. The existing active plan is updated to `status = 'INACTIVE'` and `validity_end = NOW()`.
2. A duplicate plan is inserted with `version = original_version + 1`, `status = 'ACTIVE'`, `validity_start = NOW()`, and `validity_end = NULL`.
3. All calculation rules associated with the original plan are copied to the new version.
4. Historical settlements (`SETTLEMENTS` table) remain linked to the original `plan_id` (representing the old version), preserving historical calculations.
```mermaid
graph TD
A[Request to Edit Plan] --> B{Is Status ACTIVE?}
B -->|No - DRAFT/INACTIVE| C[Update Plan In-Place]
B -->|Yes - ACTIVE| D[Mark Current Plan as INACTIVE with validity_end = NOW]
D --> E[Clone Plan Details]
E --> F[Increment Version +1]
F --> G[Insert New Plan as ACTIVE with validity_start = NOW]
G --> H[Clone and Associate Calculation Rules]
```
### 1.3. Calculation Rules Configuration
Rules define how commissions are computed based on achievement percentiles:
- **TIER**: Payout is a percentage rate applied to sales once a tier threshold is met (e.g., 90% to 100% achievement yields 1.5% commission rate).
- **BONUS**: A fixed cash payout once a threshold is met.
- Boundaries are validated to prevent overlap (`min_achievement` must be `< max_achievement` and contiguous).
### 1.4. Goal Assignment
Goals are assigned per period (`YYYY-MM`) at different scopes (`INDIVIDUAL` | `TEAM` | `HOTEL`) to define target quotas. Calculations evaluate actual sales against these quotas.
---
## 2. API Specifications
### 2.1. `POST /api/plans` (Create Plan)
- **Role Restriction**: `ADMIN` or `DIRECTOR`
- **Request Body**:
```json
{
"name": "Plan Ventas CTG Q2",
"code": "PLAN-CTG-Q2",
"validityStart": "2026-06-01T00:00:00Z",
"type": "PERCENTAGE", // PERCENTAGE | SCALE | CONDITIONAL | FIXED
"status": "DRAFT"
}
```
### 2.2. `PUT /api/plans/[id]` (Update/Version Plan)
- **Role Restriction**: `ADMIN` or `DIRECTOR`
- **Logic**: Evaluates status to execute in-place updates or clone/version logic.
### 2.3. `POST /api/plans/[id]/rules` (Configure Rules)
- **Role Restriction**: `ADMIN` or `DIRECTOR`
- **Request Body**: Array of calculation rules to bulk upsert.
### 2.4. `POST /api/goals` (Assign Goals)
- **Role Restriction**: `ADMIN` or `DIRECTOR`
---
## 3. Puppeteer Validation Plan
We verify usability using headless browser automation tests:
1. **Form Validation**: Try to submit a plan with empty name or code, confirming that HTML5/React validation prevents submission.
2. **Creation**: Fill fields, click submit, verify redirect to rules setup.
3. **Rules Config**: Add tiers, save rules, confirm database records.
4. **Versioning Check**: Modify an active plan and verify that:
- Old plan record has status `INACTIVE` and `validity_end` is populated.
- New plan record is created with version `2` and status `ACTIVE`.

18
eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
next.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

154
package-lock.json generated
View file

@ -27,6 +27,7 @@
"eslint": "^9",
"eslint-config-next": "16.2.9",
"prisma": "^7.8.0",
"puppeteer": "^25.1.0",
"typescript": "^5"
}
},
@ -1529,6 +1530,30 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@puppeteer/browsers": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.4.tgz",
"integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==",
"dev": true,
"dependencies": {
"modern-tar": "^0.7.6",
"yargs": "^17.7.2"
},
"bin": {
"browsers": "lib/main-cli.js"
},
"engines": {
"node": ">=22.12.0"
},
"peerDependencies": {
"proxy-agent": ">=8.0.1"
},
"peerDependenciesMeta": {
"proxy-agent": {
"optional": true
}
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@ -2974,6 +2999,31 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chromium-bidi": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
"integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==",
"dev": true,
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/chromium-bidi/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -3284,6 +3334,12 @@
"node": ">=8"
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1624250",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz",
"integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==",
"dev": true
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -5390,6 +5446,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -5558,6 +5626,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true
},
"node_modules/modern-tar": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz",
"integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==",
"dev": true,
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -6294,6 +6377,44 @@
"node": ">=6"
}
},
"node_modules/puppeteer": {
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.1.0.tgz",
"integrity": "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@puppeteer/browsers": "3.0.4",
"chromium-bidi": "16.0.1",
"devtools-protocol": "0.0.1624250",
"lilconfig": "^3.1.3",
"puppeteer-core": "25.1.0",
"typed-query-selector": "^2.12.2"
},
"bin": {
"puppeteer": "lib/puppeteer/node/cli.js"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/puppeteer-core": {
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.1.0.tgz",
"integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==",
"dev": true,
"dependencies": {
"@puppeteer/browsers": "3.0.4",
"chromium-bidi": "16.0.1",
"devtools-protocol": "0.0.1624250",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.2",
"ws": "^8.21.0"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@ -7393,6 +7514,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"dev": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -7557,6 +7684,12 @@
"node": ">= 0.8"
}
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
"integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
"dev": true
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -7686,6 +7819,27 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -11,7 +11,8 @@
"db:seed-test": "DATABASE_URL=\"postgresql://special_hotel_user:SpecialHotel1235%2F%2A-%2B@pg.gaboggamer.online/special_hotel_test?schema=public\" npx prisma db execute --file prisma/rls_and_seed.sql",
"test:rls": "node prisma/test-rls.js",
"test:auth-rls": "node prisma/test-auth-rls.js",
"test:all": "npm run test:rls && npm run test:auth-rls",
"test:ui": "node prisma/test-phase3-ui.js",
"test:all": "npm run test:rls && npm run test:auth-rls && npm run test:ui",
"test": "npm run test:all"
},
"dependencies": {
@ -34,6 +35,7 @@
"eslint": "^9",
"eslint-config-next": "16.2.9",
"prisma": "^7.8.0",
"puppeteer": "^25.1.0",
"typescript": "^5"
}
}

14
prisma.config.ts Normal file
View file

@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View file

@ -0,0 +1,206 @@
-- CreateTable
CREATE TABLE "regions" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
CONSTRAINT "regions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "hotels" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"region_id" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
CONSTRAINT "hotels_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"role" TEXT NOT NULL,
"hotel_id" INTEGER NOT NULL,
"area" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "compensation_plans" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"validity_start" TIMESTAMP(3) NOT NULL,
"validity_end" TIMESTAMP(3),
"type" TEXT NOT NULL,
"formula" TEXT,
"meta_amount" DECIMAL(12,2),
"percentage_rate" DECIMAL(5,4),
"max_cap" DECIMAL(12,2),
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"version" INTEGER NOT NULL DEFAULT 1,
"created_by" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "compensation_plans_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "calculation_rules" (
"id" SERIAL NOT NULL,
"plan_id" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"min_achievement" DECIMAL(5,4) NOT NULL,
"max_achievement" DECIMAL(5,4) NOT NULL,
"rate" DECIMAL(5,4) NOT NULL,
"payout_amount" DECIMAL(12,2) NOT NULL,
CONSTRAINT "calculation_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "goals" (
"id" SERIAL NOT NULL,
"target_type" TEXT NOT NULL,
"target_id" INTEGER NOT NULL,
"period" TEXT NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "goals_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sales_results" (
"id" SERIAL NOT NULL,
"source" TEXT NOT NULL,
"hotel_id" INTEGER NOT NULL,
"user_id" INTEGER NOT NULL,
"period" TEXT NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"sales_count" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"idempotency_key" TEXT NOT NULL,
"transaction_id" TEXT,
"uploaded_by" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sales_results_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "settlements" (
"id" SERIAL NOT NULL,
"period" TEXT NOT NULL,
"plan_id" INTEGER NOT NULL,
"user_id" INTEGER NOT NULL,
"sales_amount" DECIMAL(12,2) NOT NULL,
"goal_amount" DECIMAL(12,2) NOT NULL,
"achievement_percentage" DECIMAL(5,4) NOT NULL,
"calculated_commission" DECIMAL(12,2) NOT NULL,
"calculated_bonus" DECIMAL(12,2) NOT NULL,
"adjustment_amount" DECIMAL(12,2) NOT NULL,
"total_payout" DECIMAL(12,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'SIMULATED',
"approved_by" INTEGER,
"approved_at" TIMESTAMP(3),
"rejection_reason" TEXT,
"original_settlement_id" INTEGER,
"adjustment_notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "settlements_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"action" TEXT NOT NULL,
"target_table" TEXT NOT NULL,
"target_id" INTEGER NOT NULL,
"previous_value" JSONB,
"new_value" JSONB,
"ip_address" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notifications" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'UNREAD',
"type" TEXT NOT NULL,
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "regions_code_key" ON "regions"("code");
-- CreateIndex
CREATE UNIQUE INDEX "hotels_code_key" ON "hotels"("code");
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "sales_results_idempotency_key_key" ON "sales_results"("idempotency_key");
-- AddForeignKey
ALTER TABLE "hotels" ADD CONSTRAINT "hotels_region_id_fkey" FOREIGN KEY ("region_id") REFERENCES "regions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_hotel_id_fkey" FOREIGN KEY ("hotel_id") REFERENCES "hotels"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "compensation_plans" ADD CONSTRAINT "compensation_plans_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calculation_rules" ADD CONSTRAINT "calculation_rules_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "compensation_plans"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "goals" ADD CONSTRAINT "goals_target_id_fkey" FOREIGN KEY ("target_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sales_results" ADD CONSTRAINT "sales_results_hotel_id_fkey" FOREIGN KEY ("hotel_id") REFERENCES "hotels"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sales_results" ADD CONSTRAINT "sales_results_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sales_results" ADD CONSTRAINT "sales_results_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "settlements" ADD CONSTRAINT "settlements_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "compensation_plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "settlements" ADD CONSTRAINT "settlements_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "settlements" ADD CONSTRAINT "settlements_approved_by_fkey" FOREIGN KEY ("approved_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "settlements" ADD CONSTRAINT "settlements_original_settlement_id_fkey" FOREIGN KEY ("original_settlement_id") REFERENCES "settlements"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

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

177
prisma/schema.prisma Normal file
View file

@ -0,0 +1,177 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model Region {
id Int @id @default(autoincrement())
name String
code String @unique
hotels Hotel[]
@@map("regions")
}
model Hotel {
id Int @id @default(autoincrement())
name String
code String @unique
regionId Int @map("region_id")
region Region @relation(fields: [regionId], references: [id])
status String @default("ACTIVE") // ACTIVE | INACTIVE
users User[]
sales SalesResult[]
@@map("hotels")
}
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
passwordHash String @map("password_hash")
role String // ADMIN | DIRECTOR | GERENTE | LIDER | ANALISTA | CONSULTA | COLABORADOR
hotelId Int @map("hotel_id")
hotel Hotel @relation(fields: [hotelId], references: [id])
area String
status String @default("ACTIVE") // ACTIVE | INACTIVE
createdAt DateTime @default(now()) @map("created_at")
goals Goal[]
sales SalesResult[] @relation("ColaboradorSales")
uploadedSales SalesResult[] @relation("UploaderSales")
settlements Settlement[] @relation("ColaboradorSettlements")
approvedSettlements Settlement[] @relation("ApproverSettlements")
plansCreated CompensationPlan[] @relation("PlanCreator")
auditLogs AuditLog[]
notifications Notification[]
@@map("users")
}
model CompensationPlan {
id Int @id @default(autoincrement())
name String
code String
validityStart DateTime @map("validity_start")
validityEnd DateTime? @map("validity_end")
type String // PERCENTAGE | SCALE | CONDITIONAL | FIXED
formula String? // JSON or formula string
metaAmount Decimal? @map("meta_amount") @db.Decimal(12, 2)
percentageRate Decimal? @map("percentage_rate") @db.Decimal(5, 4)
maxCap Decimal? @map("max_cap") @db.Decimal(12, 2)
status String @default("DRAFT") // DRAFT | ACTIVE | INACTIVE
version Int @default(1)
createdBy Int @map("created_by")
creator User @relation("PlanCreator", fields: [createdBy], references: [id])
createdAt DateTime @default(now()) @map("created_at")
rules CalculationRule[]
settlements Settlement[]
@@map("compensation_plans")
}
model CalculationRule {
id Int @id @default(autoincrement())
planId Int @map("plan_id")
plan CompensationPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
type String // TIER | BONUS
minAchievement Decimal @map("min_achievement") @db.Decimal(5, 4)
maxAchievement Decimal @map("max_achievement") @db.Decimal(5, 4)
rate Decimal @map("rate") @db.Decimal(5, 4)
payoutAmount Decimal @map("payout_amount") @db.Decimal(12, 2)
@@map("calculation_rules")
}
model Goal {
id Int @id @default(autoincrement())
targetType String @map("target_type") // INDIVIDUAL | TEAM | HOTEL
targetId Int @map("target_id")
user User? @relation(fields: [targetId], references: [id])
period String @map("period") // YYYY-MM
amount Decimal @db.Decimal(12, 2)
createdAt DateTime @default(now()) @map("created_at")
@@map("goals")
}
model SalesResult {
id Int @id @default(autoincrement())
source String // EXCEL | API
hotelId Int @map("hotel_id")
hotel Hotel @relation(fields: [hotelId], references: [id])
userId Int @map("user_id")
colaborador User @relation("ColaboradorSales", fields: [userId], references: [id])
period String @map("period") // YYYY-MM
amount Decimal @db.Decimal(12, 2)
salesCount Int @map("sales_count")
status String @default("PENDING") // PENDING | PROCESSED
idempotencyKey String @unique @map("idempotency_key")
transactionId String? @map("transaction_id")
uploadedBy Int @map("uploaded_by")
uploader User @relation("UploaderSales", fields: [uploadedBy], references: [id])
createdAt DateTime @default(now()) @map("created_at")
@@map("sales_results")
}
model Settlement {
id Int @id @default(autoincrement())
period String @map("period") // YYYY-MM
planId Int @map("plan_id")
plan CompensationPlan @relation(fields: [planId], references: [id])
userId Int @map("user_id")
colaborador User @relation("ColaboradorSettlements", fields: [userId], references: [id])
salesAmount Decimal @map("sales_amount") @db.Decimal(12, 2)
goalAmount Decimal @map("goal_amount") @db.Decimal(12, 2)
achievementPercentage Decimal @map("achievement_percentage") @db.Decimal(5, 4)
calculatedCommission Decimal @map("calculated_commission") @db.Decimal(12, 2)
calculatedBonus Decimal @map("calculated_bonus") @db.Decimal(12, 2)
adjustmentAmount Decimal @map("adjustment_amount") @db.Decimal(12, 2)
totalPayout Decimal @map("total_payout") @db.Decimal(12, 2)
status String @default("SIMULATED") // SIMULATED | PENDING | APPROVED | REJECTED
approvedBy Int? @map("approved_by")
approver User? @relation("ApproverSettlements", fields: [approvedBy], references: [id])
approvedAt DateTime? @map("approved_at")
rejectionReason String? @map("rejection_reason")
originalSettlementId Int? @map("original_settlement_id")
originalSettlement Settlement? @relation("SettlementAdjustments", fields: [originalSettlementId], references: [id])
adjustments Settlement[] @relation("SettlementAdjustments")
adjustmentNotes String? @map("adjustment_notes")
createdAt DateTime @default(now()) @map("created_at")
@@map("settlements")
}
model AuditLog {
id Int @id @default(autoincrement())
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id])
action String // CREATE | UPDATE | DELETE | APPROVE | REJECT | LOGIN
targetTable String @map("target_table")
targetId Int @map("target_id")
previousValue Json? @map("previous_value")
newValue Json? @map("new_value")
ipAddress String? @map("ip_address")
createdAt DateTime @default(now()) @map("created_at")
@@map("audit_logs")
}
model Notification {
id Int @id @default(autoincrement())
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id])
title String
message String
status String @default("UNREAD") // UNREAD | READ
type String // EMAIL | PUSH
sentAt DateTime @default(now()) @map("sent_at")
@@map("notifications")
}

View file

@ -99,13 +99,12 @@ async function runTests() {
}
});
});
// 2. Start Next.js server
console.log(`\nStarting Next.js dev server on port ${PORT}...`);
nextProcess = spawn('npx', ['next', 'dev', '--port', String(PORT)], {
shell: true,
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'dev', '--port', String(PORT)], {
env: { ...process.env, PORT: String(PORT) }
});
nextProcess.stdout.on('data', (data) => console.log(`[Next.js] ${data.toString().trim()}`));
nextProcess.stderr.on('data', (data) => console.error(`[Next.js ERR] ${data.toString().trim()}`));
@ -271,7 +270,7 @@ async function runTests() {
failed++;
}
cleanup();
await cleanup();
console.log(`\n=== TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
if (failed > 0) {

388
prisma/test-phase3-ui.js Normal file
View file

@ -0,0 +1,388 @@
const { spawn } = require('child_process');
const { PrismaClient } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const { Pool } = require('pg');
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
const PORT = 3010;
const BASE_URL = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = path.join(__dirname, 'screenshots');
let nextProcess;
let prisma;
let pool;
let browser;
// Ensure screenshot dir exists
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
function getPrisma() {
if (!prisma) {
const dbUrl = new URL(process.env.DATABASE_URL);
pool = new Pool({
host: dbUrl.hostname,
port: dbUrl.port ? parseInt(dbUrl.port) : 5432,
user: decodeURIComponent(dbUrl.username),
password: decodeURIComponent(dbUrl.password),
database: dbUrl.pathname.substring(1).split('?')[0],
ssl: false
});
const adapter = new PrismaPg(pool);
prisma = new PrismaClient({ adapter });
}
return prisma;
}
async function runAsAdmin(queryFn) {
const db = getPrisma();
return db.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'ADMIN';`);
return queryFn(tx);
});
}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
let passed = 0;
let failed = 0;
function assert(condition, message) {
if (condition) {
console.log(` ✓ PASS: ${message}`);
passed++;
} else {
console.error(` ✗ FAIL: ${message}`);
failed++;
}
}
async function runTests() {
console.log("=== STARTING PHASE 3 E2E PUPPETEER TEST SUITE ===");
// 1. Clean database records first
console.log("Resetting test environment data...");
await runAsAdmin(async (tx) => {
await tx.calculationRule.deleteMany();
await tx.goal.deleteMany();
await tx.compensationPlan.deleteMany();
await tx.salesResult.deleteMany();
await tx.auditLog.deleteMany();
});
// 2. Start Next.js server on port 3010
console.log(`Starting Next.js dev server on port ${PORT}...`);
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'dev', '--port', String(PORT)], {
env: { ...process.env, PORT: String(PORT) }
});
nextProcess.stdout.on('data', (data) => {
// console.log(`[Next.js] ${data.toString().trim()}`);
});
nextProcess.stderr.on('data', (data) => {
console.error(`[Next.js ERR] ${data.toString().trim()}`);
});
// Wait for server to start up
let serverReady = false;
for (let i = 0; i < 15; i++) {
await sleep(2000);
try {
const res = await fetch(`${BASE_URL}/api/auth/me`);
serverReady = true;
break;
} catch (e) {
// Server not ready yet
}
}
if (!serverReady) {
console.error("Error: Next.js dev server failed to start within timeout.");
await cleanup();
process.exit(1);
}
console.log("Next.js dev server is ready!");
// 3. Launch Puppeteer browser
console.log("Launching Puppeteer browser...");
browser = await puppeteer.launch({
headless: 'shell',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
// Helper to set value of React-controlled inputs natively
const setReactInput = async (selector, value) => {
await page.$eval(selector, (el, val) => {
const nativeSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value"
).set;
nativeSetter.call(el, val);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, value);
};
// --- STEP 1: LOGIN FLOW ---
console.log("\n[Step 1] Navigating to login page...");
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '01_login_page.png') });
console.log("Entering admin credentials...");
await page.type('#username', 'admin');
await page.type('#password', 'password123');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '02_login_typed.png') });
console.log("Clicking submit...");
await page.click('button[type="submit"]');
await page.waitForSelector('#btn-create-plan');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '03_dashboard_loaded.png') });
assert(page.url().endsWith('/plans'), "Redirected to /plans after successful login");
// --- STEP 2: CREATE PLAN FLOW ---
console.log("\n[Step 2] Creating a new plan...");
await page.click('#btn-create-plan');
await page.waitForSelector('div[role="dialog"]');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_create_modal_open.png') });
// Fill in form details
await page.type('#plan-name', 'Plan Ventas E2E Puppeteer');
await page.type('#plan-code', 'PLAN-E2E-PUPP');
await setReactInput('#plan-validity-start', '2026-06-01');
await page.select('#plan-type', 'SCALE');
await page.type('#plan-meta-amount', '100000');
await page.type('#plan-max-cap', '20000');
await page.select('#plan-status', 'DRAFT');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '05_create_form_filled.png') });
// Save the plan
await page.click('#btn-save-plan');
await page.waitForSelector('div[role="dialog"]', { hidden: true });
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '06_plan_created_grid.png') });
// Verify plan is in the database
const createdPlan = await runAsAdmin(tx => tx.compensationPlan.findFirst({
where: { code: 'PLAN-E2E-PUPP' }
}));
assert(createdPlan !== null, "Plan 'PLAN-E2E-PUPP' successfully created in database");
assert(createdPlan.status === 'DRAFT', "Plan initially set to status 'DRAFT'");
// --- STEP 3: RULES CONFIG FLOW ---
// Helper to clear input and type new text
const clearAndType = async (selector, text) => {
await page.click(selector, { clickCount: 3 });
await page.keyboard.press('Backspace');
await page.type(selector, text);
};
console.log("\n[Step 3] Configuring rules for the new plan...");
const rulesBtnSelector = `#btn-rules-${createdPlan.id}`;
await page.click(rulesBtnSelector);
await page.waitForSelector('#rule-min-0');
await sleep(1500); // Allow React reconciliation to settle
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '07_rules_config_page.png') });
assert(page.url().includes(`/plans/${createdPlan.id}/rules`), "Successfully navigated to rules page");
// Modify first row
await clearAndType('#rule-min-0', '0.0');
await clearAndType('#rule-max-0', '0.9');
await clearAndType('#rule-rate-0', '0.0');
await clearAndType('#rule-payout-0', '0.0');
// Add a second row
await page.click('#btn-add-rule');
await page.waitForSelector('#rule-min-1');
await sleep(500); // Allow React state to settle
// Fill second row
await clearAndType('#rule-min-1', '0.9');
await clearAndType('#rule-max-1', '1.0');
await clearAndType('#rule-rate-1', '0.025');
await clearAndType('#rule-payout-1', '150.0');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '08_rules_populated.png') });
// Save rules
await page.click('#btn-save-rules');
await page.waitForSelector('#rules-success-msg');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '09_rules_saved_successfully.png') });
// Verify rules exist in database
const rulesInDb = await runAsAdmin(tx => tx.calculationRule.findMany({
where: { planId: createdPlan.id }
}));
assert(rulesInDb.length === 2, "2 calculation rules successfully inserted into the database");
// --- STEP 4: VERSIONING ACTIVATION FLOW ---
console.log("\n[Step 4] Activating and versioning check...");
// Go back to plans
await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' });
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '10_back_to_plans.png') });
// Toggle status to ACTIVE
const activateSelector = `#btn-toggle-status-${createdPlan.id}`;
await page.click(activateSelector);
await sleep(1000); // Wait for toggle update refresh
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '11_plan_activated.png') });
// Verify status is ACTIVE in db
let activePlan = await runAsAdmin(tx => tx.compensationPlan.findUnique({
where: { id: createdPlan.id }
}));
assert(activePlan.status === 'ACTIVE', "Plan status in database updated to 'ACTIVE'");
// Toggle status AGAIN while active to trigger versioning duplicate logic
await page.click(activateSelector);
await sleep(1500); // Wait for clone and page reload
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '12_plan_versioned.png') });
// Verify database contains cloned version
const allVersions = await runAsAdmin(tx => tx.compensationPlan.findMany({
where: { code: 'PLAN-E2E-PUPP' },
orderBy: { version: 'asc' }
}));
assert(allVersions.length === 2, "Cloned version successfully created (2 versions exist)");
assert(allVersions[0].status === 'INACTIVE' && allVersions[0].validityEnd !== null, "Version 1 is now INACTIVE with validity_end set");
assert(allVersions[1].status === 'ACTIVE' && allVersions[1].version === 2, "Version 2 is now ACTIVE with version = 2");
// Verify rules cloned for version 2
const clonedRules = await runAsAdmin(tx => tx.calculationRule.findMany({
where: { planId: allVersions[1].id }
}));
assert(clonedRules.length === 2, "Calculation rules cloned to Version 2 successfully");
// --- STEP 5: GOAL ASSIGNMENT FLOW ---
console.log("\n[Step 5] Assigning commercial goal...");
await page.goto(`${BASE_URL}/goals`, { waitUntil: 'networkidle2' });
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '13_goals_page.png') });
// Select colaborador_mde
const colaboradorMde = await runAsAdmin(tx => tx.user.findUnique({
where: { username: 'colaborador_mde' }
}));
await page.select('#goal-target-type', 'INDIVIDUAL');
await page.select('#goal-target-id', colaboradorMde.id.toString());
await setReactInput('#goal-period', '2026-06');
await page.type('#goal-amount', '75000');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '14_goal_form_filled.png') });
await page.click('#btn-save-goal');
// Wait for success alert
await page.waitForSelector('#goal-success-msg');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '15_goal_saved.png') });
// Check db for goal
const savedGoal = await runAsAdmin(tx => tx.goal.findFirst({
where: {
targetType: 'INDIVIDUAL',
targetId: colaboradorMde.id,
period: '2026-06'
}
}));
assert(savedGoal !== null, "Goal record successfully created in the database");
assert(parseFloat(savedGoal.amount) === 75000.00, "Goal amount is correctly set to 75,000.00");
console.log("\n[Step 6] Verifying RLS boundaries on Goals page...");
// Log out admin
await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' });
await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const logoutBtn = buttons.find(b => b.textContent.includes('Cerrar Sesión'));
if (logoutBtn) logoutBtn.click();
});
await sleep(1500);
// Log in as Colaborador
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
await page.type('#username', 'colaborador_mde');
await page.type('#password', 'password123');
await page.click('button[type="submit"]');
await page.waitForSelector('#btn-create-plan');
// Navigate directly to /goals and verify list shows only their goal
await page.goto(`${BASE_URL}/goals`, { waitUntil: 'networkidle2' });
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '16_goals_colaborador_rls.png') });
// Evaluate list rows shown to the collaborator
const rowCount = await page.evaluate(() => {
// Count all table rows in tbody
return document.querySelectorAll('tbody tr').length;
});
assert(rowCount === 1, "RLS restriction verified: Colaborador only sees 1 goal (their own) in the goals dashboard");
await cleanup();
console.log(`\n=== E2E TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
if (failed > 0) {
process.exit(1);
} else {
process.exit(0);
}
}
async function cleanup() {
console.log("\nCleaning up E2E test browser and server processes...");
try {
if (browser) {
await browser.close();
}
} catch (e) {}
try {
if (prisma) {
await runAsAdmin(async (tx) => {
await tx.calculationRule.deleteMany();
await tx.goal.deleteMany();
await tx.compensationPlan.deleteMany();
await tx.salesResult.deleteMany();
await tx.auditLog.deleteMany();
});
await prisma.$disconnect();
}
if (pool) {
await pool.end();
}
} catch (e) {
console.error("Error during DB cleanup:", e);
}
if (nextProcess) {
nextProcess.kill();
}
}
process.on('SIGINT', () => {
cleanup();
process.exit(1);
});
runTests().catch(async err => {
console.error("Fatal E2E test runner error:", err);
if (browser) {
try {
const pages = await browser.pages();
if (pages.length > 0) {
await pages[0].screenshot({ path: path.join(SCREENSHOT_DIR, 'error_screenshot.png') });
console.log("Saved error_screenshot.png to", path.join(SCREENSHOT_DIR, 'error_screenshot.png'));
}
} catch (screenshotErr) {
console.error("Failed to capture error screenshot:", screenshotErr);
}
}
await cleanup();
process.exit(1);
});

1
public/file.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 1 KiB

1
public/next.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 385 B

View file

@ -23,12 +23,16 @@ export async function POST(req: NextRequest) {
regionId: 0
});
console.log('[Login API] DATABASE_URL in Next.js:', process.env.DATABASE_URL);
console.log('[Login API] Attempting login for username:', username);
const user = await prismaAdmin.user.findUnique({
where: { username },
include: { hotel: true }
});
console.log('[Login API] User found in DB:', user ? { id: user.id, username: user.username, role: user.role, status: user.status } : null);
if (!user || user.status !== 'ACTIVE') {
console.log('[Login API] Login failed: User not found or inactive');
return NextResponse.json(
{ error: 'Invalid credentials or inactive user' },
{ status: 401 }
@ -36,7 +40,9 @@ export async function POST(req: NextRequest) {
}
const passwordMatch = await comparePassword(password, user.passwordHash);
console.log('[Login API] Password match result:', passwordMatch);
if (!passwordMatch) {
console.log('[Login API] Login failed: Password mismatch');
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }

View file

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
// GET: Fetch goals (accessible to all authenticated roles)
export const GET = withAuth(async (req, { prisma }) => {
const { searchParams } = req.nextUrl;
const targetType = searchParams.get('targetType');
const period = searchParams.get('period');
const whereClause: any = {};
if (targetType) whereClause.targetType = targetType;
if (period) whereClause.period = period;
const goals = await prisma.goal.findMany({
where: whereClause,
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ goals });
});
// POST: Create or Update goal for a period (restricted to ADMIN and DIRECTOR)
export const POST = withAuth(async (req, { prisma }) => {
const body = await req.json();
const { targetType, targetId, period, amount } = body;
if (!targetType || targetId === undefined || !period || amount === undefined) {
return NextResponse.json(
{ error: 'targetType, targetId, period, and amount are required fields' },
{ status: 400 }
);
}
// Upsert goal for target + period
const existingGoal = await prisma.goal.findFirst({
where: {
targetType,
targetId: parseInt(targetId),
period
}
});
let goal;
if (existingGoal) {
goal = await prisma.goal.update({
where: { id: existingGoal.id },
data: { amount: parseFloat(amount) }
});
} else {
goal = await prisma.goal.create({
data: {
targetType,
targetId: parseInt(targetId),
period,
amount: parseFloat(amount)
}
});
}
return NextResponse.json({ goal });
}, ['ADMIN', 'DIRECTOR']);

View file

@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
// GET: Fetch a single plan by ID
export const GET = withAuth(async (req, { prisma, params }) => {
const unwrappedParams = await params;
const id = parseInt(unwrappedParams.id);
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const plan = await prisma.compensationPlan.findUnique({
where: { id },
include: { rules: true }
});
if (!plan) {
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
}
return NextResponse.json({ plan });
});
// PUT: Update or Version a plan (restricted to ADMIN and DIRECTOR)
export const PUT = withAuth(async (req, { session, prisma, params }) => {
const unwrappedParams = await params;
const id = parseInt(unwrappedParams.id);
const body = await req.json();
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const plan = await prisma.compensationPlan.findUnique({
where: { id }
});
if (!plan) {
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
}
// Versioning replication if the plan is currently ACTIVE
if (plan.status === 'ACTIVE') {
const newPlan = await prisma.$transaction(async (tx: any) => {
// Set RLS variables directly on the transaction client context
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${session.role}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${session.hotelId}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${session.regionId}';`);
// 1. Mark current plan version as INACTIVE
await tx.compensationPlan.update({
where: { id },
data: {
status: 'INACTIVE',
validityEnd: new Date()
}
});
// 2. Clone to a new version record
const clonedPlan = await tx.compensationPlan.create({
data: {
name: body.name || plan.name,
code: body.code || plan.code,
validityStart: new Date(), // starts now
validityEnd: body.validityEnd ? new Date(body.validityEnd) : null,
type: body.type || plan.type,
formula: body.formula !== undefined ? body.formula : plan.formula,
metaAmount: body.metaAmount !== undefined ? parseFloat(body.metaAmount) : plan.metaAmount,
percentageRate: body.percentageRate !== undefined ? parseFloat(body.percentageRate) : plan.percentageRate,
maxCap: body.maxCap !== undefined ? parseFloat(body.maxCap) : plan.maxCap,
status: 'ACTIVE',
version: plan.version + 1,
createdBy: session.userId
}
});
// 3. Clone rules associated with the original plan
const originalRules = await tx.calculationRule.findMany({
where: { planId: id }
});
if (originalRules.length > 0) {
await tx.calculationRule.createMany({
data: originalRules.map((r: any) => ({
planId: clonedPlan.id,
type: r.type,
minAchievement: r.minAchievement,
maxAchievement: r.maxAchievement,
rate: r.rate,
payoutAmount: r.payoutAmount
}))
});
}
return clonedPlan;
});
return NextResponse.json({ plan: newPlan, versioned: true });
} else {
// In-place update for DRAFT / INACTIVE plans
const updatedPlan = await prisma.compensationPlan.update({
where: { id },
data: {
name: body.name,
code: body.code,
validityStart: body.validityStart ? new Date(body.validityStart) : undefined,
validityEnd: body.validityEnd !== undefined ? (body.validityEnd ? new Date(body.validityEnd) : null) : undefined,
type: body.type,
formula: body.formula,
metaAmount: body.metaAmount !== undefined ? (body.metaAmount ? parseFloat(body.metaAmount) : null) : undefined,
percentageRate: body.percentageRate !== undefined ? (body.percentageRate ? parseFloat(body.percentageRate) : null) : undefined,
maxCap: body.maxCap !== undefined ? (body.maxCap ? parseFloat(body.maxCap) : null) : undefined,
status: body.status
}
});
return NextResponse.json({ plan: updatedPlan, versioned: false });
}
}, ['ADMIN', 'DIRECTOR']);

View file

@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
// POST: Configure calculation rules (restricted to ADMIN and DIRECTOR)
export const POST = withAuth(async (req, { session, prisma, params }) => {
const unwrappedParams = await params;
const planId = parseInt(unwrappedParams.id);
const body = await req.json(); // Expected: { rules: [{ type: 'TIER', minAchievement: 0.9, maxAchievement: 1.0, rate: 0.015, payoutAmount: 0 }, ...] }
if (isNaN(planId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const plan = await prisma.compensationPlan.findUnique({
where: { id: planId }
});
if (!plan) {
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
}
// Bulk overwrite rules inside a transaction
const rules = await prisma.$transaction(async (tx: any) => {
// Set RLS variables directly on the transaction client context
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${session.role}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${session.hotelId}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${session.regionId}';`);
// 1. Delete existing rules
await tx.calculationRule.deleteMany({
where: { planId }
});
// 2. Insert new ones if provided
if (body.rules && Array.isArray(body.rules)) {
// Validate boundaries and contiguous nature if needed
for (const rule of body.rules) {
if (!rule.type || rule.minAchievement === undefined || rule.maxAchievement === undefined) {
throw new Error('Invalid calculation rule parameters');
}
}
await tx.calculationRule.createMany({
data: body.rules.map((r: any) => ({
planId,
type: r.type,
minAchievement: parseFloat(r.minAchievement),
maxAchievement: parseFloat(r.maxAchievement),
rate: r.rate !== undefined ? parseFloat(r.rate) : 0.0,
payoutAmount: r.payoutAmount !== undefined ? parseFloat(r.payoutAmount) : 0.0
}))
});
}
return tx.calculationRule.findMany({
where: { planId }
});
});
return NextResponse.json({ rules });
}, ['ADMIN', 'DIRECTOR']);

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
// GET: Fetch all plans (accessible to all authenticated roles)
export const GET = withAuth(async (req, { prisma }) => {
const { searchParams } = req.nextUrl;
const status = searchParams.get('status');
const code = searchParams.get('code');
const whereClause: any = {};
if (status) whereClause.status = status;
if (code) whereClause.code = code;
const plans = await prisma.compensationPlan.findMany({
where: whereClause,
include: {
rules: true
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ plans });
});
// POST: Create a new plan (restricted to ADMIN and DIRECTOR)
export const POST = withAuth(async (req, { session, prisma }) => {
const body = await req.json();
const { name, code, validityStart, validityEnd, type, formula, metaAmount, percentageRate, maxCap, status } = body;
// Validate mandatory fields
if (!name || !code || !validityStart || !type) {
return NextResponse.json(
{ error: 'Name, code, validityStart, and type are required mandatory fields' },
{ status: 400 }
);
}
const plan = await prisma.compensationPlan.create({
data: {
name,
code,
validityStart: new Date(validityStart),
validityEnd: validityEnd ? new Date(validityEnd) : null,
type,
formula: formula || null,
metaAmount: metaAmount !== undefined ? parseFloat(metaAmount) : null,
percentageRate: percentageRate !== undefined ? parseFloat(percentageRate) : null,
maxCap: maxCap !== undefined ? parseFloat(maxCap) : null,
status: status || 'DRAFT',
version: 1,
createdBy: session.userId
}
});
return NextResponse.json({ plan }, { status: 201 });
}, ['ADMIN', 'DIRECTOR']);

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
// GET: Fetch all users in user's scope (restricted to ADMIN and DIRECTOR)
export const GET = withAuth(async (req, { prisma }) => {
try {
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
role: true,
hotelId: true,
area: true,
status: true,
hotel: {
select: {
name: true,
code: true,
}
}
},
orderBy: { username: 'asc' }
});
return NextResponse.json({ users });
} catch (err: any) {
console.error('Failed to fetch users:', err);
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
}
}, ['ADMIN', 'DIRECTOR']);

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,240 @@
.container {
min-height: 100vh;
background: radial-gradient(circle at top right, hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.08), transparent 45%),
var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-8);
background-color: var(--card);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
}
.logoArea {
display: flex;
align-items: center;
gap: var(--space-3);
}
.logoText {
font-size: var(--text-lg);
font-weight: var(--weight-bold);
color: var(--foreground);
}
.nav {
display: flex;
gap: var(--space-6);
}
.navLink {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--foreground);
opacity: 0.7;
text-decoration: none;
transition: opacity var(--transition-fast);
}
.navLink:hover {
opacity: 1;
}
.navLinkActive {
opacity: 1;
border-bottom: 2px solid var(--primary);
padding-bottom: 2px;
}
.logoutBtn {
padding: var(--space-2) var(--space-4);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
background-color: transparent;
color: var(--foreground);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.logoutBtn:hover {
background-color: var(--border);
}
.main {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: grid;
grid-template-columns: 1fr 2fr;
gap: var(--space-8);
}
@media (max-width: 768px) {
.main {
grid-template-columns: 1fr;
}
}
.titleSection {
grid-column: 1 / -1;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
}
.title {
font-size: var(--text-3xl);
font-weight: var(--weight-bold);
letter-spacing: -0.02em;
}
.section {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--space-4);
height: fit-content;
}
.sectionTitle {
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
margin-bottom: var(--space-2);
border-bottom: 1px solid var(--border);
padding-bottom: var(--space-2);
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.formGroup {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
opacity: 0.8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.input {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--foreground);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--transition-fast);
}
.input:focus {
border-color: var(--primary);
}
.btnPrimary {
padding: var(--space-2) var(--space-6);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: #ffffff;
background: linear-gradient(135deg, var(--primary), var(--secondary));
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: box-shadow var(--transition-fast);
text-align: center;
}
.btnPrimary:hover {
box-shadow: var(--shadow-sm), var(--shadow-glow);
}
.errorMsg {
color: hsl(0, 85%, 60%);
background-color: hsl(0, 85%, 97%);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.successMsg {
color: hsl(120, 80%, 30%);
background-color: hsl(120, 80%, 95%);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.tableContainer {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.th {
text-align: left;
padding: var(--space-3);
font-weight: var(--weight-semibold);
border-bottom: 2px solid var(--border);
opacity: 0.8;
}
.td {
padding: var(--space-3);
border-bottom: 1px solid var(--border);
}
.badge {
display: inline-block;
padding: var(--space-1) var(--space-2);
font-size: var(--text-xxs);
font-weight: var(--weight-bold);
border-radius: var(--radius-full);
text-transform: uppercase;
}
.badgeINDIVIDUAL {
background-color: hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
color: var(--primary);
}
.badgeTEAM {
background-color: rgba(234, 179, 8, 0.15);
color: rgb(202, 138, 4);
}
.badgeHOTEL {
background-color: rgba(34, 197, 94, 0.15);
color: rgb(22, 163, 74);
}
.emptyState {
text-align: center;
padding: var(--space-10);
opacity: 0.5;
font-style: italic;
}

293
src/app/goals/page.tsx Normal file
View file

@ -0,0 +1,293 @@
'use strict';
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import styles from './page.module.css';
interface User {
id: number;
username: string;
email: string;
role: string;
area: string;
hotel?: {
name: string;
code: string;
};
}
interface Goal {
id: number;
targetType: string;
targetId: number;
period: string;
amount: string; // Decimal comes as string from database
createdAt: string;
}
export default function GoalsPage() {
const router = useRouter();
const [users, setUsers] = useState<User[]>([]);
const [goals, setGoals] = useState<Goal[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Form State
const [targetType, setTargetType] = useState('INDIVIDUAL');
const [targetId, setTargetId] = useState('');
const [period, setPeriod] = useState('');
const [amount, setAmount] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const fetchUsersAndGoals = async () => {
try {
const [usersRes, goalsRes] = await Promise.all([
fetch('/api/users'),
fetch('/api/goals')
]);
if (usersRes.ok) {
const usersData = await usersRes.json();
setUsers(usersData.users || []);
if (usersData.users && usersData.users.length > 0) {
setTargetId(usersData.users[0].id.toString());
}
}
if (goalsRes.ok) {
const goalsData = await goalsRes.json();
setGoals(goalsData.goals || []);
}
} catch (err) {
console.error('Failed to load goals or users:', err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchUsersAndGoals();
}, []);
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
};
const handleAssignGoal = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(null);
if (!targetType || !targetId || !period || !amount) {
setError('Todos los campos son obligatorios.');
return;
}
const numericAmount = parseFloat(amount);
if (isNaN(numericAmount) || numericAmount <= 0) {
setError('El monto debe ser un número positivo.');
return;
}
try {
const res = await fetch('/api/goals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetType,
targetId: parseInt(targetId),
period,
amount: numericAmount
})
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Error al asignar la meta.');
return;
}
setSuccess('Meta comercial asignada / actualizada exitosamente.');
setAmount('');
// Reload goals list
const goalsRes = await fetch('/api/goals');
if (goalsRes.ok) {
const goalsData = await goalsRes.json();
setGoals(goalsData.goals || []);
}
} catch (err) {
setError('Ocurrió un error al guardar la meta.');
}
};
const getTargetLabel = (type: string, id: number) => {
if (type === 'INDIVIDUAL') {
const user = users.find(u => u.id === id);
if (user) {
return `${user.username} (${user.role} - ${user.hotel?.name || ''})`;
}
}
return `${type} ID: ${id}`;
};
return (
<div className={styles.container}>
{/* Shared Dashboard Header */}
<header className={styles.header}>
<div className={styles.logoArea}>
<span className={styles.logoText}>Remuneración Estelar</span>
</div>
<nav className={styles.nav}>
<Link href="/plans" className={styles.navLink}>
Planes de Comisión
</Link>
<Link href="/goals" className={`${styles.navLink} ${styles.navLinkActive}`}>
Metas Comerciales
</Link>
</nav>
<button onClick={handleLogout} className={styles.logoutBtn}>
Cerrar Sesión
</button>
</header>
<main className={styles.main}>
<div className={styles.titleSection}>
<h1 className={styles.title}>Metas Comerciales</h1>
</div>
{/* Goal Assignment Form Section */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Asignar Meta</h2>
{error && <div className={styles.errorMsg} id="goal-error-msg">{error}</div>}
{success && <div className={styles.successMsg} id="goal-success-msg">{success}</div>}
<form onSubmit={handleAssignGoal} className={styles.form}>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="goal-target-type">Tipo de Meta</label>
<select
id="goal-target-type"
className={styles.input}
value={targetType}
onChange={(e) => setTargetType(e.target.value)}
>
<option value="INDIVIDUAL">Colaborador Individual</option>
<option value="TEAM">Equipo</option>
<option value="HOTEL">Hotel</option>
</select>
</div>
{targetType === 'INDIVIDUAL' && (
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="goal-target-id">Seleccionar Colaborador</label>
<select
id="goal-target-id"
className={styles.input}
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
required
>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.role} - {u.hotel?.code})
</option>
))}
</select>
</div>
)}
{targetType !== 'INDIVIDUAL' && (
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="goal-target-id-input">ID del Objetivo (Hotel/Equipo)</label>
<input
id="goal-target-id-input"
type="number"
className={styles.input}
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
placeholder="Ej. ID de Hotel o Equipo"
required
/>
</div>
)}
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="goal-period">Período (Mes/Año)</label>
<input
id="goal-period"
type="month"
className={styles.input}
value={period}
onChange={(e) => setPeriod(e.target.value)}
required
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="goal-amount">Monto Quota ($)</label>
<input
id="goal-amount"
type="number"
step="0.01"
className={styles.input}
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Ej. 50000.00"
required
/>
</div>
<button type="submit" className={styles.btnPrimary} id="btn-save-goal">
Asignar Meta
</button>
</form>
</section>
{/* Configured Goals List Section */}
<section className={styles.section} style={{ gridColumn: 'span 1' }}>
<h2 className={styles.sectionTitle}>Historial de Metas</h2>
{isLoading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>Cargando metas...</div>
) : (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Objetivo</th>
<th className={styles.th}>Período</th>
<th className={styles.th}>Tipo</th>
<th className={styles.th}>Cuota ($)</th>
</tr>
</thead>
<tbody>
{goals.length === 0 ? (
<tr>
<td colSpan={4} className={styles.emptyState}>No hay metas configuradas.</td>
</tr>
) : (
goals.map((g) => (
<tr key={g.id} data-goal-row-id={g.id}>
<td className={styles.td}>{getTargetLabel(g.targetType, g.targetId)}</td>
<td className={styles.td}>{g.period}</td>
<td className={styles.td}>
<span className={`${styles.badge} ${styles['badge' + g.targetType]}`}>
{g.targetType}
</span>
</td>
<td className={styles.td} style={{ fontWeight: 'bold' }}>
${parseFloat(g.amount).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</section>
</main>
</div>
);
}

30
src/app/layout.tsx Normal file
View file

@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
<body>{children}</body>
</html>
);
}

View file

@ -1,11 +1,11 @@
'use strict';
'use client';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import styles from './page.module.css';
export default function LoginPage() {
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [username, setUsername] = useState('');
@ -116,3 +116,11 @@ export default function LoginPage() {
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<div style={{ color: 'white', textAlign: 'center', marginTop: '50px' }}>Cargando formulario...</div>}>
<LoginForm />
</Suspense>
);
}

142
src/app/page.module.css Normal file
View file

@ -0,0 +1,142 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
flex: 1;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

5
src/app/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/plans');
}

View file

@ -0,0 +1,191 @@
.container {
min-height: 100vh;
background: radial-gradient(circle at top right, hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.08), transparent 45%),
var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-8);
background-color: var(--card);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
}
.logoArea {
display: flex;
align-items: center;
gap: var(--space-3);
}
.logoText {
font-size: var(--text-lg);
font-weight: var(--weight-bold);
color: var(--foreground);
}
.nav {
display: flex;
gap: var(--space-6);
}
.navLink {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--foreground);
opacity: 0.7;
}
.main {
max-width: 900px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
}
.titleArea {
margin-bottom: var(--space-8);
}
.backLink {
font-size: var(--text-sm);
color: var(--primary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.title {
font-size: var(--text-3xl);
font-weight: var(--weight-bold);
letter-spacing: -0.02em;
}
.subtitle {
font-size: var(--text-base);
opacity: 0.7;
margin-top: var(--space-1);
}
.section {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-8);
box-shadow: var(--shadow-sm);
}
.tableHeader {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr 1fr 1.2fr 0.5fr;
gap: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border);
font-weight: var(--weight-semibold);
font-size: var(--text-sm);
}
.rows {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) 0;
}
.row {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr 1fr 1.2fr 0.5fr;
gap: var(--space-3);
align-items: center;
}
.input {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--foreground);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--transition-fast);
}
.input:focus {
border-color: var(--primary);
}
.btnDelete {
background: none;
border: none;
font-size: var(--text-base);
color: hsl(0, 75%, 60%);
cursor: pointer;
opacity: 0.8;
padding: var(--space-1);
}
.btnDelete:hover {
opacity: 1;
}
.btnSecondary {
padding: var(--space-2) var(--space-4);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
background-color: transparent;
color: var(--foreground);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
margin-top: var(--space-4);
}
.btnSecondary:hover {
background-color: var(--border);
}
.footerActions {
display: flex;
justify-content: flex-end;
gap: var(--space-4);
margin-top: var(--space-8);
border-top: 1px solid var(--border);
padding-top: var(--space-6);
}
.btnPrimary {
padding: var(--space-2) var(--space-6);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: #ffffff;
background: linear-gradient(135deg, var(--primary), var(--secondary));
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.btnPrimary:hover {
box-shadow: var(--shadow-sm), var(--shadow-glow);
}
.successMsg {
color: hsl(120, 80%, 30%);
background-color: hsl(120, 80%, 95%);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
}
.errorMsg {
color: hsl(0, 85%, 60%);
background-color: hsl(0, 85%, 97%);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
}

View file

@ -0,0 +1,324 @@
'use strict';
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import styles from './page.module.css';
interface Rule {
id?: number;
tempId?: string;
type: 'TIER' | 'BONUS';
minAchievement: number;
maxAchievement: number;
rate: number;
payoutAmount: number;
}
interface Plan {
id: number;
name: string;
code: string;
type: string;
status: string;
version: number;
rules: Rule[];
}
export default function RulesPage() {
const router = useRouter();
const params = useParams();
const planId = params?.id ? parseInt(params.id as string) : null;
const [plan, setPlan] = useState<Plan | null>(null);
const [rules, setRules] = useState<Rule[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const fetchPlan = async () => {
if (!planId) return;
try {
const res = await fetch(`/api/plans/${planId}`);
if (res.ok) {
const data = await res.json();
setPlan(data.plan);
if (data.plan.rules && data.plan.rules.length > 0) {
// Format rules from server (Decimal fields come as strings/numbers)
const formatted = data.plan.rules.map((r: any) => ({
id: r.id,
tempId: Math.random().toString(36).substr(2, 9),
type: r.type as 'TIER' | 'BONUS',
minAchievement: parseFloat(r.minAchievement),
maxAchievement: parseFloat(r.maxAchievement),
rate: parseFloat(r.rate),
payoutAmount: parseFloat(r.payoutAmount)
}));
setRules(formatted);
} else {
// Default initial empty row
setRules([
{
tempId: Math.random().toString(36).substr(2, 9),
type: 'TIER',
minAchievement: 0.0,
maxAchievement: 1.0,
rate: 0.0,
payoutAmount: 0.0
}
]);
}
} else {
setError('No se pudo encontrar el plan.');
}
} catch (err) {
setError('Error al cargar la información del plan.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchPlan();
}, [planId]);
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
};
const handleAddRow = () => {
// Generate a default values row, potentially continuing from previous max
const lastRule = rules[rules.length - 1];
const newMin = lastRule ? lastRule.maxAchievement : 0.0;
setRules([
...rules,
{
tempId: Math.random().toString(36).substr(2, 9),
type: 'TIER',
minAchievement: newMin,
maxAchievement: newMin + 0.1 > 1.0 ? 1.0 : newMin + 0.1,
rate: 0.0,
payoutAmount: 0.0
}
]);
};
const handleDeleteRow = (tempId: string) => {
if (rules.length === 1) {
setError('El plan debe tener al menos una regla.');
return;
}
setRules(rules.filter(r => r.tempId !== tempId));
};
const handleUpdateRow = (tempId: string, field: keyof Rule, value: any) => {
setError(null);
setSuccess(null);
setRules(
rules.map(r => {
if (r.tempId === tempId) {
let parsedValue = value;
if (field === 'minAchievement' || field === 'maxAchievement' || field === 'rate') {
parsedValue = parseFloat(value) || 0.0;
} else if (field === 'payoutAmount') {
parsedValue = parseFloat(value) || 0.0;
}
return { ...r, [field]: parsedValue };
}
return r;
})
);
};
const handleSaveRules = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(null);
// Validate boundaries
for (let i = 0; i < rules.length; i++) {
const r = rules[i];
if (r.minAchievement >= r.maxAchievement) {
setError(`Fila ${i + 1}: El logro mínimo (${r.minAchievement}) debe ser menor que el logro máximo (${r.maxAchievement}).`);
return;
}
if (r.minAchievement < 0 || r.maxAchievement < 0 || r.rate < 0 || r.payoutAmount < 0) {
setError(`Fila ${i + 1}: Todos los valores deben ser mayores o iguales a cero.`);
return;
}
}
try {
const res = await fetch(`/api/plans/${planId}/rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rules: rules.map(r => ({
type: r.type,
minAchievement: r.minAchievement,
maxAchievement: r.maxAchievement,
rate: r.rate,
payoutAmount: r.payoutAmount
}))
})
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Error al guardar las reglas.');
return;
}
setSuccess('Reglas configuradas y guardadas exitosamente.');
// Refresh current plan
fetchPlan();
} catch (err) {
setError('Ocurrió un error de red al guardar las reglas.');
}
};
if (isLoading) {
return (
<div className={styles.container}>
<div style={{ textAlign: 'center', padding: '100px' }}>Cargando configuración...</div>
</div>
);
}
return (
<div className={styles.container}>
{/* Shared Dashboard Header */}
<header className={styles.header}>
<div className={styles.logoArea}>
<span className={styles.logoText}>Remuneración Estelar</span>
</div>
<nav className={styles.nav}>
<Link href="/plans" className={`${styles.navLink} ${styles.navLinkActive}`}>
Planes de Comisión
</Link>
<Link href="/goals" className={styles.navLink}>
Metas Comerciales
</Link>
</nav>
<button onClick={handleLogout} className={styles.btnSecondary} style={{ margin: 0 }}>
Cerrar Sesión
</button>
</header>
<main className={styles.main}>
<div className={styles.titleArea}>
<Link href="/plans" className={styles.backLink}>
&larr; Volver a Planes
</Link>
<h1 className={styles.title}>Configurar Reglas de Comisión</h1>
<p className={styles.subtitle}>
Plan: <strong>{plan?.name}</strong> | Código: {plan?.code} | Versión: {plan?.version} | Estado: {plan?.status}
</p>
</div>
{error && <div className={styles.errorMsg} id="rules-error-msg">{error}</div>}
{success && <div className={styles.successMsg} id="rules-success-msg">{success}</div>}
<div className={styles.section}>
<form onSubmit={handleSaveRules}>
<div className={styles.tableHeader}>
<span>Tipo de Regla</span>
<span>Min Logro (%)</span>
<span>Max Logro (%)</span>
<span>Tasa Comisión (Decimal)</span>
<span>Payout Fijo ($)</span>
<span></span>
</div>
<div className={styles.rows}>
{rules.map((rule, idx) => (
<div key={rule.tempId || idx} className={styles.row} data-rule-row={idx}>
<select
className={styles.input}
value={rule.type}
onChange={(e) => handleUpdateRow(rule.tempId!, 'type', e.target.value)}
id={`rule-type-${idx}`}
>
<option value="TIER">Rango (TIER)</option>
<option value="BONUS">Bono Fijo (BONUS)</option>
</select>
<input
type="number"
step="0.0001"
className={styles.input}
value={rule.minAchievement}
onChange={(e) => handleUpdateRow(rule.tempId!, 'minAchievement', e.target.value)}
id={`rule-min-${idx}`}
required
/>
<input
type="number"
step="0.0001"
className={styles.input}
value={rule.maxAchievement}
onChange={(e) => handleUpdateRow(rule.tempId!, 'maxAchievement', e.target.value)}
id={`rule-max-${idx}`}
required
/>
<input
type="number"
step="0.0001"
className={styles.input}
value={rule.rate}
onChange={(e) => handleUpdateRow(rule.tempId!, 'rate', e.target.value)}
id={`rule-rate-${idx}`}
required
/>
<input
type="number"
step="0.01"
className={styles.input}
value={rule.payoutAmount}
onChange={(e) => handleUpdateRow(rule.tempId!, 'payoutAmount', e.target.value)}
id={`rule-payout-${idx}`}
required
/>
<button
type="button"
onClick={() => handleDeleteRow(rule.tempId!)}
className={styles.btnDelete}
id={`btn-delete-rule-${idx}`}
title="Eliminar regla"
>
&times;
</button>
</div>
))}
</div>
<button
type="button"
onClick={handleAddRow}
className={styles.btnSecondary}
id="btn-add-rule"
>
+ Agregar Rango / Regla
</button>
<div className={styles.footerActions}>
<Link href="/plans" className={styles.btnSecondary} style={{ marginRight: 'auto' }}>
Cancelar
</Link>
<button type="submit" className={styles.btnPrimary} id="btn-save-rules">
Guardar Reglas
</button>
</div>
</form>
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,318 @@
.container {
min-height: 100vh;
background: radial-gradient(circle at top right, hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.08), transparent 45%),
var(--background);
color: var(--foreground);
font-family: var(--font-sans);
transition: background var(--transition-slow);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-8);
background-color: var(--card);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
.logoArea {
display: flex;
align-items: center;
gap: var(--space-3);
}
.logoText {
font-size: var(--text-lg);
font-weight: var(--weight-bold);
color: var(--foreground);
letter-spacing: -0.02em;
}
.nav {
display: flex;
gap: var(--space-6);
}
.navLink {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--foreground);
opacity: 0.7;
transition: opacity var(--transition-fast);
cursor: pointer;
}
.navLinkActive {
opacity: 1;
color: var(--primary);
font-weight: var(--weight-semibold);
}
.logoutBtn {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: hsl(0, 75%, 60%);
background: none;
border: none;
cursor: pointer;
opacity: 0.8;
transition: opacity var(--transition-fast);
}
.logoutBtn:hover {
opacity: 1;
}
.main {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
}
.titleSection {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-8);
}
.title {
font-size: var(--text-3xl);
font-weight: var(--weight-bold);
letter-spacing: -0.02em;
}
.actions {
display: flex;
gap: var(--space-4);
}
.btnPrimary {
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: #ffffff;
background: linear-gradient(135deg, var(--primary), var(--secondary));
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.btnPrimary:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md), var(--shadow-glow);
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
.card {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
transition: transform var(--transition-normal), box-shadow var(--transition-normal);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md), var(--shadow-glow);
}
.cardHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.cardCode {
font-size: var(--text-xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
color: var(--primary);
background-color: hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
.badge {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
.badgeDRAFT {
background-color: hsl(40, 90%, 93%);
color: hsl(40, 90%, 35%);
}
.badgeACTIVE {
background-color: hsl(120, 80%, 93%);
color: hsl(120, 80%, 30%);
}
.badgeINACTIVE {
background-color: hsl(0, 0%, 90%);
color: hsl(0, 0%, 40%);
}
.cardTitle {
font-size: var(--text-xl);
font-weight: var(--weight-bold);
margin-bottom: var(--space-2);
}
.cardMeta {
display: flex;
flex-direction: column;
gap: var(--space-2);
font-size: var(--text-sm);
opacity: 0.8;
margin-bottom: var(--space-6);
}
.metaItem {
display: flex;
justify-content: space-between;
}
.metaValue {
font-weight: var(--weight-semibold);
}
.cardFooter {
display: flex;
gap: var(--space-2);
margin-top: auto;
}
.btnSecondary {
flex: 1;
padding: var(--space-2);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
background-color: transparent;
color: var(--foreground);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color var(--transition-fast);
text-align: center;
}
.btnSecondary:hover {
background-color: var(--border);
}
/* Modal and Form styling */
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn var(--transition-fast) forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
width: 100%;
max-width: 500px;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-8);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: var(--space-6);
max-height: 90vh;
overflow-y: auto;
}
.modalTitle {
font-size: var(--text-2xl);
font-weight: var(--weight-bold);
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.formGroup {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.label {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
}
.input {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--foreground);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--transition-fast);
}
.input:focus {
border-color: var(--primary);
}
.errorText {
font-size: var(--text-xs);
color: hsl(0, 75%, 60%);
}
.formActions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
margin-top: var(--space-4);
}

333
src/app/plans/page.tsx Normal file
View file

@ -0,0 +1,333 @@
'use strict';
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import styles from './page.module.css';
interface Plan {
id: number;
name: string;
code: string;
validityStart: string;
validityEnd: string | null;
type: string;
formula: string | null;
metaAmount: number | null;
percentageRate: number | null;
maxCap: number | null;
status: string;
version: number;
rules: any[];
}
export default function PlansPage() {
const router = useRouter();
const [plans, setPlans] = useState<Plan[]>([]);
const [showModal, setShowModal] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Form State
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [validityStart, setValidityStart] = useState('');
const [type, setType] = useState('PERCENTAGE');
const [metaAmount, setMetaAmount] = useState('');
const [percentageRate, setPercentageRate] = useState('');
const [maxCap, setMaxCap] = useState('');
const [status, setStatus] = useState('DRAFT');
const [error, setError] = useState<string | null>(null);
const fetchPlans = async () => {
try {
const res = await fetch('/api/plans');
if (res.ok) {
const data = await res.json();
setPlans(data.plans || []);
}
} catch (err) {
console.error('Failed to fetch plans', err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchPlans();
}, []);
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
};
const handleCreatePlan = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name || !code || !validityStart || !type) {
setError('Name, code, validityStart, and type are required fields');
return;
}
try {
const res = await fetch('/api/plans', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
code,
validityStart,
type,
metaAmount: metaAmount ? parseFloat(metaAmount) : undefined,
percentageRate: percentageRate ? parseFloat(percentageRate) : undefined,
maxCap: maxCap ? parseFloat(maxCap) : undefined,
status
})
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to create plan');
return;
}
// Reset form & reload
setName('');
setCode('');
setValidityStart('');
setType('PERCENTAGE');
setMetaAmount('');
setPercentageRate('');
setMaxCap('');
setStatus('DRAFT');
setShowModal(false);
fetchPlans();
} catch (err) {
setError('An error occurred during submission.');
}
};
const handleToggleStatus = async (plan: Plan) => {
const nextStatus = plan.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
try {
const res = await fetch(`/api/plans/${plan.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: nextStatus
})
});
if (res.ok) {
fetchPlans();
}
} catch (err) {
console.error('Failed to toggle status', err);
}
};
return (
<div className={styles.container}>
{/* Shared Dashboard Header */}
<header className={styles.header}>
<div className={styles.logoArea}>
<span className={styles.logoText}>Remuneración Estelar</span>
</div>
<nav className={styles.nav}>
<Link href="/plans" className={`${styles.navLink} ${styles.navLinkActive}`}>
Planes de Comisión
</Link>
<Link href="/goals" className={styles.navLink}>
Metas Comerciales
</Link>
</nav>
<button onClick={handleLogout} className={styles.logoutBtn}>
Cerrar Sesión
</button>
</header>
<main className={styles.main}>
<div className={styles.titleSection}>
<h1 className={styles.title}>Planes de Comisión</h1>
<button onClick={() => setShowModal(true)} className={styles.btnPrimary} id="btn-create-plan">
Nuevo Plan
</button>
</div>
{isLoading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>Cargando planes...</div>
) : (
<div className={styles.grid}>
{plans.map((plan) => (
<div key={plan.id} className={styles.card} data-plan-id={plan.id}>
<div>
<div className={styles.cardHeader}>
<span className={styles.cardCode}>{plan.code}</span>
<span className={`${styles.badge} ${styles['badge' + plan.status]}`}>
{plan.status}
</span>
</div>
<h2 className={styles.cardTitle}>{plan.name}</h2>
<div className={styles.cardMeta}>
<div className={styles.metaItem}>
<span>Versión:</span>
<span className={styles.metaValue} data-version-id={plan.id}>{plan.version}</span>
</div>
<div className={styles.metaItem}>
<span>Inicio Vigencia:</span>
<span className={styles.metaValue}>
{new Date(plan.validityStart).toLocaleDateString()}
</span>
</div>
{plan.validityEnd && (
<div className={styles.metaItem}>
<span>Fin Vigencia:</span>
<span className={styles.metaValue}>
{new Date(plan.validityEnd).toLocaleDateString()}
</span>
</div>
)}
<div className={styles.metaItem}>
<span>Reglas creadas:</span>
<span className={styles.metaValue}>{plan.rules ? plan.rules.length : 0}</span>
</div>
</div>
</div>
<div className={styles.cardFooter}>
<Link href={`/plans/${plan.id}/rules`} className={styles.btnSecondary} id={`btn-rules-${plan.id}`}>
Configurar Reglas
</Link>
<button
onClick={() => handleToggleStatus(plan)}
className={styles.btnSecondary}
id={`btn-toggle-status-${plan.id}`}
>
{plan.status === 'ACTIVE' ? 'Inactivar (Versión)' : 'Activar'}
</button>
</div>
</div>
))}
</div>
)}
</main>
{/* Creation Modal */}
{showModal && (
<div className={styles.modalOverlay} role="dialog">
<div className={styles.modal}>
<h2 className={styles.modalTitle}>Crear Nuevo Plan</h2>
<form onSubmit={handleCreatePlan} className={styles.form}>
{error && <div className={styles.errorText}>{error}</div>}
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-name">Nombre del Plan</label>
<input
id="plan-name"
type="text"
required
className={styles.input}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ej. Plan Comercial Bogotá"
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-code">Código del Plan</label>
<input
id="plan-code"
type="text"
required
className={styles.input}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Ej. PLAN-BOG-2026"
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-validity-start">Inicio de Vigencia</label>
<input
id="plan-validity-start"
type="date"
required
className={styles.input}
value={validityStart}
onChange={(e) => setValidityStart(e.target.value)}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-type">Tipo de Remuneración</label>
<select
id="plan-type"
className={styles.input}
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="PERCENTAGE">Porcentaje Simple</option>
<option value="SCALE">Escala Contigua</option>
<option value="CONDITIONAL">Condicional por Metas</option>
<option value="FIXED">Comisión Fija</option>
</select>
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-meta-amount">Meta de Ventas (Opcional)</label>
<input
id="plan-meta-amount"
type="number"
step="0.01"
className={styles.input}
value={metaAmount}
onChange={(e) => setMetaAmount(e.target.value)}
placeholder="Ej. 100000.00"
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-max-cap">Tope Máximo / Cap (Opcional)</label>
<input
id="plan-max-cap"
type="number"
step="0.01"
className={styles.input}
value={maxCap}
onChange={(e) => setMaxCap(e.target.value)}
placeholder="Ej. 15000.00"
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-status">Estado Inicial</label>
<select
id="plan-status"
className={styles.input}
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="DRAFT">Borrador (DRAFT)</option>
<option value="ACTIVE">Activo (ACTIVE)</option>
</select>
</div>
<div className={styles.formActions}>
<button type="button" onClick={() => setShowModal(false)} className={styles.btnSecondary}>
Cancelar
</button>
<button type="submit" className={styles.btnPrimary} id="btn-save-plan">
Guardar Plan
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View file

@ -18,7 +18,10 @@ const getPrismaClient = (): PrismaClient => {
ssl: false
});
const adapter = new PrismaPg(pool);
globalPrisma = new PrismaClient({ adapter });
globalPrisma = new PrismaClient({
adapter,
log: ['query', 'info', 'warn', 'error']
});
}
return globalPrisma;
};
@ -33,7 +36,14 @@ export const getPrisma = (session?: UserSession | null) => {
return prisma.$extends({
query: {
$allModels: {
async $allOperations({ args, query }) {
async $allOperations({ args, query, __internalParams }: any) {
// Check if we are inside a transaction (either batch or interactive transaction)
const isInsideTransaction = __internalParams?.transaction !== undefined;
if (isInsideTransaction) {
return query(args);
}
// Execute RLS parameter setting followed by the original query in a batch transaction
const results = await prisma.$transaction([
prisma.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`),

34
tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}