feat: integrate SalesImportJob tracking table and fix E2E test race conditions
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
out
|
||||||
|
build
|
||||||
|
prisma/screenshots
|
||||||
|
*.log
|
||||||
|
.env*.local
|
||||||
27
Dockerfile
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@9
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Skip Puppeteer Chrome download in Docker builds
|
||||||
|
ENV PUPPETEER_SKIP_DOWNLOAD=1
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pnpm prisma generate
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
|
|
@ -82,7 +82,7 @@ erDiagram
|
||||||
string username
|
string username
|
||||||
string email
|
string email
|
||||||
string password_hash
|
string password_hash
|
||||||
string role "ADMIN | DIRECTOR | GERENTE | LIDER | ANALISTA | CONSULTA | COLABORADOR"
|
string role "admin | director | hotel_manager | commercial_leader | analyst | auditor | collaborator"
|
||||||
int hotel_id FK
|
int hotel_id FK
|
||||||
string area
|
string area
|
||||||
string status "ACTIVE | INACTIVE"
|
string status "ACTIVE | INACTIVE"
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ Goals are assigned per period (`YYYY-MM`) at different scopes (`INDIVIDUAL` | `T
|
||||||
## 2. API Specifications
|
## 2. API Specifications
|
||||||
|
|
||||||
### 2.1. `POST /api/plans` (Create Plan)
|
### 2.1. `POST /api/plans` (Create Plan)
|
||||||
- **Role Restriction**: `ADMIN` or `DIRECTOR`
|
- **Role Restriction**: `admin` or `director`
|
||||||
- **Request Body**:
|
- **Request Body**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -60,15 +60,15 @@ Goals are assigned per period (`YYYY-MM`) at different scopes (`INDIVIDUAL` | `T
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2. `PUT /api/plans/[id]` (Update/Version Plan)
|
### 2.2. `PUT /api/plans/[id]` (Update/Version Plan)
|
||||||
- **Role Restriction**: `ADMIN` or `DIRECTOR`
|
- **Role Restriction**: `admin` or `director`
|
||||||
- **Logic**: Evaluates status to execute in-place updates or clone/version logic.
|
- **Logic**: Evaluates status to execute in-place updates or clone/version logic.
|
||||||
|
|
||||||
### 2.3. `POST /api/plans/[id]/rules` (Configure Rules)
|
### 2.3. `POST /api/plans/[id]/rules` (Configure Rules)
|
||||||
- **Role Restriction**: `ADMIN` or `DIRECTOR`
|
- **Role Restriction**: `admin` or `director`
|
||||||
- **Request Body**: Array of calculation rules to bulk upsert.
|
- **Request Body**: Array of calculation rules to bulk upsert.
|
||||||
|
|
||||||
### 2.4. `POST /api/goals` (Assign Goals)
|
### 2.4. `POST /api/goals` (Assign Goals)
|
||||||
- **Role Restriction**: `ADMIN` or `DIRECTOR`
|
- **Role Restriction**: `admin` or `director`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
7941
package-lock.json
generated
5633
pnpm-lock.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sales_import_jobs" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"idempotency_key" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'PROCESSING',
|
||||||
|
"error_message" TEXT,
|
||||||
|
"uploaded_by" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sales_import_jobs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sales_import_jobs_idempotency_key_key" ON "sales_import_jobs"("idempotency_key");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sales_import_jobs" ADD CONSTRAINT "sales_import_jobs_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
@ -1,62 +1,22 @@
|
||||||
-- RLS Policies and Seeding Script for Hoteles Estelar Variable Remuneration System
|
-- RLS Policies and Seeding Script for Hoteles Estelar Variable Remuneration System
|
||||||
SET app.current_user_role = 'ADMIN';
|
SET app.current_user_role = 'admin';
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- 1. SEED DATA
|
-- 0. TEMPORARILY DISABLE RLS FOR SEEDING
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
|
ALTER TABLE "regions" DISABLE ROW LEVEL SECURITY;
|
||||||
-- Seed Regions
|
ALTER TABLE "hotels" DISABLE ROW LEVEL SECURITY;
|
||||||
INSERT INTO "regions" ("name", "code") VALUES
|
ALTER TABLE "users" DISABLE ROW LEVEL SECURITY;
|
||||||
('Bogotá', 'BOG'),
|
ALTER TABLE "goals" DISABLE ROW LEVEL SECURITY;
|
||||||
('Antioquia', 'ANT'),
|
ALTER TABLE "sales_results" DISABLE ROW LEVEL SECURITY;
|
||||||
('Caribe', 'CAR')
|
ALTER TABLE "settlements" DISABLE ROW LEVEL SECURITY;
|
||||||
ON CONFLICT ("code") DO NOTHING;
|
ALTER TABLE "audit_logs" DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "compensation_plans" DISABLE ROW LEVEL SECURITY;
|
||||||
-- Seed Hotels
|
ALTER TABLE "calculation_rules" DISABLE ROW LEVEL SECURITY;
|
||||||
INSERT INTO "hotels" ("name", "code", "region_id", "status") VALUES
|
|
||||||
('Estelar Parque de la 93', 'EST-P93', (SELECT id FROM regions WHERE code = 'BOG'), 'ACTIVE'),
|
|
||||||
('Estelar Medellin', 'EST-MDE', (SELECT id FROM regions WHERE code = 'ANT'), 'ACTIVE'),
|
|
||||||
('Estelar Cartagena', 'EST-CTG', (SELECT id FROM regions WHERE code = 'CAR'), 'ACTIVE')
|
|
||||||
ON CONFLICT ("code") DO NOTHING;
|
|
||||||
|
|
||||||
-- Seed Users
|
|
||||||
-- password_hash is bcrypt hash of 'password123'
|
|
||||||
INSERT INTO "users" ("username", "email", "password_hash", "role", "hotel_id", "area", "status", "created_at") VALUES
|
|
||||||
('admin', 'admin@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'ADMIN', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Sistemas', 'ACTIVE', NOW()),
|
|
||||||
('director', 'director@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'DIRECTOR', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Comercial', 'ACTIVE', NOW()),
|
|
||||||
('gerente_mde', 'gerente.mde@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'GERENTE', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Administracion', 'ACTIVE', NOW()),
|
|
||||||
('lider_ctg', 'lider.ctg@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'LIDER', (SELECT id FROM hotels WHERE code = 'EST-CTG'), 'Ventas', 'ACTIVE', NOW()),
|
|
||||||
('analista', 'analista@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'ANALISTA', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Finanzas', 'ACTIVE', NOW()),
|
|
||||||
('consulta', 'consulta@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'CONSULTA', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Auditoria', 'ACTIVE', NOW()),
|
|
||||||
('colaborador_mde', 'colaborador.mde@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'COLABORADOR', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Ventas', 'ACTIVE', NOW())
|
|
||||||
ON CONFLICT ("username") DO NOTHING;
|
|
||||||
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- 2. ENABLE ROW-LEVEL SECURITY
|
-- 1. CLEAN UP OLD POLICIES
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
|
|
||||||
ALTER TABLE "regions" ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "hotels" ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "goals" ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "sales_results" ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "settlements" ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "audit_logs" ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
ALTER TABLE "regions" FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "hotels" FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "users" FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "goals" FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "sales_results" FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "settlements" FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "audit_logs" FORCE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
|
|
||||||
-- ==========================================
|
|
||||||
-- 3. CLEAN UP OLD POLICIES
|
|
||||||
-- ==========================================
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS audit_logs_insert_policy ON "audit_logs";
|
DROP POLICY IF EXISTS audit_logs_insert_policy ON "audit_logs";
|
||||||
DROP POLICY IF EXISTS audit_logs_select_policy ON "audit_logs";
|
DROP POLICY IF EXISTS audit_logs_select_policy ON "audit_logs";
|
||||||
DROP POLICY IF EXISTS regions_select_policy ON "regions";
|
DROP POLICY IF EXISTS regions_select_policy ON "regions";
|
||||||
|
|
@ -76,6 +36,61 @@ DROP POLICY IF EXISTS plans_modify_policy ON "compensation_plans";
|
||||||
DROP POLICY IF EXISTS rules_select_policy ON "calculation_rules";
|
DROP POLICY IF EXISTS rules_select_policy ON "calculation_rules";
|
||||||
DROP POLICY IF EXISTS rules_modify_policy ON "calculation_rules";
|
DROP POLICY IF EXISTS rules_modify_policy ON "calculation_rules";
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- 2. SEED DATA
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Seed Regions
|
||||||
|
INSERT INTO "regions" ("name", "code") VALUES
|
||||||
|
('Bogotá', 'BOG'),
|
||||||
|
('Antioquia', 'ANT'),
|
||||||
|
('Caribe', 'CAR')
|
||||||
|
ON CONFLICT ("code") DO NOTHING;
|
||||||
|
|
||||||
|
-- Seed Hotels
|
||||||
|
INSERT INTO "hotels" ("name", "code", "region_id", "status") VALUES
|
||||||
|
('Estelar Parque de la 93', 'EST-P93', (SELECT id FROM regions WHERE code = 'BOG'), 'ACTIVE'),
|
||||||
|
('Estelar Medellin', 'EST-MDE', (SELECT id FROM regions WHERE code = 'ANT'), 'ACTIVE'),
|
||||||
|
('Estelar Cartagena', 'EST-CTG', (SELECT id FROM regions WHERE code = 'CAR'), 'ACTIVE')
|
||||||
|
ON CONFLICT ("code") DO NOTHING;
|
||||||
|
|
||||||
|
-- Seed Users
|
||||||
|
-- password_hash is bcrypt hash of 'password123'
|
||||||
|
INSERT INTO "users" ("username", "email", "password_hash", "role", "hotel_id", "area", "status", "created_at") VALUES
|
||||||
|
('admin', 'admin@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'admin', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Sistemas', 'ACTIVE', NOW()),
|
||||||
|
('director', 'director@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'director', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Comercial', 'ACTIVE', NOW()),
|
||||||
|
('gerente_mde', 'gerente.mde@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'hotel_manager', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Administracion', 'ACTIVE', NOW()),
|
||||||
|
('lider_ctg', 'lider.ctg@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'commercial_leader', (SELECT id FROM hotels WHERE code = 'EST-CTG'), 'Ventas', 'ACTIVE', NOW()),
|
||||||
|
('analista', 'analista@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'analyst', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Finanzas', 'ACTIVE', NOW()),
|
||||||
|
('consulta', 'consulta@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'auditor', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Auditoria', 'ACTIVE', NOW()),
|
||||||
|
('colaborador_mde', 'colaborador.mde@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'collaborator', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Ventas', 'ACTIVE', NOW())
|
||||||
|
ON CONFLICT ("username") DO UPDATE SET role = EXCLUDED.role;
|
||||||
|
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- 3. ENABLE ROW-LEVEL SECURITY
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
ALTER TABLE "regions" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "hotels" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "goals" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "sales_results" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "settlements" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "audit_logs" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "compensation_plans" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "calculation_rules" ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
ALTER TABLE "regions" FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "hotels" FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "users" FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "goals" FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "sales_results" FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "settlements" FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "audit_logs" FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "compensation_plans" FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "calculation_rules" FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- 4. CREATE RLS POLICIES
|
-- 4. CREATE RLS POLICIES
|
||||||
|
|
@ -87,7 +102,7 @@ CREATE POLICY audit_logs_insert_policy ON "audit_logs"
|
||||||
|
|
||||||
CREATE POLICY audit_logs_select_policy ON "audit_logs"
|
CREATE POLICY audit_logs_select_policy ON "audit_logs"
|
||||||
FOR SELECT USING (
|
FOR SELECT USING (
|
||||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA')
|
current_setting('app.current_user_role', true) IN ('admin', 'analyst')
|
||||||
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -95,49 +110,49 @@ CREATE POLICY audit_logs_select_policy ON "audit_logs"
|
||||||
-- B. Regions Policies
|
-- B. Regions Policies
|
||||||
CREATE POLICY regions_select_policy ON "regions"
|
CREATE POLICY regions_select_policy ON "regions"
|
||||||
FOR SELECT USING (
|
FOR SELECT USING (
|
||||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
|
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
|
||||||
OR id = NULLIF(current_setting('app.current_region_id', true), '')::integer
|
OR id = NULLIF(current_setting('app.current_region_id', true), '')::integer
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY regions_modify_policy ON "regions"
|
CREATE POLICY regions_modify_policy ON "regions"
|
||||||
FOR ALL USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'DIRECTOR'));
|
FOR ALL USING (current_setting('app.current_user_role', true) IN ('admin', 'director'));
|
||||||
|
|
||||||
|
|
||||||
-- C. Hotels Policies
|
-- C. Hotels Policies
|
||||||
CREATE POLICY hotels_select_policy ON "hotels"
|
CREATE POLICY hotels_select_policy ON "hotels"
|
||||||
FOR SELECT USING (
|
FOR SELECT USING (
|
||||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
|
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
|
||||||
OR region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer
|
OR region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer
|
||||||
OR id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
OR id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY hotels_modify_policy ON "hotels"
|
CREATE POLICY hotels_modify_policy ON "hotels"
|
||||||
FOR ALL USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'DIRECTOR'));
|
FOR ALL USING (current_setting('app.current_user_role', true) IN ('admin', 'director'));
|
||||||
|
|
||||||
|
|
||||||
-- D. Users Policies
|
-- D. Users Policies
|
||||||
CREATE POLICY users_select_policy ON "users"
|
CREATE POLICY users_select_policy ON "users"
|
||||||
FOR SELECT USING (
|
FOR SELECT USING (
|
||||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
|
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
|
||||||
OR (
|
OR (
|
||||||
current_setting('app.current_user_role', true) = 'GERENTE'
|
current_setting('app.current_user_role', true) = 'hotel_manager'
|
||||||
AND hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
AND hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
||||||
)
|
)
|
||||||
OR (
|
OR (
|
||||||
current_setting('app.current_user_role', true) = 'LIDER'
|
current_setting('app.current_user_role', true) = 'commercial_leader'
|
||||||
AND hotel_id IN (SELECT id FROM hotels WHERE region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer)
|
AND hotel_id IN (SELECT id FROM hotels WHERE region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer)
|
||||||
)
|
)
|
||||||
OR id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
OR id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY users_modify_policy ON "users"
|
CREATE POLICY users_modify_policy ON "users"
|
||||||
FOR ALL USING (current_setting('app.current_user_role', true) = 'ADMIN');
|
FOR ALL USING (current_setting('app.current_user_role', true) = 'admin');
|
||||||
|
|
||||||
|
|
||||||
-- E. Goals Policies
|
-- E. Goals Policies
|
||||||
CREATE POLICY goals_select_policy ON "goals"
|
CREATE POLICY goals_select_policy ON "goals"
|
||||||
FOR SELECT USING (
|
FOR SELECT USING (
|
||||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
|
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
|
||||||
OR target_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
OR target_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
||||||
OR target_id IN (
|
OR target_id IN (
|
||||||
SELECT u.id FROM users u WHERE u.hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
SELECT u.id FROM users u WHERE u.hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
||||||
|
|
@ -149,13 +164,13 @@ CREATE POLICY goals_select_policy ON "goals"
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY goals_modify_policy ON "goals"
|
CREATE POLICY goals_modify_policy ON "goals"
|
||||||
FOR ALL USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'DIRECTOR'));
|
FOR ALL USING (current_setting('app.current_user_role', true) IN ('admin', 'director'));
|
||||||
|
|
||||||
|
|
||||||
-- F. Sales Results Policies
|
-- F. Sales Results Policies
|
||||||
CREATE POLICY sales_results_select_policy ON "sales_results"
|
CREATE POLICY sales_results_select_policy ON "sales_results"
|
||||||
FOR SELECT USING (
|
FOR SELECT USING (
|
||||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
|
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
|
||||||
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
||||||
OR hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
OR hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
||||||
OR hotel_id IN (
|
OR hotel_id IN (
|
||||||
|
|
@ -165,9 +180,9 @@ CREATE POLICY sales_results_select_policy ON "sales_results"
|
||||||
|
|
||||||
CREATE POLICY sales_results_modify_policy ON "sales_results"
|
CREATE POLICY sales_results_modify_policy ON "sales_results"
|
||||||
FOR ALL USING (
|
FOR ALL USING (
|
||||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA')
|
current_setting('app.current_user_role', true) IN ('admin', 'analyst')
|
||||||
OR (
|
OR (
|
||||||
current_setting('app.current_user_role', true) = 'LIDER'
|
current_setting('app.current_user_role', true) = 'commercial_leader'
|
||||||
AND hotel_id IN (
|
AND hotel_id IN (
|
||||||
SELECT id FROM hotels WHERE region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer
|
SELECT id FROM hotels WHERE region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer
|
||||||
)
|
)
|
||||||
|
|
@ -178,7 +193,7 @@ CREATE POLICY sales_results_modify_policy ON "sales_results"
|
||||||
-- G. Settlements Policies
|
-- G. Settlements Policies
|
||||||
CREATE POLICY settlements_select_policy ON "settlements"
|
CREATE POLICY settlements_select_policy ON "settlements"
|
||||||
FOR SELECT USING (
|
FOR SELECT USING (
|
||||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
|
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
|
||||||
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
||||||
OR user_id IN (
|
OR user_id IN (
|
||||||
SELECT u.id FROM users u WHERE u.hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
SELECT u.id FROM users u WHERE u.hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
||||||
|
|
@ -191,9 +206,9 @@ CREATE POLICY settlements_select_policy ON "settlements"
|
||||||
|
|
||||||
CREATE POLICY settlements_modify_policy ON "settlements"
|
CREATE POLICY settlements_modify_policy ON "settlements"
|
||||||
FOR ALL USING (
|
FOR ALL USING (
|
||||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA')
|
current_setting('app.current_user_role', true) IN ('admin', 'analyst')
|
||||||
OR (
|
OR (
|
||||||
current_setting('app.current_user_role', true) = 'LIDER'
|
current_setting('app.current_user_role', true) = 'commercial_leader'
|
||||||
AND user_id IN (
|
AND user_id IN (
|
||||||
SELECT u.id FROM users u JOIN hotels h ON u.hotel_id = h.id
|
SELECT u.id FROM users u JOIN hotels h ON u.hotel_id = h.id
|
||||||
WHERE h.region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer
|
WHERE h.region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer
|
||||||
|
|
@ -201,23 +216,18 @@ CREATE POLICY settlements_modify_policy ON "settlements"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- H. Compensation Plans Policies
|
|
||||||
ALTER TABLE "compensation_plans" ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "compensation_plans" FORCE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
|
-- H. Compensation Plans Policies
|
||||||
CREATE POLICY plans_select_policy ON "compensation_plans"
|
CREATE POLICY plans_select_policy ON "compensation_plans"
|
||||||
FOR SELECT USING (true);
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
CREATE POLICY plans_modify_policy ON "compensation_plans"
|
CREATE POLICY plans_modify_policy ON "compensation_plans"
|
||||||
FOR ALL USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'DIRECTOR', 'ANALISTA'));
|
FOR ALL USING (current_setting('app.current_user_role', true) IN ('admin', 'director', 'analyst'));
|
||||||
|
|
||||||
|
|
||||||
-- I. Calculation Rules Policies
|
-- I. Calculation Rules Policies
|
||||||
ALTER TABLE "calculation_rules" ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "calculation_rules" FORCE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY rules_select_policy ON "calculation_rules"
|
CREATE POLICY rules_select_policy ON "calculation_rules"
|
||||||
FOR SELECT USING (true);
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
CREATE POLICY rules_modify_policy ON "calculation_rules"
|
CREATE POLICY rules_modify_policy ON "calculation_rules"
|
||||||
FOR ALL USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'DIRECTOR', 'ANALISTA'));
|
FOR ALL USING (current_setting('app.current_user_role', true) IN ('admin', 'director', 'analyst'));
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ model User {
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
role String // ADMIN | DIRECTOR | GERENTE | LIDER | ANALISTA | CONSULTA | COLABORADOR
|
role String // admin | director | hotel_manager | commercial_leader | analyst | auditor | collaborator
|
||||||
hotelId Int @map("hotel_id")
|
hotelId Int @map("hotel_id")
|
||||||
hotel Hotel @relation(fields: [hotelId], references: [id])
|
hotel Hotel @relation(fields: [hotelId], references: [id])
|
||||||
area String
|
area String
|
||||||
|
|
@ -48,6 +48,7 @@ model User {
|
||||||
plansCreated CompensationPlan[] @relation("PlanCreator")
|
plansCreated CompensationPlan[] @relation("PlanCreator")
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
|
importJobs SalesImportJob[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
@ -175,3 +176,16 @@ model Notification {
|
||||||
|
|
||||||
@@map("notifications")
|
@@map("notifications")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SalesImportJob {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idempotencyKey String @unique @map("idempotency_key")
|
||||||
|
status String @default("PROCESSING") // PROCESSING | SUCCESS | FAILED
|
||||||
|
errorMessage String? @map("error_message")
|
||||||
|
uploadedBy Int @map("uploaded_by")
|
||||||
|
uploader User @relation(fields: [uploadedBy], references: [id])
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("sales_import_jobs")
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
prisma/screenshots-phase4/01_collaborator_unauthorized.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
prisma/screenshots-phase4/02_admin_import_view.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
prisma/screenshots-phase4/03_invalid_file_selected.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
prisma/screenshots-phase4/04_validation_errors_rendered.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
prisma/screenshots-phase4/05_valid_upload_success.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
prisma/screenshots-phase4/06_idempotency_duplicate.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
prisma/screenshots-phase4/error_screenshot.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
|
@ -32,7 +32,7 @@ function getPrisma() {
|
||||||
async function runAsAdmin(queryFn) {
|
async function runAsAdmin(queryFn) {
|
||||||
const db = getPrisma();
|
const db = getPrisma();
|
||||||
return db.$transaction(async (tx) => {
|
return db.$transaction(async (tx) => {
|
||||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'ADMIN';`);
|
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||||
return queryFn(tx);
|
return queryFn(tx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -68,8 +68,11 @@ async function runTests() {
|
||||||
const adminUser = users.find(u => u.username === 'admin');
|
const adminUser = users.find(u => u.username === 'admin');
|
||||||
|
|
||||||
await runAsAdmin(async (tx) => {
|
await runAsAdmin(async (tx) => {
|
||||||
await tx.salesResult.deleteMany();
|
await tx.salesResult.deleteMany({
|
||||||
await tx.auditLog.deleteMany();
|
where: {
|
||||||
|
idempotencyKey: { in: ['auth-test-colab', 'auth-test-lider'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Colaborador sale
|
// Colaborador sale
|
||||||
await tx.salesResult.create({
|
await tx.salesResult.create({
|
||||||
|
|
@ -285,8 +288,11 @@ async function cleanup() {
|
||||||
try {
|
try {
|
||||||
if (prisma) {
|
if (prisma) {
|
||||||
await runAsAdmin(async (tx) => {
|
await runAsAdmin(async (tx) => {
|
||||||
await tx.salesResult.deleteMany();
|
await tx.salesResult.deleteMany({
|
||||||
await tx.auditLog.deleteMany();
|
where: {
|
||||||
|
idempotencyKey: { in: ['auth-test-colab', 'auth-test-lider'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ function getPrisma() {
|
||||||
async function runAsAdmin(queryFn) {
|
async function runAsAdmin(queryFn) {
|
||||||
const db = getPrisma();
|
const db = getPrisma();
|
||||||
return db.$transaction(async (tx) => {
|
return db.$transaction(async (tx) => {
|
||||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'ADMIN';`);
|
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||||
return queryFn(tx);
|
return queryFn(tx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -69,21 +69,28 @@ async function runTests() {
|
||||||
// 1. Clean database records first
|
// 1. Clean database records first
|
||||||
console.log("Resetting test environment data...");
|
console.log("Resetting test environment data...");
|
||||||
await runAsAdmin(async (tx) => {
|
await runAsAdmin(async (tx) => {
|
||||||
await tx.calculationRule.deleteMany();
|
// Delete calculation rules associated with E2E test plans
|
||||||
await tx.goal.deleteMany();
|
await tx.calculationRule.deleteMany({
|
||||||
await tx.compensationPlan.deleteMany();
|
where: { plan: { code: 'PLAN-E2E-PUPP' } }
|
||||||
await tx.salesResult.deleteMany();
|
});
|
||||||
await tx.auditLog.deleteMany();
|
// Delete E2E test goals
|
||||||
|
await tx.goal.deleteMany({
|
||||||
|
where: { period: '2026-06', amount: 75000.00 }
|
||||||
|
});
|
||||||
|
// Delete E2E test plans
|
||||||
|
await tx.compensationPlan.deleteMany({
|
||||||
|
where: { code: 'PLAN-E2E-PUPP' }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Start Next.js server on port 3010
|
// 2. Start Next.js production server on port 3010
|
||||||
console.log(`Starting Next.js dev server on port ${PORT}...`);
|
console.log(`Starting Next.js production server on port ${PORT}...`);
|
||||||
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'dev', '--port', String(PORT)], {
|
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'start', '--port', String(PORT)], {
|
||||||
env: { ...process.env, PORT: String(PORT) }
|
env: { ...process.env, PORT: String(PORT) }
|
||||||
});
|
});
|
||||||
|
|
||||||
nextProcess.stdout.on('data', (data) => {
|
nextProcess.stdout.on('data', (data) => {
|
||||||
// console.log(`[Next.js] ${data.toString().trim()}`);
|
console.log(`[Next.js] ${data.toString().trim()}`);
|
||||||
});
|
});
|
||||||
nextProcess.stderr.on('data', (data) => {
|
nextProcess.stderr.on('data', (data) => {
|
||||||
console.error(`[Next.js ERR] ${data.toString().trim()}`);
|
console.error(`[Next.js ERR] ${data.toString().trim()}`);
|
||||||
|
|
@ -155,6 +162,15 @@ async function runTests() {
|
||||||
await page.waitForSelector('div[role="dialog"]');
|
await page.waitForSelector('div[role="dialog"]');
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_create_modal_open.png') });
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_create_modal_open.png') });
|
||||||
|
|
||||||
|
// Trigger save without filling fields to test HTML5 validations
|
||||||
|
console.log("Testing empty form submission block...");
|
||||||
|
await page.click('#btn-save-plan');
|
||||||
|
await sleep(1000);
|
||||||
|
const isModalOpen = await page.evaluate(() => {
|
||||||
|
return document.querySelector('div[role="dialog"]') !== null;
|
||||||
|
});
|
||||||
|
assert(isModalOpen, "HTML5 constraint validation: Modal remains open when attempting to submit empty fields");
|
||||||
|
|
||||||
// Fill in form details
|
// Fill in form details
|
||||||
await page.type('#plan-name', 'Plan Ventas E2E Puppeteer');
|
await page.type('#plan-name', 'Plan Ventas E2E Puppeteer');
|
||||||
await page.type('#plan-code', 'PLAN-E2E-PUPP');
|
await page.type('#plan-code', 'PLAN-E2E-PUPP');
|
||||||
|
|
@ -193,11 +209,20 @@ async function runTests() {
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '07_rules_config_page.png') });
|
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");
|
assert(page.url().includes(`/plans/${createdPlan.id}/rules`), "Successfully navigated to rules page");
|
||||||
|
|
||||||
// Modify first row
|
// Test invalid rule boundary constraint (min >= max)
|
||||||
await clearAndType('#rule-min-0', '0.0');
|
console.log("Testing rule validation constraints (min >= max)...");
|
||||||
await clearAndType('#rule-max-0', '0.9');
|
await setReactInput('#rule-min-0', '0.95');
|
||||||
await clearAndType('#rule-rate-0', '0.0');
|
await setReactInput('#rule-max-0', '0.90');
|
||||||
await clearAndType('#rule-payout-0', '0.0');
|
await page.click('#btn-save-rules');
|
||||||
|
await page.waitForSelector('#rules-error-msg');
|
||||||
|
const rulesError = await page.$eval('#rules-error-msg', el => el.textContent);
|
||||||
|
assert(rulesError.includes("El logro mínimo") && rulesError.includes("debe ser menor"), "Validation error shows when rule minimum exceeds maximum");
|
||||||
|
|
||||||
|
// Modify first row back to valid values
|
||||||
|
await setReactInput('#rule-min-0', '0.0');
|
||||||
|
await setReactInput('#rule-max-0', '0.9');
|
||||||
|
await setReactInput('#rule-rate-0', '0.0');
|
||||||
|
await setReactInput('#rule-payout-0', '0.0');
|
||||||
|
|
||||||
// Add a second row
|
// Add a second row
|
||||||
await page.click('#btn-add-rule');
|
await page.click('#btn-add-rule');
|
||||||
|
|
@ -205,10 +230,10 @@ async function runTests() {
|
||||||
await sleep(500); // Allow React state to settle
|
await sleep(500); // Allow React state to settle
|
||||||
|
|
||||||
// Fill second row
|
// Fill second row
|
||||||
await clearAndType('#rule-min-1', '0.9');
|
await setReactInput('#rule-min-1', '0.9');
|
||||||
await clearAndType('#rule-max-1', '1.0');
|
await setReactInput('#rule-max-1', '1.0');
|
||||||
await clearAndType('#rule-rate-1', '0.025');
|
await setReactInput('#rule-rate-1', '0.025');
|
||||||
await clearAndType('#rule-payout-1', '150.0');
|
await setReactInput('#rule-payout-1', '150.0');
|
||||||
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '08_rules_populated.png') });
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '08_rules_populated.png') });
|
||||||
|
|
||||||
|
|
@ -275,7 +300,17 @@ async function runTests() {
|
||||||
await page.select('#goal-target-type', 'INDIVIDUAL');
|
await page.select('#goal-target-type', 'INDIVIDUAL');
|
||||||
await page.select('#goal-target-id', colaboradorMde.id.toString());
|
await page.select('#goal-target-id', colaboradorMde.id.toString());
|
||||||
await setReactInput('#goal-period', '2026-06');
|
await setReactInput('#goal-period', '2026-06');
|
||||||
await page.type('#goal-amount', '75000');
|
|
||||||
|
// Test invalid goal amount validation
|
||||||
|
console.log("Testing invalid goal amount validation...");
|
||||||
|
await setReactInput('#goal-amount', '-100');
|
||||||
|
await page.click('#btn-save-goal');
|
||||||
|
await page.waitForSelector('#goal-error-msg');
|
||||||
|
const goalErrorMsg = await page.$eval('#goal-error-msg', el => el.textContent);
|
||||||
|
assert(goalErrorMsg.includes("El monto debe ser un número positivo"), "Validation error shows when entering negative goal amount");
|
||||||
|
|
||||||
|
// Enter valid amount
|
||||||
|
await setReactInput('#goal-amount', '75000');
|
||||||
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '14_goal_form_filled.png') });
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '14_goal_form_filled.png') });
|
||||||
await page.click('#btn-save-goal');
|
await page.click('#btn-save-goal');
|
||||||
|
|
@ -310,7 +345,9 @@ async function runTests() {
|
||||||
await page.type('#username', 'colaborador_mde');
|
await page.type('#username', 'colaborador_mde');
|
||||||
await page.type('#password', 'password123');
|
await page.type('#password', 'password123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await page.waitForSelector('#btn-create-plan');
|
await page.waitForSelector('header');
|
||||||
|
const hasCreatePlanBtn = await page.evaluate(() => !!document.getElementById('btn-create-plan'));
|
||||||
|
assert(!hasCreatePlanBtn, "Collaborator should not see the 'Nuevo Plan' button in the dashboard");
|
||||||
|
|
||||||
// Navigate directly to /goals and verify list shows only their goal
|
// Navigate directly to /goals and verify list shows only their goal
|
||||||
await page.goto(`${BASE_URL}/goals`, { waitUntil: 'networkidle2' });
|
await page.goto(`${BASE_URL}/goals`, { waitUntil: 'networkidle2' });
|
||||||
|
|
@ -324,6 +361,38 @@ async function runTests() {
|
||||||
|
|
||||||
assert(rowCount === 1, "RLS restriction verified: Colaborador only sees 1 goal (their own) in the goals dashboard");
|
assert(rowCount === 1, "RLS restriction verified: Colaborador only sees 1 goal (their own) in the goals dashboard");
|
||||||
|
|
||||||
|
// Verify API security boundary: Colaborador cannot create plans or assign goals
|
||||||
|
console.log("Verifying collaborator is blocked from executing ADMIN/DIRECTOR APIs...");
|
||||||
|
const planBlockRes = await page.evaluate(async () => {
|
||||||
|
const res = await fetch('/api/plans', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Plan Hack',
|
||||||
|
code: 'PLAN-HACK',
|
||||||
|
validityStart: '2026-06-01',
|
||||||
|
type: 'PERCENTAGE'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return { status: res.status };
|
||||||
|
});
|
||||||
|
assert(planBlockRes.status === 403, "API security check: Collaborator blocked from creating a plan (POST /api/plans returns 403)");
|
||||||
|
|
||||||
|
const goalBlockRes = await page.evaluate(async () => {
|
||||||
|
const res = await fetch('/api/goals', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetType: 'INDIVIDUAL',
|
||||||
|
targetId: 7,
|
||||||
|
period: '2026-06',
|
||||||
|
amount: 85000.00
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return { status: res.status };
|
||||||
|
});
|
||||||
|
assert(goalBlockRes.status === 403, "API security check: Collaborator blocked from assigning goals (POST /api/goals returns 403)");
|
||||||
|
|
||||||
await cleanup();
|
await cleanup();
|
||||||
|
|
||||||
console.log(`\n=== E2E TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
|
console.log(`\n=== E2E TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
|
||||||
|
|
@ -345,11 +414,18 @@ async function cleanup() {
|
||||||
try {
|
try {
|
||||||
if (prisma) {
|
if (prisma) {
|
||||||
await runAsAdmin(async (tx) => {
|
await runAsAdmin(async (tx) => {
|
||||||
await tx.calculationRule.deleteMany();
|
// Delete calculation rules associated with E2E test plans
|
||||||
await tx.goal.deleteMany();
|
await tx.calculationRule.deleteMany({
|
||||||
await tx.compensationPlan.deleteMany();
|
where: { plan: { code: 'PLAN-E2E-PUPP' } }
|
||||||
await tx.salesResult.deleteMany();
|
});
|
||||||
await tx.auditLog.deleteMany();
|
// Delete E2E test goals
|
||||||
|
await tx.goal.deleteMany({
|
||||||
|
where: { period: '2026-06', amount: 75000.00 }
|
||||||
|
});
|
||||||
|
// Delete E2E test plans
|
||||||
|
await tx.compensationPlan.deleteMany({
|
||||||
|
where: { code: 'PLAN-E2E-PUPP' }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,9 @@ async function runTests() {
|
||||||
await page.type('#username', 'colaborador_mde');
|
await page.type('#username', 'colaborador_mde');
|
||||||
await page.type('#password', 'password123');
|
await page.type('#password', 'password123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await page.waitForSelector('#btn-create-plan'); // Wait for home page dashboard load
|
await page.waitForSelector('header'); // Wait for home page dashboard load
|
||||||
|
const hasCreatePlanBtn = await page.evaluate(() => !!document.getElementById('btn-create-plan'));
|
||||||
|
assert(!hasCreatePlanBtn, "Collaborator should not see the 'Nuevo Plan' button in the dashboard");
|
||||||
|
|
||||||
// Try navigating to import page
|
// Try navigating to import page
|
||||||
await page.goto(`${BASE_URL}/sales/import`, { waitUntil: 'networkidle2' });
|
await page.goto(`${BASE_URL}/sales/import`, { waitUntil: 'networkidle2' });
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ async function runTests() {
|
||||||
|
|
||||||
// 1. Fetch seeded data to map IDs (Run as ADMIN to bypass RLS)
|
// 1. Fetch seeded data to map IDs (Run as ADMIN to bypass RLS)
|
||||||
const { users, hotels, regions } = await prisma.$transaction(async (tx) => {
|
const { users, hotels, regions } = await prisma.$transaction(async (tx) => {
|
||||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'ADMIN';`);
|
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||||
const users = await tx.user.findMany({ include: { hotel: true } });
|
const users = await tx.user.findMany({ include: { hotel: true } });
|
||||||
const hotels = await tx.hotel.findMany();
|
const hotels = await tx.hotel.findMany();
|
||||||
const regions = await tx.region.findMany();
|
const regions = await tx.region.findMany();
|
||||||
|
|
@ -104,12 +104,15 @@ async function runTests() {
|
||||||
|
|
||||||
// --- TEST 2: Sales Results RLS ---
|
// --- TEST 2: Sales Results RLS ---
|
||||||
console.log("\nRunning TEST 2: Sales Results RLS...");
|
console.log("\nRunning TEST 2: Sales Results RLS...");
|
||||||
|
let saleColab, saleOther;
|
||||||
try {
|
try {
|
||||||
// Clean up existing sales results to isolate the test
|
// Clean up test sales results first (non-destructively)
|
||||||
await runWithContext(adminUser, tx => tx.salesResult.deleteMany());
|
await runWithContext(adminUser, tx => tx.salesResult.deleteMany({
|
||||||
|
where: { idempotencyKey: { in: ['test-key-colab-1', 'test-key-other-1'] } }
|
||||||
|
}));
|
||||||
|
|
||||||
// Create sales results as Admin (bypass RLS)
|
// Create sales results as Admin (bypass RLS)
|
||||||
const saleColab = await runWithContext(adminUser, tx => tx.salesResult.create({
|
saleColab = await runWithContext(adminUser, tx => tx.salesResult.create({
|
||||||
data: {
|
data: {
|
||||||
source: 'EXCEL',
|
source: 'EXCEL',
|
||||||
hotelId: colaboradorMde.hotelId,
|
hotelId: colaboradorMde.hotelId,
|
||||||
|
|
@ -122,7 +125,7 @@ async function runTests() {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const saleOther = await runWithContext(adminUser, tx => tx.salesResult.create({
|
saleOther = await runWithContext(adminUser, tx => tx.salesResult.create({
|
||||||
data: {
|
data: {
|
||||||
source: 'EXCEL',
|
source: 'EXCEL',
|
||||||
hotelId: liderCtg.hotelId,
|
hotelId: liderCtg.hotelId,
|
||||||
|
|
@ -202,10 +205,17 @@ async function runTests() {
|
||||||
failed++;
|
failed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up test sales results and audit logs
|
// Clean up test sales results
|
||||||
console.log("\nCleaning up test records...");
|
console.log("\nCleaning up test records...");
|
||||||
await runWithContext(adminUser, tx => tx.salesResult.deleteMany());
|
const salesResultIds = [];
|
||||||
await runWithContext(adminUser, tx => tx.auditLog.deleteMany());
|
if (saleColab) salesResultIds.push(saleColab.id);
|
||||||
|
if (saleOther) salesResultIds.push(saleOther.id);
|
||||||
|
if (salesResultIds.length > 0) {
|
||||||
|
await runWithContext(adminUser, tx => tx.salesResult.deleteMany({
|
||||||
|
where: { id: { in: salesResultIds } }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Audit logs are not deleted due to write-only RLS immutability
|
||||||
|
|
||||||
console.log(`\n=== TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
|
console.log(`\n=== TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Username and password are required' },
|
{ error: 'El nombre de usuario y la contraseña son obligatorios.' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -18,13 +18,19 @@ export async function POST(req: NextRequest) {
|
||||||
userId: 0,
|
userId: 0,
|
||||||
username: 'login_system',
|
username: 'login_system',
|
||||||
email: 'system@estelar.com',
|
email: 'system@estelar.com',
|
||||||
role: 'ADMIN',
|
role: 'admin',
|
||||||
hotelId: 0,
|
hotelId: 0,
|
||||||
regionId: 0
|
regionId: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[Login API] DATABASE_URL in Next.js:', process.env.DATABASE_URL);
|
console.log('[Login API] DATABASE_URL in Next.js:', process.env.DATABASE_URL);
|
||||||
console.log('[Login API] Attempting login for username:', username);
|
console.log('[Login API] Attempting login for username:', username);
|
||||||
|
try {
|
||||||
|
const dbRole = await prismaAdmin.$queryRaw`SELECT current_setting('app.current_user_role', true) as role;`;
|
||||||
|
console.log('[DEBUG LOGIN] current_setting role inside Next.js:', JSON.stringify(dbRole));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[DEBUG LOGIN] Failed to fetch current_setting role:', e);
|
||||||
|
}
|
||||||
const user = await prismaAdmin.user.findUnique({
|
const user = await prismaAdmin.user.findUnique({
|
||||||
where: { username },
|
where: { username },
|
||||||
include: { hotel: true }
|
include: { hotel: true }
|
||||||
|
|
@ -34,7 +40,7 @@ export async function POST(req: NextRequest) {
|
||||||
if (!user || user.status !== 'ACTIVE') {
|
if (!user || user.status !== 'ACTIVE') {
|
||||||
console.log('[Login API] Login failed: User not found or inactive');
|
console.log('[Login API] Login failed: User not found or inactive');
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid credentials or inactive user' },
|
{ error: 'Credenciales inválidas o usuario inactivo.' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +50,7 @@ export async function POST(req: NextRequest) {
|
||||||
if (!passwordMatch) {
|
if (!passwordMatch) {
|
||||||
console.log('[Login API] Login failed: Password mismatch');
|
console.log('[Login API] Login failed: Password mismatch');
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid credentials' },
|
{ error: 'Credenciales inválidas.' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +105,7 @@ export async function POST(req: NextRequest) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Internal server error' },
|
{ error: 'Error interno del servidor.' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export const POST = withAuth(async (req, { prisma }) => {
|
||||||
|
|
||||||
if (!targetType || targetId === undefined || !period || amount === undefined) {
|
if (!targetType || targetId === undefined || !period || amount === undefined) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'targetType, targetId, period, and amount are required fields' },
|
{ error: 'targetType, targetId, period y amount son campos obligatorios.' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -58,4 +58,4 @@ export const POST = withAuth(async (req, { prisma }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ goal });
|
return NextResponse.json({ goal });
|
||||||
}, ['ADMIN', 'DIRECTOR']);
|
}, ['admin', 'director']);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export const GET = withAuth(async (req, { prisma, params }) => {
|
||||||
const id = parseInt(unwrappedParams.id);
|
const id = parseInt(unwrappedParams.id);
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = await prisma.compensationPlan.findUnique({
|
const plan = await prisma.compensationPlan.findUnique({
|
||||||
|
|
@ -16,20 +16,20 @@ export const GET = withAuth(async (req, { prisma, params }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plan no encontrado.' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ plan });
|
return NextResponse.json({ plan });
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT: Update or Version a plan (restricted to ADMIN and DIRECTOR)
|
// PUT: Update or Version a plan (restricted to admin and director)
|
||||||
export const PUT = withAuth(async (req, { session, prisma, params }) => {
|
export const PUT = withAuth(async (req, { session, prisma, params }) => {
|
||||||
const unwrappedParams = await params;
|
const unwrappedParams = await params;
|
||||||
const id = parseInt(unwrappedParams.id);
|
const id = parseInt(unwrappedParams.id);
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = await prisma.compensationPlan.findUnique({
|
const plan = await prisma.compensationPlan.findUnique({
|
||||||
|
|
@ -37,18 +37,12 @@ export const PUT = withAuth(async (req, { session, prisma, params }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plan no encontrado.' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Versioning replication if the plan is currently ACTIVE
|
// Versioning replication if the plan is currently ACTIVE
|
||||||
if (plan.status === 'ACTIVE') {
|
if (plan.status === 'ACTIVE') {
|
||||||
const newPlan = await prisma.$transaction(async (tx: any) => {
|
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
|
// 1. Mark current plan version as INACTIVE
|
||||||
await tx.compensationPlan.update({
|
await tx.compensationPlan.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
@ -118,4 +112,4 @@ export const PUT = withAuth(async (req, { session, prisma, params }) => {
|
||||||
|
|
||||||
return NextResponse.json({ plan: updatedPlan, versioned: false });
|
return NextResponse.json({ plan: updatedPlan, versioned: false });
|
||||||
}
|
}
|
||||||
}, ['ADMIN', 'DIRECTOR']);
|
}, ['admin', 'director']);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { withAuth } from '@/lib/api-guards';
|
import { withAuth } from '@/lib/api-guards';
|
||||||
|
|
||||||
// POST: Configure calculation rules (restricted to ADMIN and DIRECTOR)
|
// POST: Configure calculation rules (restricted to admin and director)
|
||||||
export const POST = withAuth(async (req, { session, prisma, params }) => {
|
export const POST = withAuth(async (req, { session, prisma, params }) => {
|
||||||
const unwrappedParams = await params;
|
const unwrappedParams = await params;
|
||||||
const planId = parseInt(unwrappedParams.id);
|
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 }, ...] }
|
const body = await req.json(); // Expected: { rules: [{ type: 'TIER', minAchievement: 0.9, maxAchievement: 1.0, rate: 0.015, payoutAmount: 0 }, ...] }
|
||||||
|
|
||||||
if (isNaN(planId)) {
|
if (isNaN(planId)) {
|
||||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = await prisma.compensationPlan.findUnique({
|
const plan = await prisma.compensationPlan.findUnique({
|
||||||
|
|
@ -16,17 +16,64 @@ export const POST = withAuth(async (req, { session, prisma, params }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plan no encontrado.' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[DEBUG RULES] body.rules:", JSON.stringify(body.rules));
|
||||||
|
// Validate rules before database execution
|
||||||
|
if (body.rules && Array.isArray(body.rules)) {
|
||||||
|
try {
|
||||||
|
const sortedRules = [...body.rules].sort((a: any, b: any) => parseFloat(a.minAchievement) - parseFloat(b.minAchievement));
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedRules.length; i++) {
|
||||||
|
const rule = sortedRules[i];
|
||||||
|
|
||||||
|
if (!rule.type || rule.minAchievement === undefined || rule.maxAchievement === undefined) {
|
||||||
|
return NextResponse.json({ error: 'Parámetros de regla de cálculo no válidos.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = parseFloat(rule.minAchievement);
|
||||||
|
const max = parseFloat(rule.maxAchievement);
|
||||||
|
|
||||||
|
if (isNaN(min) || isNaN(max)) {
|
||||||
|
console.log("[DEBUG RULES] isNaN(min) || isNaN(max) failed:", min, max);
|
||||||
|
return NextResponse.json({ error: 'Los límites de cumplimiento deben ser números válidos.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (min >= max) {
|
||||||
|
console.log("[DEBUG RULES] min >= max failed:", min, max);
|
||||||
|
return NextResponse.json({ error: `El cumplimiento mínimo de la regla (${min}) debe ser estrictamente menor que el cumplimiento máximo (${max}).` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < sortedRules.length - 1) {
|
||||||
|
const nextRule = sortedRules[i + 1];
|
||||||
|
const nextMin = parseFloat(nextRule.minAchievement);
|
||||||
|
const nextMax = parseFloat(nextRule.maxAchievement);
|
||||||
|
|
||||||
|
if (isNaN(nextMin) || isNaN(nextMax)) {
|
||||||
|
console.log("[DEBUG RULES] isNaN(nextMin) || isNaN(nextMax) failed:", nextMin, nextMax);
|
||||||
|
return NextResponse.json({ error: 'Los límites de cumplimiento deben ser números válidos.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextMin < max) {
|
||||||
|
console.log("[DEBUG RULES] nextMin < max failed:", nextMin, max);
|
||||||
|
return NextResponse.json({ error: `Se detectó superposición en las reglas de cálculo: el rango [${min}, ${max}] se superpone con el rango [${nextMin}, ${nextMax}].` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(nextMin - max) > 0.0001) {
|
||||||
|
console.log("[DEBUG RULES] Math.abs(nextMin - max) > 0.0001 failed:", nextMin, max, Math.abs(nextMin - max));
|
||||||
|
return NextResponse.json({ error: `Las reglas de cálculo deben ser contiguas: el rango [${min}, ${max}] no es contiguo con el rango [${nextMin}, ${nextMax}].` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log("[DEBUG RULES] Catch error:", e);
|
||||||
|
return NextResponse.json({ error: e.message || 'Error de validación.' }, { status: 400 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk overwrite rules inside a transaction
|
// Bulk overwrite rules inside a transaction
|
||||||
const rules = await prisma.$transaction(async (tx: any) => {
|
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
|
// 1. Delete existing rules
|
||||||
await tx.calculationRule.deleteMany({
|
await tx.calculationRule.deleteMany({
|
||||||
where: { planId }
|
where: { planId }
|
||||||
|
|
@ -34,13 +81,6 @@ export const POST = withAuth(async (req, { session, prisma, params }) => {
|
||||||
|
|
||||||
// 2. Insert new ones if provided
|
// 2. Insert new ones if provided
|
||||||
if (body.rules && Array.isArray(body.rules)) {
|
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({
|
await tx.calculationRule.createMany({
|
||||||
data: body.rules.map((r: any) => ({
|
data: body.rules.map((r: any) => ({
|
||||||
planId,
|
planId,
|
||||||
|
|
@ -59,4 +99,4 @@ export const POST = withAuth(async (req, { session, prisma, params }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ rules });
|
return NextResponse.json({ rules });
|
||||||
}, ['ADMIN', 'DIRECTOR']);
|
}, ['admin', 'director']);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export const GET = withAuth(async (req, { prisma }) => {
|
||||||
return NextResponse.json({ plans });
|
return NextResponse.json({ plans });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST: Create a new plan (restricted to ADMIN and DIRECTOR)
|
// POST: Create a new plan (restricted to admin and director)
|
||||||
export const POST = withAuth(async (req, { session, prisma }) => {
|
export const POST = withAuth(async (req, { session, prisma }) => {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { name, code, validityStart, validityEnd, type, formula, metaAmount, percentageRate, maxCap, status } = body;
|
const { name, code, validityStart, validityEnd, type, formula, metaAmount, percentageRate, maxCap, status } = body;
|
||||||
|
|
@ -30,7 +30,7 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
||||||
// Validate mandatory fields
|
// Validate mandatory fields
|
||||||
if (!name || !code || !validityStart || !type) {
|
if (!name || !code || !validityStart || !type) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Name, code, validityStart, and type are required mandatory fields' },
|
{ error: 'El nombre, código, fecha de inicio de vigencia y tipo son campos obligatorios.' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -53,4 +53,4 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ plan }, { status: 201 });
|
return NextResponse.json({ plan }, { status: 201 });
|
||||||
}, ['ADMIN', 'DIRECTOR']);
|
}, ['admin', 'director']);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPrisma } from '@/lib/db';
|
import { getPrisma } from '@/lib/db';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
function safeCompare(a: string, b: string): boolean {
|
||||||
|
const bufA = Buffer.from(a);
|
||||||
|
const bufB = Buffer.from(b);
|
||||||
|
if (bufA.length !== bufB.length) {
|
||||||
|
crypto.timingSafeEqual(bufA, bufA);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return crypto.timingSafeEqual(bufA, bufB);
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const signature = req.headers.get('x-n8n-signature');
|
const signature = req.headers.get('x-n8n-signature') || '';
|
||||||
const expectedSecret = process.env.N8N_WEBHOOK_SECRET;
|
const expectedSecret = process.env.N8N_WEBHOOK_SECRET || '';
|
||||||
|
|
||||||
if (!expectedSecret || signature !== expectedSecret) {
|
if (!expectedSecret || !signature || !safeCompare(signature, expectedSecret)) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
|
|
@ -17,102 +28,169 @@ export async function POST(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { idempotencyKey, uploaderId, sales } = body;
|
const { idempotencyKey, uploaderId, sales, status, errorMessage } = body;
|
||||||
|
|
||||||
if (!idempotencyKey || !uploaderId || !Array.isArray(sales)) {
|
if (!idempotencyKey || !uploaderId) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
metadata: {}
|
metadata: { message: 'idempotencyKey and uploaderId are required.' }
|
||||||
}
|
}
|
||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const prisma = getPrisma();
|
const prisma = getPrisma();
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx: any) => {
|
// Support n8n failure reporting
|
||||||
// Elevate privileges to admin role to bypass RLS for n8n batch operations
|
if (status === 'FAILED') {
|
||||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
await prisma.salesImportJob.upsert({
|
||||||
|
where: { idempotencyKey },
|
||||||
// 1. Check idempotency
|
create: {
|
||||||
const existing = await tx.salesResult.findMany({
|
idempotencyKey,
|
||||||
where: {
|
status: 'FAILED',
|
||||||
idempotencyKey: {
|
errorMessage: errorMessage || 'n8n background processing failed',
|
||||||
startsWith: `${idempotencyKey}-`
|
uploadedBy: uploaderId
|
||||||
}
|
},
|
||||||
|
update: {
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: errorMessage || 'n8n background processing failed'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
code: 'IMPORT_FAILED_REPORTED',
|
||||||
|
metadata: { idempotencyKey }
|
||||||
|
}, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (!Array.isArray(sales)) {
|
||||||
return {
|
return NextResponse.json({
|
||||||
alreadyProcessed: true,
|
success: false,
|
||||||
count: existing.length
|
error: {
|
||||||
};
|
code: 'BAD_REQUEST',
|
||||||
}
|
metadata: { message: 'sales must be an array.' }
|
||||||
|
|
||||||
// 2. Resolve usernames and hotel codes
|
|
||||||
const uniqueUsernames = Array.from(new Set(sales.map(s => s.username).filter(Boolean))) as string[];
|
|
||||||
const uniqueHotelCodes = Array.from(new Set(sales.map(s => s.hotelCode).filter(Boolean))) as string[];
|
|
||||||
|
|
||||||
const dbUsers = await tx.user.findMany({
|
|
||||||
where: { username: { in: uniqueUsernames } }
|
|
||||||
});
|
|
||||||
|
|
||||||
const dbHotels = await tx.hotel.findMany({
|
|
||||||
where: { code: { in: uniqueHotelCodes } }
|
|
||||||
});
|
|
||||||
|
|
||||||
const userMap = new Map<string, any>(dbUsers.map((u: any) => [u.username, u]));
|
|
||||||
const hotelMap = new Map<string, any>(dbHotels.map((h: any) => [h.code, h]));
|
|
||||||
|
|
||||||
// 3. Save records
|
|
||||||
const createdSales = [];
|
|
||||||
for (let i = 0; i < sales.length; i++) {
|
|
||||||
const s = sales[i];
|
|
||||||
const user = userMap.get(s.username);
|
|
||||||
const hotel = hotelMap.get(s.hotelCode);
|
|
||||||
|
|
||||||
if (!user || !hotel) {
|
|
||||||
throw new Error(`User or Hotel not found for record: ${JSON.stringify(s)}`);
|
|
||||||
}
|
}
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const created = await tx.salesResult.create({
|
let result;
|
||||||
data: {
|
try {
|
||||||
source: 'API',
|
result = await prisma.$transaction(async (tx: any) => {
|
||||||
hotelId: hotel.id,
|
// Elevate privileges to admin role to bypass RLS for n8n batch operations
|
||||||
userId: user.id,
|
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||||
period: s.period,
|
|
||||||
amount: Number(s.amount),
|
// 1. Check idempotency
|
||||||
salesCount: Number(s.salesCount),
|
const existing = await tx.salesResult.findMany({
|
||||||
idempotencyKey: `${idempotencyKey}-${i}`,
|
where: {
|
||||||
transactionId: s.transactionId || null,
|
idempotencyKey: {
|
||||||
uploadedBy: uploaderId,
|
startsWith: `${idempotencyKey}-`
|
||||||
status: 'PENDING'
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
createdSales.push(created);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Create Audit Log
|
if (existing.length > 0) {
|
||||||
await tx.auditLog.create({
|
return {
|
||||||
data: {
|
alreadyProcessed: true,
|
||||||
userId: uploaderId,
|
count: existing.length
|
||||||
action: 'CREATE',
|
};
|
||||||
targetTable: 'sales_results',
|
|
||||||
targetId: createdSales[0]?.id || 0,
|
|
||||||
newValue: {
|
|
||||||
count: createdSales.length,
|
|
||||||
idempotencyKey
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
// 2. Resolve usernames and hotel codes
|
||||||
alreadyProcessed: false,
|
const uniqueUsernames = Array.from(new Set(sales.map(s => s.username).filter(Boolean))) as string[];
|
||||||
count: createdSales.length
|
const uniqueHotelCodes = Array.from(new Set(sales.map(s => s.hotelCode).filter(Boolean))) as string[];
|
||||||
};
|
|
||||||
});
|
const dbUsers = await tx.user.findMany({
|
||||||
|
where: { username: { in: uniqueUsernames } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbHotels = await tx.hotel.findMany({
|
||||||
|
where: { code: { in: uniqueHotelCodes } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const userMap = new Map<string, any>(dbUsers.map((u: any) => [u.username, u]));
|
||||||
|
const hotelMap = new Map<string, any>(dbHotels.map((h: any) => [h.code, h]));
|
||||||
|
|
||||||
|
// 3. Save records
|
||||||
|
const createdSales = [];
|
||||||
|
for (let i = 0; i < sales.length; i++) {
|
||||||
|
const s = sales[i];
|
||||||
|
const user = userMap.get(s.username);
|
||||||
|
const hotel = hotelMap.get(s.hotelCode);
|
||||||
|
|
||||||
|
if (!user || !hotel) {
|
||||||
|
throw new Error(`User or Hotel not found for record: ${JSON.stringify(s)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await tx.salesResult.create({
|
||||||
|
data: {
|
||||||
|
source: 'API',
|
||||||
|
hotelId: hotel.id,
|
||||||
|
userId: user.id,
|
||||||
|
period: s.period,
|
||||||
|
amount: Number(s.amount),
|
||||||
|
salesCount: Number(s.salesCount),
|
||||||
|
idempotencyKey: `${idempotencyKey}-${i}`,
|
||||||
|
transactionId: s.transactionId || null,
|
||||||
|
uploadedBy: uploaderId,
|
||||||
|
status: 'PENDING'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
createdSales.push(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create Audit Log
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: uploaderId,
|
||||||
|
action: 'CREATE',
|
||||||
|
targetTable: 'sales_results',
|
||||||
|
targetId: createdSales[0]?.id || 0,
|
||||||
|
newValue: {
|
||||||
|
count: createdSales.length,
|
||||||
|
idempotencyKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update import job to SUCCESS
|
||||||
|
await tx.salesImportJob.upsert({
|
||||||
|
where: { idempotencyKey },
|
||||||
|
create: {
|
||||||
|
idempotencyKey,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
uploadedBy: uploaderId
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
status: 'SUCCESS'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
alreadyProcessed: false,
|
||||||
|
count: createdSales.length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (txErr: any) {
|
||||||
|
try {
|
||||||
|
await prisma.salesImportJob.upsert({
|
||||||
|
where: { idempotencyKey },
|
||||||
|
create: {
|
||||||
|
idempotencyKey,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: txErr.message,
|
||||||
|
uploadedBy: uploaderId
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: txErr.message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (upsertErr) {
|
||||||
|
console.error('Failed to upsert failed status:', upsertErr);
|
||||||
|
}
|
||||||
|
throw txErr;
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -15,26 +15,49 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Check idempotency
|
// 1. Check idempotency / job status
|
||||||
const existingSales = await prisma.salesResult.findMany({
|
const existingJob = await prisma.salesImportJob.findUnique({
|
||||||
where: {
|
where: { idempotencyKey }
|
||||||
idempotencyKey: {
|
|
||||||
startsWith: `${idempotencyKey}-`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingSales.length > 0) {
|
if (existingJob) {
|
||||||
const totalAmount = existingSales.reduce((acc: number, cur: any) => acc + Number(cur.amount), 0);
|
if (existingJob.status === 'SUCCESS') {
|
||||||
return NextResponse.json({
|
const existingSales = await prisma.salesResult.findMany({
|
||||||
success: true,
|
where: {
|
||||||
code: 'IMPORT_ALREADY_PROCESSED',
|
idempotencyKey: {
|
||||||
metadata: {
|
startsWith: `${idempotencyKey}-`
|
||||||
count: existingSales.length,
|
}
|
||||||
totalAmount,
|
}
|
||||||
idempotencyKey
|
});
|
||||||
}
|
const totalAmount = existingSales.reduce((acc: number, cur: any) => acc + Number(cur.amount), 0);
|
||||||
});
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
code: 'IMPORT_ALREADY_PROCESSED',
|
||||||
|
metadata: {
|
||||||
|
count: existingSales.length,
|
||||||
|
totalAmount,
|
||||||
|
idempotencyKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingJob.status === 'PROCESSING') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
code: 'IMPORT_ACCEPTED',
|
||||||
|
metadata: {
|
||||||
|
idempotencyKey,
|
||||||
|
status: 'PROCESSING'
|
||||||
|
}
|
||||||
|
}, { status: 202 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If FAILED, allow retrying by deleting the old failed job
|
||||||
|
if (existingJob.status === 'FAILED') {
|
||||||
|
await prisma.salesImportJob.delete({
|
||||||
|
where: { idempotencyKey }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Parse file
|
// 2. Parse file
|
||||||
|
|
@ -227,100 +250,169 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Dispatch to n8n or direct save
|
// Create the SalesImportJob record first
|
||||||
|
await prisma.salesImportJob.create({
|
||||||
|
data: {
|
||||||
|
idempotencyKey,
|
||||||
|
status: 'PROCESSING',
|
||||||
|
uploadedBy: session.userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Dispatch to n8n or direct save
|
||||||
const useN8n = process.env.N8N_WEBHOOK_URL && process.env.NODE_ENV !== 'test' && process.env.IS_E2E_TEST !== 'true' && !req.nextUrl.searchParams.has('direct');
|
const useN8n = process.env.N8N_WEBHOOK_URL && process.env.NODE_ENV !== 'test' && process.env.IS_E2E_TEST !== 'true' && !req.nextUrl.searchParams.has('direct');
|
||||||
|
|
||||||
if (useN8n) {
|
if (useN8n) {
|
||||||
const response = await fetch(process.env.N8N_WEBHOOK_URL!, {
|
try {
|
||||||
method: 'POST',
|
const response = await fetch(process.env.N8N_WEBHOOK_URL!, {
|
||||||
headers: {
|
method: 'POST',
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET || ''
|
'Content-Type': 'application/json',
|
||||||
},
|
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET || ''
|
||||||
body: JSON.stringify({
|
},
|
||||||
idempotencyKey,
|
body: JSON.stringify({
|
||||||
uploaderId: session.userId,
|
idempotencyKey,
|
||||||
sales: rows.map(r => ({
|
uploaderId: session.userId,
|
||||||
username: r.username,
|
sales: rows.map(r => ({
|
||||||
hotelCode: r.hotelCode,
|
username: r.username,
|
||||||
period: r.period,
|
hotelCode: r.hotelCode,
|
||||||
amount: r.amount,
|
period: r.period,
|
||||||
salesCount: r.salesCount,
|
amount: r.amount,
|
||||||
transactionId: r.transactionId
|
salesCount: r.salesCount,
|
||||||
}))
|
transactionId: r.transactionId
|
||||||
})
|
}))
|
||||||
});
|
})
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('n8n integration failed:', await response.text());
|
console.error('n8n integration failed:', await response.text());
|
||||||
return NextResponse.json({
|
await prisma.salesImportJob.upsert({
|
||||||
success: false,
|
where: { idempotencyKey },
|
||||||
error: {
|
create: {
|
||||||
code: 'INTEGRATION_ERROR',
|
idempotencyKey,
|
||||||
metadata: { status: response.status }
|
status: 'FAILED',
|
||||||
}
|
errorMessage: `n8n webhook responded with status ${response.status}`,
|
||||||
}, { status: 500 });
|
uploadedBy: session.userId
|
||||||
}
|
},
|
||||||
|
update: {
|
||||||
return NextResponse.json({
|
status: 'FAILED',
|
||||||
success: true,
|
errorMessage: `n8n webhook responded with status ${response.status}`
|
||||||
code: 'IMPORT_ACCEPTED',
|
}
|
||||||
metadata: {
|
});
|
||||||
idempotencyKey,
|
return NextResponse.json({
|
||||||
status: 'PROCESSING'
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INTEGRATION_ERROR',
|
||||||
|
metadata: { status: response.status }
|
||||||
|
}
|
||||||
|
}, { status: 500 });
|
||||||
}
|
}
|
||||||
}, { status: 202 });
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
code: 'IMPORT_ACCEPTED',
|
||||||
|
metadata: {
|
||||||
|
idempotencyKey,
|
||||||
|
status: 'PROCESSING'
|
||||||
|
}
|
||||||
|
}, { status: 202 });
|
||||||
|
} catch (err: any) {
|
||||||
|
await prisma.salesImportJob.upsert({
|
||||||
|
where: { idempotencyKey },
|
||||||
|
create: {
|
||||||
|
idempotencyKey,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: err.message,
|
||||||
|
uploadedBy: session.userId
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: err.message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct Import Fallback / Test mode
|
// Direct Import Fallback / Test mode
|
||||||
const totalAmount = rows.reduce((acc, r) => acc + r.amount, 0);
|
const totalAmount = rows.reduce((acc, r) => acc + r.amount, 0);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
try {
|
||||||
const createdSales = [];
|
await prisma.$transaction(async (tx: any) => {
|
||||||
for (let i = 0; i < rows.length; i++) {
|
const createdSales = [];
|
||||||
const r = rows[i];
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const user = userMap.get(r.username)!;
|
const r = rows[i];
|
||||||
const hotel = hotelMap.get(r.hotelCode)!;
|
const user = userMap.get(r.username)!;
|
||||||
|
const hotel = hotelMap.get(r.hotelCode)!;
|
||||||
|
|
||||||
const created = await tx.salesResult.create({
|
const created = await tx.salesResult.create({
|
||||||
|
data: {
|
||||||
|
source: 'EXCEL',
|
||||||
|
hotelId: hotel.id,
|
||||||
|
userId: user.id,
|
||||||
|
period: r.period,
|
||||||
|
amount: r.amount,
|
||||||
|
salesCount: r.salesCount,
|
||||||
|
idempotencyKey: `${idempotencyKey}-${i}`,
|
||||||
|
transactionId: r.transactionId || null,
|
||||||
|
uploadedBy: session.userId,
|
||||||
|
status: 'PENDING'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
createdSales.push(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
source: 'EXCEL',
|
userId: session.userId,
|
||||||
hotelId: hotel.id,
|
action: 'CREATE',
|
||||||
userId: user.id,
|
targetTable: 'sales_results',
|
||||||
period: r.period,
|
targetId: createdSales[0]?.id || 0,
|
||||||
amount: r.amount,
|
newValue: {
|
||||||
salesCount: r.salesCount,
|
count: createdSales.length,
|
||||||
idempotencyKey: `${idempotencyKey}-${i}`,
|
idempotencyKey
|
||||||
transactionId: r.transactionId || null,
|
}
|
||||||
uploadedBy: session.userId,
|
|
||||||
status: 'PENDING'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
createdSales.push(created);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.auditLog.create({
|
// Update import job to SUCCESS
|
||||||
data: {
|
await tx.salesImportJob.upsert({
|
||||||
userId: session.userId,
|
where: { idempotencyKey },
|
||||||
action: 'CREATE',
|
create: {
|
||||||
targetTable: 'sales_results',
|
idempotencyKey,
|
||||||
targetId: createdSales[0]?.id || 0,
|
status: 'SUCCESS',
|
||||||
newValue: {
|
uploadedBy: session.userId
|
||||||
count: createdSales.length,
|
},
|
||||||
idempotencyKey
|
update: {
|
||||||
|
status: 'SUCCESS'
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
code: 'IMPORT_SUCCESSFUL',
|
||||||
|
metadata: {
|
||||||
|
count: rows.length,
|
||||||
|
totalAmount
|
||||||
|
}
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (txErr: any) {
|
||||||
|
await prisma.salesImportJob.upsert({
|
||||||
|
where: { idempotencyKey },
|
||||||
|
create: {
|
||||||
|
idempotencyKey,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: txErr.message,
|
||||||
|
uploadedBy: session.userId
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: txErr.message
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
throw txErr;
|
||||||
|
}
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
code: 'IMPORT_SUCCESSFUL',
|
|
||||||
metadata: {
|
|
||||||
count: rows.length,
|
|
||||||
totalAmount
|
|
||||||
}
|
|
||||||
}, { status: 201 });
|
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Sales import error:', err);
|
console.error('Sales import error:', err);
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,8 @@ export const GET = withAuth(async (req, { prisma, params }) => {
|
||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = await prisma.salesResult.count({
|
const job = await prisma.salesImportJob.findUnique({
|
||||||
where: {
|
where: { idempotencyKey: key }
|
||||||
idempotencyKey: {
|
|
||||||
startsWith: `${key}-`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
@ -28,7 +24,8 @@ export const GET = withAuth(async (req, { prisma, params }) => {
|
||||||
code: 'STATUS_CHECKED',
|
code: 'STATUS_CHECKED',
|
||||||
metadata: {
|
metadata: {
|
||||||
idempotencyKey: key,
|
idempotencyKey: key,
|
||||||
status: count > 0 ? 'SUCCESS' : 'PROCESSING'
|
status: job ? job.status : 'PROCESSING',
|
||||||
|
errorMessage: job ? job.errorMessage : null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, ['admin', 'analyst', 'commercial_leader']);
|
}, ['admin', 'analyst', 'commercial_leader']);
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,4 @@ export const GET = withAuth(async (req, { prisma }) => {
|
||||||
console.error('Failed to fetch users:', err);
|
console.error('Failed to fetch users:', err);
|
||||||
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}, ['ADMIN', 'DIRECTOR']);
|
}, ['admin', 'director']);
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@
|
||||||
--space-12: 3rem; /* 48px */
|
--space-12: 3rem; /* 48px */
|
||||||
|
|
||||||
/* Typography Scale */
|
/* Typography Scale */
|
||||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
--font-sans: var(--font-geist-sans), 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: var(--font-geist-mono), monospace;
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
--text-base: 1rem;
|
--text-base: 1rem;
|
||||||
|
|
@ -130,3 +131,14 @@ a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fix select and option dropdown styling for dark/light mode compatibility */
|
||||||
|
select {
|
||||||
|
background-color: var(--card) !important;
|
||||||
|
color: var(--foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background-color: var(--card);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,13 +143,14 @@
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color var(--transition-fast);
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnPrimary {
|
.btnPrimary {
|
||||||
|
|
@ -161,12 +162,17 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow var(--transition-fast);
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnPrimary:hover {
|
.btnPrimary:hover {
|
||||||
box-shadow: var(--shadow-sm), var(--shadow-glow);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md), var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary:active {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorMsg {
|
.errorMsg {
|
||||||
|
|
@ -177,6 +183,12 @@
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .errorMsg {
|
||||||
|
background-color: hsla(0, 85%, 20%, 0.15);
|
||||||
|
border: 1px solid hsla(0, 85%, 50%, 0.3);
|
||||||
|
color: hsl(0, 85%, 60%);
|
||||||
|
}
|
||||||
|
|
||||||
.successMsg {
|
.successMsg {
|
||||||
color: hsl(120, 80%, 30%);
|
color: hsl(120, 80%, 30%);
|
||||||
background-color: hsl(120, 80%, 95%);
|
background-color: hsl(120, 80%, 95%);
|
||||||
|
|
@ -185,6 +197,12 @@
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .successMsg {
|
||||||
|
background-color: hsla(120, 80%, 20%, 0.15);
|
||||||
|
border: 1px solid hsla(120, 80%, 50%, 0.3);
|
||||||
|
color: hsl(120, 80%, 60%);
|
||||||
|
}
|
||||||
|
|
||||||
.tableContainer {
|
.tableContainer {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -238,3 +256,8 @@
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fullWidth {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import React, { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import styles from './page.module.css';
|
import styles from './page.module.css';
|
||||||
|
import Header from '@/components/Header';
|
||||||
|
import { getRoleBilingualLabel } from '@/lib/roles';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -32,6 +34,7 @@ export default function GoalsPage() {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [goals, setGoals] = useState<Goal[]>([]);
|
const [goals, setGoals] = useState<Goal[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [role, setRole] = useState<string | null>(null);
|
||||||
|
|
||||||
// Form State
|
// Form State
|
||||||
const [targetType, setTargetType] = useState('INDIVIDUAL');
|
const [targetType, setTargetType] = useState('INDIVIDUAL');
|
||||||
|
|
@ -67,14 +70,16 @@ export default function GoalsPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me')
|
||||||
|
.then(res => res.ok ? res.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (data?.user) setRole(data.user.role);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to fetch user role', err));
|
||||||
fetchUsersAndGoals();
|
fetchUsersAndGoals();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
router.push('/login');
|
|
||||||
router.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAssignGoal = async (e: React.FormEvent) => {
|
const handleAssignGoal = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -127,7 +132,7 @@ export default function GoalsPage() {
|
||||||
if (type === 'INDIVIDUAL') {
|
if (type === 'INDIVIDUAL') {
|
||||||
const user = users.find(u => u.id === id);
|
const user = users.find(u => u.id === id);
|
||||||
if (user) {
|
if (user) {
|
||||||
return `${user.username} (${user.role} - ${user.hotel?.name || ''})`;
|
return `${user.username} (${getRoleBilingualLabel(user.role)} - ${user.hotel?.name || ''})`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `${type} ID: ${id}`;
|
return `${type} ID: ${id}`;
|
||||||
|
|
@ -136,22 +141,7 @@ export default function GoalsPage() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Shared Dashboard Header */}
|
{/* Shared Dashboard Header */}
|
||||||
<header className={styles.header}>
|
<Header activeTab="goals" />
|
||||||
<div className={styles.logoArea}>
|
|
||||||
<span className={styles.logoText}>Remuneración Estelar</span>
|
|
||||||
</div>
|
|
||||||
<nav className={styles.nav}>
|
|
||||||
<Link href="/plans" className={styles.navLink}>
|
|
||||||
Planes de Comisión
|
|
||||||
</Link>
|
|
||||||
<Link href="/goals" className={`${styles.navLink} ${styles.navLinkActive}`}>
|
|
||||||
Metas Comerciales
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
<button onClick={handleLogout} className={styles.logoutBtn}>
|
|
||||||
Cerrar Sesión
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<div className={styles.titleSection}>
|
<div className={styles.titleSection}>
|
||||||
|
|
@ -159,94 +149,96 @@ export default function GoalsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Goal Assignment Form Section */}
|
{/* Goal Assignment Form Section */}
|
||||||
<section className={styles.section}>
|
{(role === 'admin' || role === 'director') && (
|
||||||
<h2 className={styles.sectionTitle}>Asignar Meta</h2>
|
<section className={styles.section}>
|
||||||
{error && <div className={styles.errorMsg} id="goal-error-msg">{error}</div>}
|
<h2 className={styles.sectionTitle}>Asignar Meta</h2>
|
||||||
{success && <div className={styles.successMsg} id="goal-success-msg">{success}</div>}
|
{error && <div className={styles.errorMsg} id="goal-error-msg">{error}</div>}
|
||||||
|
{success && <div className={styles.successMsg} id="goal-success-msg">{success}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleAssignGoal} className={styles.form}>
|
<form onSubmit={handleAssignGoal} className={styles.form}>
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label className={styles.label} htmlFor="goal-target-type">Tipo de Meta</label>
|
|
||||||
<select
|
|
||||||
id="goal-target-type"
|
|
||||||
className={styles.input}
|
|
||||||
value={targetType}
|
|
||||||
onChange={(e) => setTargetType(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="INDIVIDUAL">Colaborador Individual</option>
|
|
||||||
<option value="TEAM">Equipo</option>
|
|
||||||
<option value="HOTEL">Hotel</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{targetType === 'INDIVIDUAL' && (
|
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label className={styles.label} htmlFor="goal-target-id">Seleccionar Colaborador</label>
|
<label className={styles.label} htmlFor="goal-target-type">Tipo de Meta</label>
|
||||||
<select
|
<select
|
||||||
id="goal-target-id"
|
id="goal-target-type"
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
value={targetId}
|
value={targetType}
|
||||||
onChange={(e) => setTargetId(e.target.value)}
|
onChange={(e) => setTargetType(e.target.value)}
|
||||||
required
|
|
||||||
>
|
>
|
||||||
{users.map((u) => (
|
<option value="INDIVIDUAL">Colaborador Individual</option>
|
||||||
<option key={u.id} value={u.id}>
|
<option value="TEAM">Equipo</option>
|
||||||
{u.username} ({u.role} - {u.hotel?.code})
|
<option value="HOTEL">Hotel</option>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{targetType !== 'INDIVIDUAL' && (
|
{targetType === 'INDIVIDUAL' && (
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label} htmlFor="goal-target-id">Seleccionar Colaborador</label>
|
||||||
|
<select
|
||||||
|
id="goal-target-id"
|
||||||
|
className={styles.input}
|
||||||
|
value={targetId}
|
||||||
|
onChange={(e) => setTargetId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.username} ({getRoleBilingualLabel(u.role)} - {u.hotel?.code})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{targetType !== 'INDIVIDUAL' && (
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label} htmlFor="goal-target-id-input">ID del Objetivo (Hotel/Equipo)</label>
|
||||||
|
<input
|
||||||
|
id="goal-target-id-input"
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
value={targetId}
|
||||||
|
onChange={(e) => setTargetId(e.target.value)}
|
||||||
|
placeholder="Ej. ID de Hotel o Equipo"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label className={styles.label} htmlFor="goal-target-id-input">ID del Objetivo (Hotel/Equipo)</label>
|
<label className={styles.label} htmlFor="goal-period">Período (Mes/Año)</label>
|
||||||
<input
|
<input
|
||||||
id="goal-target-id-input"
|
id="goal-period"
|
||||||
type="number"
|
type="month"
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
value={targetId}
|
value={period}
|
||||||
onChange={(e) => setTargetId(e.target.value)}
|
onChange={(e) => setPeriod(e.target.value)}
|
||||||
placeholder="Ej. ID de Hotel o Equipo"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label className={styles.label} htmlFor="goal-period">Período (Mes/Año)</label>
|
<label className={styles.label} htmlFor="goal-amount">Monto Quota ($)</label>
|
||||||
<input
|
<input
|
||||||
id="goal-period"
|
id="goal-amount"
|
||||||
type="month"
|
type="number"
|
||||||
className={styles.input}
|
step="0.01"
|
||||||
value={period}
|
className={styles.input}
|
||||||
onChange={(e) => setPeriod(e.target.value)}
|
value={amount}
|
||||||
required
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
/>
|
placeholder="Ej. 50000.00"
|
||||||
</div>
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
<button type="submit" className={styles.btnPrimary} id="btn-save-goal">
|
||||||
<label className={styles.label} htmlFor="goal-amount">Monto Quota ($)</label>
|
Asignar Meta
|
||||||
<input
|
</button>
|
||||||
id="goal-amount"
|
</form>
|
||||||
type="number"
|
</section>
|
||||||
step="0.01"
|
)}
|
||||||
className={styles.input}
|
|
||||||
value={amount}
|
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
|
||||||
placeholder="Ej. 50000.00"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" className={styles.btnPrimary} id="btn-save-goal">
|
|
||||||
Asignar Meta
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Configured Goals List Section */}
|
{/* Configured Goals List Section */}
|
||||||
<section className={styles.section} style={{ gridColumn: 'span 1' }}>
|
<section className={`${styles.section} ${!(role === 'admin' || role === 'director') ? styles.fullWidth : ''}`}>
|
||||||
<h2 className={styles.sectionTitle}>Historial de Metas</h2>
|
<h2 className={styles.sectionTitle}>Historial de Metas</h2>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>Cargando metas...</div>
|
<div style={{ textAlign: 'center', padding: '20px' }}>Cargando metas...</div>
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Remuneración Estelar - Hoteles Estelar",
|
||||||
description: "Generated by create next app",
|
description: "Sistema de Remuneración Variable, Compensaciones y Comisiones de Hoteles Estelar",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,7 @@ function LoginForm() {
|
||||||
// Read return url if any
|
// Read return url if any
|
||||||
const callbackUrl = searchParams.get('callbackUrl') || '/';
|
const callbackUrl = searchParams.get('callbackUrl') || '/';
|
||||||
|
|
||||||
// Clear session on component mount just in case
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -44,8 +41,7 @@ function LoginForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to target dashboard
|
// Redirect to target dashboard
|
||||||
router.push(callbackUrl);
|
window.location.href = callbackUrl;
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('An unexpected error occurred. Please try again.');
|
setError('An unexpected error occurred. Please try again.');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -111,13 +111,14 @@
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color var(--transition-fast);
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnDelete {
|
.btnDelete {
|
||||||
|
|
@ -128,6 +129,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnDelete:hover {
|
.btnDelete:hover {
|
||||||
|
|
@ -141,13 +143,19 @@
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
|
transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnSecondary:hover {
|
.btnSecondary:hover {
|
||||||
background-color: var(--border);
|
background-color: var(--border);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSecondary:active {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footerActions {
|
.footerActions {
|
||||||
|
|
@ -168,10 +176,16 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnPrimary:hover {
|
.btnPrimary:hover {
|
||||||
box-shadow: var(--shadow-sm), var(--shadow-glow);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md), var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary:active {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.successMsg {
|
.successMsg {
|
||||||
|
|
@ -182,6 +196,12 @@
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .successMsg {
|
||||||
|
background-color: hsla(120, 80%, 20%, 0.15);
|
||||||
|
border: 1px solid hsla(120, 80%, 50%, 0.3);
|
||||||
|
color: hsl(120, 80%, 60%);
|
||||||
|
}
|
||||||
|
|
||||||
.errorMsg {
|
.errorMsg {
|
||||||
color: hsl(0, 85%, 60%);
|
color: hsl(0, 85%, 60%);
|
||||||
background-color: hsl(0, 85%, 97%);
|
background-color: hsl(0, 85%, 97%);
|
||||||
|
|
@ -189,3 +209,9 @@
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .errorMsg {
|
||||||
|
background-color: hsla(0, 85%, 20%, 0.15);
|
||||||
|
border: 1px solid hsla(0, 85%, 50%, 0.3);
|
||||||
|
color: hsl(0, 85%, 60%);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import React, { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import styles from './page.module.css';
|
import styles from './page.module.css';
|
||||||
|
import Header from '@/components/Header';
|
||||||
|
|
||||||
interface Rule {
|
interface Rule {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|
@ -36,6 +37,7 @@ export default function RulesPage() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [role, setRole] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchPlan = async () => {
|
const fetchPlan = async () => {
|
||||||
if (!planId) return;
|
if (!planId) return;
|
||||||
|
|
@ -80,14 +82,16 @@ export default function RulesPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me')
|
||||||
|
.then(res => res.ok ? res.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (data?.user) setRole(data.user.role);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to fetch user role', err));
|
||||||
fetchPlan();
|
fetchPlan();
|
||||||
}, [planId]);
|
}, [planId]);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
router.push('/login');
|
|
||||||
router.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddRow = () => {
|
const handleAddRow = () => {
|
||||||
// Generate a default values row, potentially continuing from previous max
|
// Generate a default values row, potentially continuing from previous max
|
||||||
|
|
@ -191,22 +195,7 @@ export default function RulesPage() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Shared Dashboard Header */}
|
{/* Shared Dashboard Header */}
|
||||||
<header className={styles.header}>
|
<Header activeTab="plans" />
|
||||||
<div className={styles.logoArea}>
|
|
||||||
<span className={styles.logoText}>Remuneración Estelar</span>
|
|
||||||
</div>
|
|
||||||
<nav className={styles.nav}>
|
|
||||||
<Link href="/plans" className={`${styles.navLink} ${styles.navLinkActive}`}>
|
|
||||||
Planes de Comisión
|
|
||||||
</Link>
|
|
||||||
<Link href="/goals" className={styles.navLink}>
|
|
||||||
Metas Comerciales
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
<button onClick={handleLogout} className={styles.btnSecondary} style={{ margin: 0 }}>
|
|
||||||
Cerrar Sesión
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<div className={styles.titleArea}>
|
<div className={styles.titleArea}>
|
||||||
|
|
@ -241,6 +230,7 @@ export default function RulesPage() {
|
||||||
value={rule.type}
|
value={rule.type}
|
||||||
onChange={(e) => handleUpdateRow(rule.tempId!, 'type', e.target.value)}
|
onChange={(e) => handleUpdateRow(rule.tempId!, 'type', e.target.value)}
|
||||||
id={`rule-type-${idx}`}
|
id={`rule-type-${idx}`}
|
||||||
|
disabled={!(role === 'admin' || role === 'director')}
|
||||||
>
|
>
|
||||||
<option value="TIER">Rango (TIER)</option>
|
<option value="TIER">Rango (TIER)</option>
|
||||||
<option value="BONUS">Bono Fijo (BONUS)</option>
|
<option value="BONUS">Bono Fijo (BONUS)</option>
|
||||||
|
|
@ -254,6 +244,7 @@ export default function RulesPage() {
|
||||||
onChange={(e) => handleUpdateRow(rule.tempId!, 'minAchievement', e.target.value)}
|
onChange={(e) => handleUpdateRow(rule.tempId!, 'minAchievement', e.target.value)}
|
||||||
id={`rule-min-${idx}`}
|
id={`rule-min-${idx}`}
|
||||||
required
|
required
|
||||||
|
disabled={!(role === 'admin' || role === 'director')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
@ -264,6 +255,7 @@ export default function RulesPage() {
|
||||||
onChange={(e) => handleUpdateRow(rule.tempId!, 'maxAchievement', e.target.value)}
|
onChange={(e) => handleUpdateRow(rule.tempId!, 'maxAchievement', e.target.value)}
|
||||||
id={`rule-max-${idx}`}
|
id={`rule-max-${idx}`}
|
||||||
required
|
required
|
||||||
|
disabled={!(role === 'admin' || role === 'director')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
@ -274,6 +266,7 @@ export default function RulesPage() {
|
||||||
onChange={(e) => handleUpdateRow(rule.tempId!, 'rate', e.target.value)}
|
onChange={(e) => handleUpdateRow(rule.tempId!, 'rate', e.target.value)}
|
||||||
id={`rule-rate-${idx}`}
|
id={`rule-rate-${idx}`}
|
||||||
required
|
required
|
||||||
|
disabled={!(role === 'admin' || role === 'director')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
@ -284,37 +277,44 @@ export default function RulesPage() {
|
||||||
onChange={(e) => handleUpdateRow(rule.tempId!, 'payoutAmount', e.target.value)}
|
onChange={(e) => handleUpdateRow(rule.tempId!, 'payoutAmount', e.target.value)}
|
||||||
id={`rule-payout-${idx}`}
|
id={`rule-payout-${idx}`}
|
||||||
required
|
required
|
||||||
|
disabled={!(role === 'admin' || role === 'director')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{(role === 'admin' || role === 'director') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleDeleteRow(rule.tempId!)}
|
type="button"
|
||||||
className={styles.btnDelete}
|
onClick={() => handleDeleteRow(rule.tempId!)}
|
||||||
id={`btn-delete-rule-${idx}`}
|
className={styles.btnDelete}
|
||||||
title="Eliminar regla"
|
id={`btn-delete-rule-${idx}`}
|
||||||
>
|
title="Eliminar regla"
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{(role === 'admin' || role === 'director') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={handleAddRow}
|
type="button"
|
||||||
className={styles.btnSecondary}
|
onClick={handleAddRow}
|
||||||
id="btn-add-rule"
|
className={styles.btnSecondary}
|
||||||
>
|
id="btn-add-rule"
|
||||||
+ Agregar Rango / Regla
|
>
|
||||||
</button>
|
+ Agregar Rango / Regla
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.footerActions}>
|
<div className={styles.footerActions}>
|
||||||
<Link href="/plans" className={styles.btnSecondary} style={{ marginRight: 'auto' }}>
|
<Link href="/plans" className={styles.btnSecondary} style={{ marginRight: 'auto' }}>
|
||||||
Cancelar
|
{ (role === 'admin' || role === 'director') ? 'Cancelar' : 'Volver' }
|
||||||
</Link>
|
</Link>
|
||||||
<button type="submit" className={styles.btnPrimary} id="btn-save-rules">
|
{(role === 'admin' || role === 'director') && (
|
||||||
Guardar Reglas
|
<button type="submit" className={styles.btnPrimary} id="btn-save-rules">
|
||||||
</button>
|
Guardar Reglas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,10 @@
|
||||||
box-shadow: var(--shadow-md), var(--shadow-glow);
|
box-shadow: var(--shadow-md), var(--shadow-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btnPrimary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -164,13 +168,20 @@
|
||||||
.badge {
|
.badge {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
padding: var(--space-1) var(--space-2);
|
padding: var(--space-1) var(--space-3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-full);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badgeDRAFT {
|
.badgeDRAFT {
|
||||||
background-color: hsl(40, 90%, 93%);
|
background-color: hsl(40, 90%, 93%);
|
||||||
color: hsl(40, 90%, 35%);
|
color: hsl(40, 90%, 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .badgeDRAFT {
|
||||||
|
background-color: hsla(40, 90%, 50%, 0.15);
|
||||||
|
color: hsl(40, 90%, 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badgeACTIVE {
|
.badgeACTIVE {
|
||||||
|
|
@ -178,9 +189,19 @@
|
||||||
color: hsl(120, 80%, 30%);
|
color: hsl(120, 80%, 30%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .badgeACTIVE {
|
||||||
|
background-color: hsla(120, 80%, 50%, 0.15);
|
||||||
|
color: hsl(120, 80%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
.badgeINACTIVE {
|
.badgeINACTIVE {
|
||||||
background-color: hsl(0, 0%, 90%);
|
background-color: hsl(0, 0%, 90%);
|
||||||
color: hsl(0, 0%, 40%);
|
color: hsl(0, 0%, 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .badgeINACTIVE {
|
||||||
|
background-color: hsla(0, 0%, 50%, 0.15);
|
||||||
|
color: hsl(0, 0%, 65%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardTitle {
|
.cardTitle {
|
||||||
|
|
@ -221,14 +242,19 @@
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color var(--transition-fast);
|
transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnSecondary:hover {
|
.btnSecondary:hover {
|
||||||
background-color: var(--border);
|
background-color: var(--border);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSecondary:active {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal and Form styling */
|
/* Modal and Form styling */
|
||||||
|
|
@ -296,18 +322,29 @@
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color var(--transition-fast);
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorText {
|
.errorText {
|
||||||
|
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-xs);
|
font-size: var(--text-xs);
|
||||||
color: hsl(0, 75%, 60%);
|
border: 1px solid hsl(0, 85%, 90%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .errorText {
|
||||||
|
background-color: hsla(0, 85%, 20%, 0.15);
|
||||||
|
border-color: hsla(0, 85%, 50%, 0.3);
|
||||||
|
color: hsl(0, 85%, 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.formActions {
|
.formActions {
|
||||||
|
|
@ -316,3 +353,10 @@
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-8);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import React, { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import styles from './page.module.css';
|
import styles from './page.module.css';
|
||||||
|
import Header from '@/components/Header';
|
||||||
|
|
||||||
interface Plan {
|
interface Plan {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -27,6 +28,7 @@ export default function PlansPage() {
|
||||||
const [plans, setPlans] = useState<Plan[]>([]);
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [role, setRole] = useState<string | null>(null);
|
||||||
|
|
||||||
// Form State
|
// Form State
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
|
@ -54,14 +56,16 @@ export default function PlansPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me')
|
||||||
|
.then(res => res.ok ? res.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (data?.user) setRole(data.user.role);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to fetch user role', err));
|
||||||
fetchPlans();
|
fetchPlans();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
router.push('/login');
|
|
||||||
router.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreatePlan = async (e: React.FormEvent) => {
|
const handleCreatePlan = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -133,33 +137,20 @@ export default function PlansPage() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Shared Dashboard Header */}
|
{/* Shared Dashboard Header */}
|
||||||
<header className={styles.header}>
|
<Header activeTab="plans" />
|
||||||
<div className={styles.logoArea}>
|
|
||||||
<span className={styles.logoText}>Remuneración Estelar</span>
|
|
||||||
</div>
|
|
||||||
<nav className={styles.nav}>
|
|
||||||
<Link href="/plans" className={`${styles.navLink} ${styles.navLinkActive}`}>
|
|
||||||
Planes de Comisión
|
|
||||||
</Link>
|
|
||||||
<Link href="/goals" className={styles.navLink}>
|
|
||||||
Metas Comerciales
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
<button onClick={handleLogout} className={styles.logoutBtn}>
|
|
||||||
Cerrar Sesión
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<div className={styles.titleSection}>
|
<div className={styles.titleSection}>
|
||||||
<h1 className={styles.title}>Planes de Comisión</h1>
|
<h1 className={styles.title}>Planes de Comisión</h1>
|
||||||
<button onClick={() => setShowModal(true)} className={styles.btnPrimary} id="btn-create-plan">
|
{(role === 'admin' || role === 'director') && (
|
||||||
Nuevo Plan
|
<button onClick={() => setShowModal(true)} className={styles.btnPrimary} id="btn-create-plan">
|
||||||
</button>
|
Nuevo Plan
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '40px' }}>Cargando planes...</div>
|
<div className={styles.loading}>Cargando planes...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
|
|
@ -199,16 +190,24 @@ export default function PlansPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.cardFooter}>
|
<div className={styles.cardFooter}>
|
||||||
<Link href={`/plans/${plan.id}/rules`} className={styles.btnSecondary} id={`btn-rules-${plan.id}`}>
|
{ (role === 'admin' || role === 'director') ? (
|
||||||
Configurar Reglas
|
<>
|
||||||
</Link>
|
<Link href={`/plans/${plan.id}/rules`} className={styles.btnSecondary} id={`btn-rules-${plan.id}`}>
|
||||||
<button
|
Configurar Reglas
|
||||||
onClick={() => handleToggleStatus(plan)}
|
</Link>
|
||||||
className={styles.btnSecondary}
|
<button
|
||||||
id={`btn-toggle-status-${plan.id}`}
|
onClick={() => handleToggleStatus(plan)}
|
||||||
>
|
className={styles.btnSecondary}
|
||||||
{plan.status === 'ACTIVE' ? 'Inactivar (Versión)' : 'Activar'}
|
id={`btn-toggle-status-${plan.id}`}
|
||||||
</button>
|
>
|
||||||
|
{plan.status === 'ACTIVE' ? 'Inactivar (Versión)' : 'Activar'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link href={`/plans/${plan.id}/rules`} className={styles.btnSecondary} id={`btn-rules-${plan.id}`}>
|
||||||
|
Ver Reglas
|
||||||
|
</Link>
|
||||||
|
) }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,12 @@ export default function SalesImportPage() {
|
||||||
setProgress(100);
|
setProgress(100);
|
||||||
setSuccessData({ count: data.metadata.count || 0 });
|
setSuccessData({ count: data.metadata.count || 0 });
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
|
} else if (data.metadata?.status === 'FAILED') {
|
||||||
|
clearInterval(interval);
|
||||||
|
setIsUploading(false);
|
||||||
|
setProgress(0);
|
||||||
|
setGeneralError(data.metadata.errorMessage || "Fallo en el procesamiento de n8n.");
|
||||||
|
setStatusMessage('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
71
src/components/Header.module.css
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
.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;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn:hover {
|
||||||
|
background-color: hsla(0, 85%, 60%, 0.1);
|
||||||
|
border-color: hsl(0, 85%, 60%);
|
||||||
|
color: hsl(0, 85%, 60%);
|
||||||
|
}
|
||||||
|
|
@ -21,14 +21,14 @@ export function withAuth(
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Unauthorized. Session expired or missing.' },
|
{ error: 'No autorizado. La sesión ha expirado o no existe.' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowedRoles && allowedRoles.length > 0 && !allowedRoles.includes(session.role)) {
|
if (allowedRoles && allowedRoles.length > 0 && !allowedRoles.includes(session.role)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Forbidden. Insufficient permissions.' },
|
{ error: 'Acceso prohibido. Permisos insuficientes.' },
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ export function withAuth(
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('API guard execution error:', err);
|
console.error('API guard execution error:', err);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Internal server error' },
|
{ error: 'Error interno del servidor.' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ export interface UserSession {
|
||||||
regionId: number;
|
regionId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JWT_SECRET = process.env.NEXTAUTH_SECRET || 'fallback_secret_for_development_jwt_auth';
|
if (!process.env.NEXTAUTH_SECRET) {
|
||||||
|
throw new Error('FATAL: NEXTAUTH_SECRET environment variable is missing.');
|
||||||
|
}
|
||||||
|
const JWT_SECRET = process.env.NEXTAUTH_SECRET;
|
||||||
|
|
||||||
function base64urlDecode(str: string): string {
|
function base64urlDecode(str: string): string {
|
||||||
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import crypto from 'crypto';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const JWT_SECRET = process.env.NEXTAUTH_SECRET || 'fallback_secret_for_development_jwt_auth';
|
if (!process.env.NEXTAUTH_SECRET) {
|
||||||
|
throw new Error('FATAL: NEXTAUTH_SECRET environment variable is missing.');
|
||||||
|
}
|
||||||
|
const JWT_SECRET = process.env.NEXTAUTH_SECRET;
|
||||||
|
|
||||||
export interface UserSession {
|
export interface UserSession {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,30 @@ export const getPrisma = (session?: UserSession | null) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return prisma.$extends({
|
return prisma.$extends({
|
||||||
|
client: {
|
||||||
|
async $transaction(args: any, options?: any) {
|
||||||
|
if (typeof args === 'function') {
|
||||||
|
const originalFn = args;
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
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}';`);
|
||||||
|
return originalFn(tx);
|
||||||
|
}, options);
|
||||||
|
} else if (Array.isArray(args)) {
|
||||||
|
const rlsQueries = [
|
||||||
|
prisma.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`),
|
||||||
|
prisma.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${session.role}';`),
|
||||||
|
prisma.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${session.hotelId}';`),
|
||||||
|
prisma.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${session.regionId}';`),
|
||||||
|
];
|
||||||
|
const results = await prisma.$transaction([...rlsQueries, ...args], options);
|
||||||
|
return results.slice(rlsQueries.length);
|
||||||
|
}
|
||||||
|
return prisma.$transaction(args, options);
|
||||||
|
}
|
||||||
|
},
|
||||||
query: {
|
query: {
|
||||||
$allModels: {
|
$allModels: {
|
||||||
async $allOperations({ args, query, __internalParams }: any) {
|
async $allOperations({ args, query, __internalParams }: any) {
|
||||||
|
|
|
||||||
27
src/lib/roles.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
export interface RoleDetail {
|
||||||
|
id: string;
|
||||||
|
nameEn: string;
|
||||||
|
nameEs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLES: Record<string, RoleDetail> = {
|
||||||
|
admin: { id: 'admin', nameEn: 'Administrator', nameEs: 'Administrador' },
|
||||||
|
director: { id: 'director', nameEn: 'Director', nameEs: 'Director' },
|
||||||
|
hotel_manager: { id: 'hotel_manager', nameEn: 'Hotel Manager', nameEs: 'Gerente de Hotel' },
|
||||||
|
commercial_leader: { id: 'commercial_leader', nameEn: 'Commercial Leader', nameEs: 'Líder Comercial' },
|
||||||
|
analyst: { id: 'analyst', nameEn: 'Analyst', nameEs: 'Analista' },
|
||||||
|
auditor: { id: 'auditor', nameEn: 'Auditor', nameEs: 'Auditor' },
|
||||||
|
collaborator: { id: 'collaborator', nameEn: 'Collaborator', nameEs: 'Colaborador' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRoleLabel(roleId: string, locale: 'en' | 'es' = 'es'): string {
|
||||||
|
const role = ROLES[roleId.toLowerCase()];
|
||||||
|
if (!role) return roleId;
|
||||||
|
return locale === 'en' ? role.nameEn : role.nameEs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoleBilingualLabel(roleId: string): string {
|
||||||
|
const role = ROLES[roleId.toLowerCase()];
|
||||||
|
if (!role) return roleId;
|
||||||
|
return `${role.nameEs} / ${role.nameEn}`;
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ export async function middleware(req: NextRequest) {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
if (pathname.startsWith('/api/')) {
|
if (pathname.startsWith('/api/')) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Unauthorized. Session expired or missing.' },
|
{ error: 'No autorizado. La sesión ha expirado o no existe.' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||