diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb7dffd --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/docs/PHASE_3_IMPLEMENTATION.md b/docs/PHASE_3_IMPLEMENTATION.md new file mode 100644 index 0000000..3b524ce --- /dev/null +++ b/docs/PHASE_3_IMPLEMENTATION.md @@ -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`. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/eslint.config.mjs @@ -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; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..e9ffa30 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 504ae13..3f99a6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4484092..1819a60 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..831a20f --- /dev/null +++ b/prisma.config.ts @@ -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"], + }, +}); diff --git a/prisma/migrations/20260611140956_init/migration.sql b/prisma/migrations/20260611140956_init/migration.sql new file mode 100644 index 0000000..59cab0b --- /dev/null +++ b/prisma/migrations/20260611140956_init/migration.sql @@ -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; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..7feb5c3 --- /dev/null +++ b/prisma/schema.prisma @@ -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") +} diff --git a/prisma/test-auth-rls.js b/prisma/test-auth-rls.js index 3a210c8..6d21693 100644 --- a/prisma/test-auth-rls.js +++ b/prisma/test-auth-rls.js @@ -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) { diff --git a/prisma/test-phase3-ui.js b/prisma/test-phase3-ui.js new file mode 100644 index 0000000..95438c8 --- /dev/null +++ b/prisma/test-phase3-ui.js @@ -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); +}); diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 9ea5972..235f91d 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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 } diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts new file mode 100644 index 0000000..b5f7dcf --- /dev/null +++ b/src/app/api/goals/route.ts @@ -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']); diff --git a/src/app/api/plans/[id]/route.ts b/src/app/api/plans/[id]/route.ts new file mode 100644 index 0000000..4308f48 --- /dev/null +++ b/src/app/api/plans/[id]/route.ts @@ -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']); diff --git a/src/app/api/plans/[id]/rules/route.ts b/src/app/api/plans/[id]/rules/route.ts new file mode 100644 index 0000000..03b5da0 --- /dev/null +++ b/src/app/api/plans/[id]/rules/route.ts @@ -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']); diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts new file mode 100644 index 0000000..b17c59a --- /dev/null +++ b/src/app/api/plans/route.ts @@ -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']); diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..08eaac4 --- /dev/null +++ b/src/app/api/users/route.ts @@ -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']); diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/goals/page.module.css b/src/app/goals/page.module.css new file mode 100644 index 0000000..caae275 --- /dev/null +++ b/src/app/goals/page.module.css @@ -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; +} diff --git a/src/app/goals/page.tsx b/src/app/goals/page.tsx new file mode 100644 index 0000000..85af31f --- /dev/null +++ b/src/app/goals/page.tsx @@ -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([]); + const [goals, setGoals] = useState([]); + 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(null); + const [success, setSuccess] = useState(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 ( +
+ {/* Shared Dashboard Header */} +
+
+ Remuneración Estelar +
+ + +
+ +
+
+

Metas Comerciales

+
+ + {/* Goal Assignment Form Section */} +
+

Asignar Meta

+ {error &&
{error}
} + {success &&
{success}
} + +
+
+ + +
+ + {targetType === 'INDIVIDUAL' && ( +
+ + +
+ )} + + {targetType !== 'INDIVIDUAL' && ( +
+ + setTargetId(e.target.value)} + placeholder="Ej. ID de Hotel o Equipo" + required + /> +
+ )} + +
+ + setPeriod(e.target.value)} + required + /> +
+ +
+ + setAmount(e.target.value)} + placeholder="Ej. 50000.00" + required + /> +
+ + +
+
+ + {/* Configured Goals List Section */} +
+

Historial de Metas

+ {isLoading ? ( +
Cargando metas...
+ ) : ( +
+ + + + + + + + + + + {goals.length === 0 ? ( + + + + ) : ( + goals.map((g) => ( + + + + + + + )) + )} + +
ObjetivoPeríodoTipoCuota ($)
No hay metas configuradas.
{getTargetLabel(g.targetType, g.targetId)}{g.period} + + {g.targetType} + + + ${parseFloat(g.amount).toLocaleString('es-CO', { minimumFractionDigits: 2 })} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..1da6a2f --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index adc9e3d..d9a3b37 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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() { ); } + +export default function LoginPage() { + return ( + Cargando formulario...}> + + + ); +} diff --git a/src/app/page.module.css b/src/app/page.module.css new file mode 100644 index 0000000..643fe27 --- /dev/null +++ b/src/app/page.module.css @@ -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; + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..0f005b6 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Home() { + redirect('/plans'); +} diff --git a/src/app/plans/[id]/rules/page.module.css b/src/app/plans/[id]/rules/page.module.css new file mode 100644 index 0000000..052e016 --- /dev/null +++ b/src/app/plans/[id]/rules/page.module.css @@ -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); +} diff --git a/src/app/plans/[id]/rules/page.tsx b/src/app/plans/[id]/rules/page.tsx new file mode 100644 index 0000000..6d5ab59 --- /dev/null +++ b/src/app/plans/[id]/rules/page.tsx @@ -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(null); + const [rules, setRules] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(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 ( +
+
Cargando configuración...
+
+ ); + } + + return ( +
+ {/* Shared Dashboard Header */} +
+
+ Remuneración Estelar +
+ + +
+ +
+
+ + ← Volver a Planes + +

Configurar Reglas de Comisión

+

+ Plan: {plan?.name} | Código: {plan?.code} | Versión: {plan?.version} | Estado: {plan?.status} +

+
+ + {error &&
{error}
} + {success &&
{success}
} + +
+
+
+ Tipo de Regla + Min Logro (%) + Max Logro (%) + Tasa Comisión (Decimal) + Payout Fijo ($) + +
+ +
+ {rules.map((rule, idx) => ( +
+ + + handleUpdateRow(rule.tempId!, 'minAchievement', e.target.value)} + id={`rule-min-${idx}`} + required + /> + + handleUpdateRow(rule.tempId!, 'maxAchievement', e.target.value)} + id={`rule-max-${idx}`} + required + /> + + handleUpdateRow(rule.tempId!, 'rate', e.target.value)} + id={`rule-rate-${idx}`} + required + /> + + handleUpdateRow(rule.tempId!, 'payoutAmount', e.target.value)} + id={`rule-payout-${idx}`} + required + /> + + +
+ ))} +
+ + + +
+ + Cancelar + + +
+
+
+
+
+ ); +} diff --git a/src/app/plans/page.module.css b/src/app/plans/page.module.css new file mode 100644 index 0000000..b5152a9 --- /dev/null +++ b/src/app/plans/page.module.css @@ -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); +} diff --git a/src/app/plans/page.tsx b/src/app/plans/page.tsx new file mode 100644 index 0000000..29c2cae --- /dev/null +++ b/src/app/plans/page.tsx @@ -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([]); + 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(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 ( +
+ {/* Shared Dashboard Header */} +
+
+ Remuneración Estelar +
+ + +
+ +
+
+

Planes de Comisión

+ +
+ + {isLoading ? ( +
Cargando planes...
+ ) : ( +
+ {plans.map((plan) => ( +
+
+
+ {plan.code} + + {plan.status} + +
+

{plan.name}

+
+
+ Versión: + {plan.version} +
+
+ Inicio Vigencia: + + {new Date(plan.validityStart).toLocaleDateString()} + +
+ {plan.validityEnd && ( +
+ Fin Vigencia: + + {new Date(plan.validityEnd).toLocaleDateString()} + +
+ )} +
+ Reglas creadas: + {plan.rules ? plan.rules.length : 0} +
+
+
+ +
+ + Configurar Reglas + + +
+
+ ))} +
+ )} +
+ + {/* Creation Modal */} + {showModal && ( +
+
+

Crear Nuevo Plan

+
+ {error &&
{error}
} + +
+ + setName(e.target.value)} + placeholder="Ej. Plan Comercial Bogotá" + /> +
+ +
+ + setCode(e.target.value)} + placeholder="Ej. PLAN-BOG-2026" + /> +
+ +
+ + setValidityStart(e.target.value)} + /> +
+ +
+ + +
+ +
+ + setMetaAmount(e.target.value)} + placeholder="Ej. 100000.00" + /> +
+ +
+ + setMaxCap(e.target.value)} + placeholder="Ej. 15000.00" + /> +
+ +
+ + +
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 0138247..4980fe7 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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}';`), diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/tsconfig.json @@ -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"] +}