feat: implement Phase 3 - Compensation Plan Creation, Versioning Logic, Goals, and E2E Tests
This commit is contained in:
parent
9ea98f8d76
commit
f79d7db793
36 changed files with 3378 additions and 9 deletions
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal 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/
|
||||
82
docs/PHASE_3_IMPLEMENTATION.md
Normal file
82
docs/PHASE_3_IMPLEMENTATION.md
Normal 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
18
eslint.config.mjs
Normal 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
7
next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
154
package-lock.json
generated
154
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
14
prisma.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
206
prisma/migrations/20260611140956_init/migration.sql
Normal file
206
prisma/migrations/20260611140956_init/migration.sql
Normal 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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
177
prisma/schema.prisma
Normal file
177
prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
|
|
@ -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
388
prisma/test-phase3-ui.js
Normal 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
1
public/file.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
|
|
@ -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 }
|
||||
|
|
|
|||
61
src/app/api/goals/route.ts
Normal file
61
src/app/api/goals/route.ts
Normal 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']);
|
||||
121
src/app/api/plans/[id]/route.ts
Normal file
121
src/app/api/plans/[id]/route.ts
Normal 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']);
|
||||
62
src/app/api/plans/[id]/rules/route.ts
Normal file
62
src/app/api/plans/[id]/rules/route.ts
Normal 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']);
|
||||
56
src/app/api/plans/route.ts
Normal file
56
src/app/api/plans/route.ts
Normal 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']);
|
||||
31
src/app/api/users/route.ts
Normal file
31
src/app/api/users/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
240
src/app/goals/page.module.css
Normal file
240
src/app/goals/page.module.css
Normal 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
293
src/app/goals/page.tsx
Normal 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
30
src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
142
src/app/page.module.css
Normal 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
5
src/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/plans');
|
||||
}
|
||||
191
src/app/plans/[id]/rules/page.module.css
Normal file
191
src/app/plans/[id]/rules/page.module.css
Normal 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);
|
||||
}
|
||||
324
src/app/plans/[id]/rules/page.tsx
Normal file
324
src/app/plans/[id]/rules/page.tsx
Normal 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}>
|
||||
← 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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
318
src/app/plans/page.module.css
Normal file
318
src/app/plans/page.module.css
Normal 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
333
src/app/plans/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
34
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue