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 email
|
||||
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
|
||||
string area
|
||||
string status "ACTIVE | INACTIVE"
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ Goals are assigned per period (`YYYY-MM`) at different scopes (`INDIVIDUAL` | `T
|
|||
## 2. API Specifications
|
||||
|
||||
### 2.1. `POST /api/plans` (Create Plan)
|
||||
- **Role Restriction**: `ADMIN` or `DIRECTOR`
|
||||
- **Role Restriction**: `admin` or `director`
|
||||
- **Request Body**:
|
||||
```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)
|
||||
- **Role Restriction**: `ADMIN` or `DIRECTOR`
|
||||
- **Role Restriction**: `admin` or `director`
|
||||
- **Logic**: Evaluates status to execute in-place updates or clone/version logic.
|
||||
|
||||
### 2.3. `POST /api/plans/[id]/rules` (Configure Rules)
|
||||
- **Role Restriction**: `ADMIN` or `DIRECTOR`
|
||||
- **Role Restriction**: `admin` or `director`
|
||||
- **Request Body**: Array of calculation rules to bulk upsert.
|
||||
|
||||
### 2.4. `POST /api/goals` (Assign Goals)
|
||||
- **Role Restriction**: `ADMIN` or `DIRECTOR`
|
||||
- **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
|
||||
SET app.current_user_role = 'ADMIN';
|
||||
SET app.current_user_role = 'admin';
|
||||
|
||||
-- ==========================================
|
||||
-- 1. SEED DATA
|
||||
-- 0. TEMPORARILY DISABLE RLS FOR SEEDING
|
||||
-- ==========================================
|
||||
|
||||
-- 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', '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;
|
||||
|
||||
ALTER TABLE "regions" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "hotels" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "users" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "goals" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "sales_results" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "settlements" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "audit_logs" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "compensation_plans" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "calculation_rules" DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ==========================================
|
||||
-- 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_select_policy ON "audit_logs";
|
||||
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_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
|
||||
|
|
@ -87,7 +102,7 @@ CREATE POLICY audit_logs_insert_policy ON "audit_logs"
|
|||
|
||||
CREATE POLICY audit_logs_select_policy ON "audit_logs"
|
||||
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
|
||||
);
|
||||
|
||||
|
|
@ -95,49 +110,49 @@ CREATE POLICY audit_logs_select_policy ON "audit_logs"
|
|||
-- B. Regions Policies
|
||||
CREATE POLICY regions_select_policy ON "regions"
|
||||
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
|
||||
);
|
||||
|
||||
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
|
||||
CREATE POLICY hotels_select_policy ON "hotels"
|
||||
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 id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
||||
);
|
||||
|
||||
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
|
||||
CREATE POLICY users_select_policy ON "users"
|
||||
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 (
|
||||
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
|
||||
)
|
||||
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)
|
||||
)
|
||||
OR id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
||||
);
|
||||
|
||||
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
|
||||
CREATE POLICY goals_select_policy ON "goals"
|
||||
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 IN (
|
||||
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"
|
||||
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
|
||||
CREATE POLICY sales_results_select_policy ON "sales_results"
|
||||
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 hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
||||
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"
|
||||
FOR ALL USING (
|
||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA')
|
||||
current_setting('app.current_user_role', true) IN ('admin', 'analyst')
|
||||
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
|
||||
)
|
||||
|
|
@ -178,7 +193,7 @@ CREATE POLICY sales_results_modify_policy ON "sales_results"
|
|||
-- G. Settlements Policies
|
||||
CREATE POLICY settlements_select_policy ON "settlements"
|
||||
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 IN (
|
||||
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"
|
||||
FOR ALL USING (
|
||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA')
|
||||
current_setting('app.current_user_role', true) IN ('admin', 'analyst')
|
||||
OR (
|
||||
current_setting('app.current_user_role', true) = 'LIDER'
|
||||
current_setting('app.current_user_role', true) = 'commercial_leader'
|
||||
AND user_id IN (
|
||||
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
|
||||
|
|
@ -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"
|
||||
FOR SELECT USING (true);
|
||||
|
||||
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
|
||||
ALTER TABLE "calculation_rules" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "calculation_rules" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY rules_select_policy ON "calculation_rules"
|
||||
FOR SELECT USING (true);
|
||||
|
||||
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
|
||||
email String @unique
|
||||
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")
|
||||
hotel Hotel @relation(fields: [hotelId], references: [id])
|
||||
area String
|
||||
|
|
@ -48,6 +48,7 @@ model User {
|
|||
plansCreated CompensationPlan[] @relation("PlanCreator")
|
||||
auditLogs AuditLog[]
|
||||
notifications Notification[]
|
||||
importJobs SalesImportJob[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
|
@ -175,3 +176,16 @@ model Notification {
|
|||
|
||||
@@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) {
|
||||
const db = getPrisma();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -68,8 +68,11 @@ async function runTests() {
|
|||
const adminUser = users.find(u => u.username === 'admin');
|
||||
|
||||
await runAsAdmin(async (tx) => {
|
||||
await tx.salesResult.deleteMany();
|
||||
await tx.auditLog.deleteMany();
|
||||
await tx.salesResult.deleteMany({
|
||||
where: {
|
||||
idempotencyKey: { in: ['auth-test-colab', 'auth-test-lider'] }
|
||||
}
|
||||
});
|
||||
|
||||
// Colaborador sale
|
||||
await tx.salesResult.create({
|
||||
|
|
@ -285,8 +288,11 @@ async function cleanup() {
|
|||
try {
|
||||
if (prisma) {
|
||||
await runAsAdmin(async (tx) => {
|
||||
await tx.salesResult.deleteMany();
|
||||
await tx.auditLog.deleteMany();
|
||||
await tx.salesResult.deleteMany({
|
||||
where: {
|
||||
idempotencyKey: { in: ['auth-test-colab', 'auth-test-lider'] }
|
||||
}
|
||||
});
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function getPrisma() {
|
|||
async function runAsAdmin(queryFn) {
|
||||
const db = getPrisma();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -69,21 +69,28 @@ async function runTests() {
|
|||
// 1. Clean database records first
|
||||
console.log("Resetting test environment data...");
|
||||
await runAsAdmin(async (tx) => {
|
||||
await tx.calculationRule.deleteMany();
|
||||
await tx.goal.deleteMany();
|
||||
await tx.compensationPlan.deleteMany();
|
||||
await tx.salesResult.deleteMany();
|
||||
await tx.auditLog.deleteMany();
|
||||
// Delete calculation rules associated with E2E test plans
|
||||
await tx.calculationRule.deleteMany({
|
||||
where: { plan: { code: 'PLAN-E2E-PUPP' } }
|
||||
});
|
||||
// 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
|
||||
console.log(`Starting Next.js dev server on port ${PORT}...`);
|
||||
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'dev', '--port', String(PORT)], {
|
||||
// 2. Start Next.js production server on port 3010
|
||||
console.log(`Starting Next.js production server on port ${PORT}...`);
|
||||
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'start', '--port', String(PORT)], {
|
||||
env: { ...process.env, PORT: String(PORT) }
|
||||
});
|
||||
|
||||
nextProcess.stdout.on('data', (data) => {
|
||||
// console.log(`[Next.js] ${data.toString().trim()}`);
|
||||
console.log(`[Next.js] ${data.toString().trim()}`);
|
||||
});
|
||||
nextProcess.stderr.on('data', (data) => {
|
||||
console.error(`[Next.js ERR] ${data.toString().trim()}`);
|
||||
|
|
@ -155,6 +162,15 @@ async function runTests() {
|
|||
await page.waitForSelector('div[role="dialog"]');
|
||||
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
|
||||
await page.type('#plan-name', 'Plan Ventas E2E Puppeteer');
|
||||
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') });
|
||||
assert(page.url().includes(`/plans/${createdPlan.id}/rules`), "Successfully navigated to rules page");
|
||||
|
||||
// Modify first row
|
||||
await clearAndType('#rule-min-0', '0.0');
|
||||
await clearAndType('#rule-max-0', '0.9');
|
||||
await clearAndType('#rule-rate-0', '0.0');
|
||||
await clearAndType('#rule-payout-0', '0.0');
|
||||
// Test invalid rule boundary constraint (min >= max)
|
||||
console.log("Testing rule validation constraints (min >= max)...");
|
||||
await setReactInput('#rule-min-0', '0.95');
|
||||
await setReactInput('#rule-max-0', '0.90');
|
||||
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
|
||||
await page.click('#btn-add-rule');
|
||||
|
|
@ -205,10 +230,10 @@ async function runTests() {
|
|||
await sleep(500); // Allow React state to settle
|
||||
|
||||
// Fill second row
|
||||
await clearAndType('#rule-min-1', '0.9');
|
||||
await clearAndType('#rule-max-1', '1.0');
|
||||
await clearAndType('#rule-rate-1', '0.025');
|
||||
await clearAndType('#rule-payout-1', '150.0');
|
||||
await setReactInput('#rule-min-1', '0.9');
|
||||
await setReactInput('#rule-max-1', '1.0');
|
||||
await setReactInput('#rule-rate-1', '0.025');
|
||||
await setReactInput('#rule-payout-1', '150.0');
|
||||
|
||||
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-id', colaboradorMde.id.toString());
|
||||
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.click('#btn-save-goal');
|
||||
|
|
@ -310,7 +345,9 @@ async function runTests() {
|
|||
await page.type('#username', 'colaborador_mde');
|
||||
await page.type('#password', 'password123');
|
||||
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
|
||||
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");
|
||||
|
||||
// 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();
|
||||
|
||||
console.log(`\n=== E2E TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
|
||||
|
|
@ -345,11 +414,18 @@ async function cleanup() {
|
|||
try {
|
||||
if (prisma) {
|
||||
await runAsAdmin(async (tx) => {
|
||||
await tx.calculationRule.deleteMany();
|
||||
await tx.goal.deleteMany();
|
||||
await tx.compensationPlan.deleteMany();
|
||||
await tx.salesResult.deleteMany();
|
||||
await tx.auditLog.deleteMany();
|
||||
// Delete calculation rules associated with E2E test plans
|
||||
await tx.calculationRule.deleteMany({
|
||||
where: { plan: { code: 'PLAN-E2E-PUPP' } }
|
||||
});
|
||||
// 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,9 @@ async function runTests() {
|
|||
await page.type('#username', 'colaborador_mde');
|
||||
await page.type('#password', 'password123');
|
||||
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
|
||||
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)
|
||||
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 hotels = await tx.hotel.findMany();
|
||||
const regions = await tx.region.findMany();
|
||||
|
|
@ -104,12 +104,15 @@ async function runTests() {
|
|||
|
||||
// --- TEST 2: Sales Results RLS ---
|
||||
console.log("\nRunning TEST 2: Sales Results RLS...");
|
||||
let saleColab, saleOther;
|
||||
try {
|
||||
// Clean up existing sales results to isolate the test
|
||||
await runWithContext(adminUser, tx => tx.salesResult.deleteMany());
|
||||
// Clean up test sales results first (non-destructively)
|
||||
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)
|
||||
const saleColab = await runWithContext(adminUser, tx => tx.salesResult.create({
|
||||
saleColab = await runWithContext(adminUser, tx => tx.salesResult.create({
|
||||
data: {
|
||||
source: 'EXCEL',
|
||||
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: {
|
||||
source: 'EXCEL',
|
||||
hotelId: liderCtg.hotelId,
|
||||
|
|
@ -202,10 +205,17 @@ async function runTests() {
|
|||
failed++;
|
||||
}
|
||||
|
||||
// Clean up test sales results and audit logs
|
||||
// Clean up test sales results
|
||||
console.log("\nCleaning up test records...");
|
||||
await runWithContext(adminUser, tx => tx.salesResult.deleteMany());
|
||||
await runWithContext(adminUser, tx => tx.auditLog.deleteMany());
|
||||
const salesResultIds = [];
|
||||
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 ===`);
|
||||
if (failed > 0) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export async function POST(req: NextRequest) {
|
|||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username and password are required' },
|
||||
{ error: 'El nombre de usuario y la contraseña son obligatorios.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
|
@ -18,13 +18,19 @@ export async function POST(req: NextRequest) {
|
|||
userId: 0,
|
||||
username: 'login_system',
|
||||
email: 'system@estelar.com',
|
||||
role: 'ADMIN',
|
||||
role: 'admin',
|
||||
hotelId: 0,
|
||||
regionId: 0
|
||||
});
|
||||
|
||||
console.log('[Login API] DATABASE_URL in Next.js:', process.env.DATABASE_URL);
|
||||
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({
|
||||
where: { username },
|
||||
include: { hotel: true }
|
||||
|
|
@ -34,7 +40,7 @@ export async function POST(req: NextRequest) {
|
|||
if (!user || user.status !== 'ACTIVE') {
|
||||
console.log('[Login API] Login failed: User not found or inactive');
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials or inactive user' },
|
||||
{ error: 'Credenciales inválidas o usuario inactivo.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
|
@ -44,7 +50,7 @@ export async function POST(req: NextRequest) {
|
|||
if (!passwordMatch) {
|
||||
console.log('[Login API] Login failed: Password mismatch');
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ error: 'Credenciales inválidas.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
|
@ -99,7 +105,7 @@ export async function POST(req: NextRequest) {
|
|||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ error: 'Error interno del servidor.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export const POST = withAuth(async (req, { prisma }) => {
|
|||
|
||||
if (!targetType || targetId === undefined || !period || amount === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: 'targetType, targetId, period, and amount are required fields' },
|
||||
{ error: 'targetType, targetId, period y amount son campos obligatorios.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
|
@ -58,4 +58,4 @@ export const POST = withAuth(async (req, { prisma }) => {
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
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({
|
||||
|
|
@ -16,20 +16,20 @@ export const GET = withAuth(async (req, { prisma, params }) => {
|
|||
});
|
||||
|
||||
if (!plan) {
|
||||
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Plan no encontrado.' }, { status: 404 });
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
const unwrappedParams = await params;
|
||||
const id = parseInt(unwrappedParams.id);
|
||||
const body = await req.json();
|
||||
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const plan = await prisma.compensationPlan.findUnique({
|
||||
|
|
@ -37,18 +37,12 @@ export const PUT = withAuth(async (req, { session, prisma, params }) => {
|
|||
});
|
||||
|
||||
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
|
||||
if (plan.status === 'ACTIVE') {
|
||||
const newPlan = await prisma.$transaction(async (tx: any) => {
|
||||
// Set RLS variables directly on the transaction client context
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`);
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${session.role}';`);
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${session.hotelId}';`);
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${session.regionId}';`);
|
||||
|
||||
// 1. Mark current plan version as INACTIVE
|
||||
await tx.compensationPlan.update({
|
||||
where: { id },
|
||||
|
|
@ -118,4 +112,4 @@ export const PUT = withAuth(async (req, { session, prisma, params }) => {
|
|||
|
||||
return NextResponse.json({ plan: updatedPlan, versioned: false });
|
||||
}
|
||||
}, ['ADMIN', 'DIRECTOR']);
|
||||
}, ['admin', 'director']);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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 }) => {
|
||||
const unwrappedParams = await params;
|
||||
const planId = parseInt(unwrappedParams.id);
|
||||
const body = await req.json(); // Expected: { rules: [{ type: 'TIER', minAchievement: 0.9, maxAchievement: 1.0, rate: 0.015, payoutAmount: 0 }, ...] }
|
||||
|
||||
if (isNaN(planId)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const plan = await prisma.compensationPlan.findUnique({
|
||||
|
|
@ -16,17 +16,64 @@ export const POST = withAuth(async (req, { session, prisma, params }) => {
|
|||
});
|
||||
|
||||
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
|
||||
const rules = await prisma.$transaction(async (tx: any) => {
|
||||
// Set RLS variables directly on the transaction client context
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`);
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${session.role}';`);
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${session.hotelId}';`);
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${session.regionId}';`);
|
||||
|
||||
// 1. Delete existing rules
|
||||
await tx.calculationRule.deleteMany({
|
||||
where: { planId }
|
||||
|
|
@ -34,13 +81,6 @@ export const POST = withAuth(async (req, { session, prisma, params }) => {
|
|||
|
||||
// 2. Insert new ones if provided
|
||||
if (body.rules && Array.isArray(body.rules)) {
|
||||
// Validate boundaries and contiguous nature if needed
|
||||
for (const rule of body.rules) {
|
||||
if (!rule.type || rule.minAchievement === undefined || rule.maxAchievement === undefined) {
|
||||
throw new Error('Invalid calculation rule parameters');
|
||||
}
|
||||
}
|
||||
|
||||
await tx.calculationRule.createMany({
|
||||
data: body.rules.map((r: any) => ({
|
||||
planId,
|
||||
|
|
@ -59,4 +99,4 @@ export const POST = withAuth(async (req, { session, prisma, params }) => {
|
|||
});
|
||||
|
||||
return NextResponse.json({ rules });
|
||||
}, ['ADMIN', 'DIRECTOR']);
|
||||
}, ['admin', 'director']);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const GET = withAuth(async (req, { prisma }) => {
|
|||
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 }) => {
|
||||
const body = await req.json();
|
||||
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
|
||||
if (!name || !code || !validityStart || !type) {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
|
@ -53,4 +53,4 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
|||
});
|
||||
|
||||
return NextResponse.json({ plan }, { status: 201 });
|
||||
}, ['ADMIN', 'DIRECTOR']);
|
||||
}, ['admin', 'director']);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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) {
|
||||
try {
|
||||
const signature = req.headers.get('x-n8n-signature');
|
||||
const expectedSecret = process.env.N8N_WEBHOOK_SECRET;
|
||||
const signature = req.headers.get('x-n8n-signature') || '';
|
||||
const expectedSecret = process.env.N8N_WEBHOOK_SECRET || '';
|
||||
|
||||
if (!expectedSecret || signature !== expectedSecret) {
|
||||
if (!expectedSecret || !signature || !safeCompare(signature, expectedSecret)) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: {
|
||||
|
|
@ -17,21 +28,55 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
|
||||
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({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'BAD_REQUEST',
|
||||
metadata: {}
|
||||
metadata: { message: 'idempotencyKey and uploaderId are required.' }
|
||||
}
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const prisma = getPrisma();
|
||||
|
||||
const result = await prisma.$transaction(async (tx: any) => {
|
||||
// Support n8n failure reporting
|
||||
if (status === 'FAILED') {
|
||||
await prisma.salesImportJob.upsert({
|
||||
where: { idempotencyKey },
|
||||
create: {
|
||||
idempotencyKey,
|
||||
status: 'FAILED',
|
||||
errorMessage: errorMessage || 'n8n background processing failed',
|
||||
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 (!Array.isArray(sales)) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'BAD_REQUEST',
|
||||
metadata: { message: 'sales must be an array.' }
|
||||
}
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await prisma.$transaction(async (tx: any) => {
|
||||
// Elevate privileges to admin role to bypass RLS for n8n batch operations
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||
|
||||
|
|
@ -108,11 +153,44 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
});
|
||||
|
||||
// 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({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,13 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
|||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Check idempotency
|
||||
// 1. Check idempotency / job status
|
||||
const existingJob = await prisma.salesImportJob.findUnique({
|
||||
where: { idempotencyKey }
|
||||
});
|
||||
|
||||
if (existingJob) {
|
||||
if (existingJob.status === 'SUCCESS') {
|
||||
const existingSales = await prisma.salesResult.findMany({
|
||||
where: {
|
||||
idempotencyKey: {
|
||||
|
|
@ -23,8 +29,6 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existingSales.length > 0) {
|
||||
const totalAmount = existingSales.reduce((acc: number, cur: any) => acc + Number(cur.amount), 0);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
|
@ -37,6 +41,25 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
|||
});
|
||||
}
|
||||
|
||||
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
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
|
@ -227,10 +250,20 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
|||
}, { 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');
|
||||
|
||||
if (useN8n) {
|
||||
try {
|
||||
const response = await fetch(process.env.N8N_WEBHOOK_URL!, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -253,6 +286,19 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
|||
|
||||
if (!response.ok) {
|
||||
console.error('n8n integration failed:', await response.text());
|
||||
await prisma.salesImportJob.upsert({
|
||||
where: { idempotencyKey },
|
||||
create: {
|
||||
idempotencyKey,
|
||||
status: 'FAILED',
|
||||
errorMessage: `n8n webhook responded with status ${response.status}`,
|
||||
uploadedBy: session.userId
|
||||
},
|
||||
update: {
|
||||
status: 'FAILED',
|
||||
errorMessage: `n8n webhook responded with status ${response.status}`
|
||||
}
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: {
|
||||
|
|
@ -270,11 +316,28 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
|||
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
|
||||
const totalAmount = rows.reduce((acc, r) => acc + r.amount, 0);
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx: any) => {
|
||||
const createdSales = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
|
|
@ -311,6 +374,19 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update import job to SUCCESS
|
||||
await tx.salesImportJob.upsert({
|
||||
where: { idempotencyKey },
|
||||
create: {
|
||||
idempotencyKey,
|
||||
status: 'SUCCESS',
|
||||
uploadedBy: session.userId
|
||||
},
|
||||
update: {
|
||||
status: 'SUCCESS'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
|
@ -321,6 +397,22 @@ export const POST = withAuth(async (req, { session, prisma }) => {
|
|||
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;
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Sales import error:', err);
|
||||
|
|
|
|||
|
|
@ -15,12 +15,8 @@ export const GET = withAuth(async (req, { prisma, params }) => {
|
|||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const count = await prisma.salesResult.count({
|
||||
where: {
|
||||
idempotencyKey: {
|
||||
startsWith: `${key}-`
|
||||
}
|
||||
}
|
||||
const job = await prisma.salesImportJob.findUnique({
|
||||
where: { idempotencyKey: key }
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
|
@ -28,7 +24,8 @@ export const GET = withAuth(async (req, { prisma, params }) => {
|
|||
code: 'STATUS_CHECKED',
|
||||
metadata: {
|
||||
idempotencyKey: key,
|
||||
status: count > 0 ? 'SUCCESS' : 'PROCESSING'
|
||||
status: job ? job.status : 'PROCESSING',
|
||||
errorMessage: job ? job.errorMessage : null
|
||||
}
|
||||
});
|
||||
}, ['admin', 'analyst', 'commercial_leader']);
|
||||
|
|
|
|||
|
|
@ -28,4 +28,4 @@ export const GET = withAuth(async (req, { prisma }) => {
|
|||
console.error('Failed to fetch users:', err);
|
||||
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
|
||||
}
|
||||
}, ['ADMIN', 'DIRECTOR']);
|
||||
}, ['admin', 'director']);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@
|
|||
--space-12: 3rem; /* 48px */
|
||||
|
||||
/* 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-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
|
|
@ -130,3 +131,14 @@ a {
|
|||
color: inherit;
|
||||
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);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
|
|
@ -161,12 +162,17 @@
|
|||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: box-shadow var(--transition-fast);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
@ -177,6 +183,12 @@
|
|||
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 {
|
||||
color: hsl(120, 80%, 30%);
|
||||
background-color: hsl(120, 80%, 95%);
|
||||
|
|
@ -185,6 +197,12 @@
|
|||
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 {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
|
@ -238,3 +256,8 @@
|
|||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import React, { useState, useEffect } from 'react';
|
|||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styles from './page.module.css';
|
||||
import Header from '@/components/Header';
|
||||
import { getRoleBilingualLabel } from '@/lib/roles';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
|
|
@ -32,6 +34,7 @@ export default function GoalsPage() {
|
|||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [goals, setGoals] = useState<Goal[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
|
||||
// Form State
|
||||
const [targetType, setTargetType] = useState('INDIVIDUAL');
|
||||
|
|
@ -67,14 +70,16 @@ export default function GoalsPage() {
|
|||
};
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
|
||||
const handleAssignGoal = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -127,7 +132,7 @@ export default function GoalsPage() {
|
|||
if (type === 'INDIVIDUAL') {
|
||||
const user = users.find(u => u.id === id);
|
||||
if (user) {
|
||||
return `${user.username} (${user.role} - ${user.hotel?.name || ''})`;
|
||||
return `${user.username} (${getRoleBilingualLabel(user.role)} - ${user.hotel?.name || ''})`;
|
||||
}
|
||||
}
|
||||
return `${type} ID: ${id}`;
|
||||
|
|
@ -136,22 +141,7 @@ export default function GoalsPage() {
|
|||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Shared Dashboard Header */}
|
||||
<header className={styles.header}>
|
||||
<div className={styles.logoArea}>
|
||||
<span className={styles.logoText}>Remuneración Estelar</span>
|
||||
</div>
|
||||
<nav className={styles.nav}>
|
||||
<Link href="/plans" className={styles.navLink}>
|
||||
Planes de Comisión
|
||||
</Link>
|
||||
<Link href="/goals" className={`${styles.navLink} ${styles.navLinkActive}`}>
|
||||
Metas Comerciales
|
||||
</Link>
|
||||
</nav>
|
||||
<button onClick={handleLogout} className={styles.logoutBtn}>
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</header>
|
||||
<Header activeTab="goals" />
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className={styles.titleSection}>
|
||||
|
|
@ -159,6 +149,7 @@ export default function GoalsPage() {
|
|||
</div>
|
||||
|
||||
{/* Goal Assignment Form Section */}
|
||||
{(role === 'admin' || role === 'director') && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Asignar Meta</h2>
|
||||
{error && <div className={styles.errorMsg} id="goal-error-msg">{error}</div>}
|
||||
|
|
@ -191,7 +182,7 @@ export default function GoalsPage() {
|
|||
>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username} ({u.role} - {u.hotel?.code})
|
||||
{u.username} ({getRoleBilingualLabel(u.role)} - {u.hotel?.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -244,9 +235,10 @@ export default function GoalsPage() {
|
|||
</button>
|
||||
</form>
|
||||
</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>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>Cargando metas...</div>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Remuneración Estelar - Hoteles Estelar",
|
||||
description: "Sistema de Remuneración Variable, Compensaciones y Comisiones de Hoteles Estelar",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ function LoginForm() {
|
|||
// Read return url if any
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -44,8 +41,7 @@ function LoginForm() {
|
|||
}
|
||||
|
||||
// Redirect to target dashboard
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
window.location.href = callbackUrl;
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred. Please try again.');
|
||||
setIsLoading(false);
|
||||
|
|
|
|||
|
|
@ -111,13 +111,14 @@
|
|||
color: var(--foreground);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
|
||||
}
|
||||
|
||||
.btnDelete {
|
||||
|
|
@ -128,6 +129,7 @@
|
|||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
padding: var(--space-1);
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.btnDelete:hover {
|
||||
|
|
@ -141,13 +143,19 @@
|
|||
background-color: transparent;
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
margin-top: var(--space-4);
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.btnSecondary:hover {
|
||||
background-color: var(--border);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btnSecondary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
|
|
@ -168,10 +176,16 @@
|
|||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
@ -182,6 +196,12 @@
|
|||
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 {
|
||||
color: hsl(0, 85%, 60%);
|
||||
background-color: hsl(0, 85%, 97%);
|
||||
|
|
@ -189,3 +209,9 @@
|
|||
border-radius: var(--radius-md);
|
||||
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 { useRouter, useParams } from 'next/navigation';
|
||||
import styles from './page.module.css';
|
||||
import Header from '@/components/Header';
|
||||
|
||||
interface Rule {
|
||||
id?: number;
|
||||
|
|
@ -36,6 +37,7 @@ export default function RulesPage() {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
|
||||
const fetchPlan = async () => {
|
||||
if (!planId) return;
|
||||
|
|
@ -80,14 +82,16 @@ export default function RulesPage() {
|
|||
};
|
||||
|
||||
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();
|
||||
}, [planId]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
|
||||
const handleAddRow = () => {
|
||||
// Generate a default values row, potentially continuing from previous max
|
||||
|
|
@ -191,22 +195,7 @@ export default function RulesPage() {
|
|||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Shared Dashboard Header */}
|
||||
<header className={styles.header}>
|
||||
<div className={styles.logoArea}>
|
||||
<span className={styles.logoText}>Remuneración Estelar</span>
|
||||
</div>
|
||||
<nav className={styles.nav}>
|
||||
<Link href="/plans" className={`${styles.navLink} ${styles.navLinkActive}`}>
|
||||
Planes de Comisión
|
||||
</Link>
|
||||
<Link href="/goals" className={styles.navLink}>
|
||||
Metas Comerciales
|
||||
</Link>
|
||||
</nav>
|
||||
<button onClick={handleLogout} className={styles.btnSecondary} style={{ margin: 0 }}>
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</header>
|
||||
<Header activeTab="plans" />
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className={styles.titleArea}>
|
||||
|
|
@ -241,6 +230,7 @@ export default function RulesPage() {
|
|||
value={rule.type}
|
||||
onChange={(e) => handleUpdateRow(rule.tempId!, 'type', e.target.value)}
|
||||
id={`rule-type-${idx}`}
|
||||
disabled={!(role === 'admin' || role === 'director')}
|
||||
>
|
||||
<option value="TIER">Rango (TIER)</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)}
|
||||
id={`rule-min-${idx}`}
|
||||
required
|
||||
disabled={!(role === 'admin' || role === 'director')}
|
||||
/>
|
||||
|
||||
<input
|
||||
|
|
@ -264,6 +255,7 @@ export default function RulesPage() {
|
|||
onChange={(e) => handleUpdateRow(rule.tempId!, 'maxAchievement', e.target.value)}
|
||||
id={`rule-max-${idx}`}
|
||||
required
|
||||
disabled={!(role === 'admin' || role === 'director')}
|
||||
/>
|
||||
|
||||
<input
|
||||
|
|
@ -274,6 +266,7 @@ export default function RulesPage() {
|
|||
onChange={(e) => handleUpdateRow(rule.tempId!, 'rate', e.target.value)}
|
||||
id={`rule-rate-${idx}`}
|
||||
required
|
||||
disabled={!(role === 'admin' || role === 'director')}
|
||||
/>
|
||||
|
||||
<input
|
||||
|
|
@ -284,8 +277,10 @@ export default function RulesPage() {
|
|||
onChange={(e) => handleUpdateRow(rule.tempId!, 'payoutAmount', e.target.value)}
|
||||
id={`rule-payout-${idx}`}
|
||||
required
|
||||
disabled={!(role === 'admin' || role === 'director')}
|
||||
/>
|
||||
|
||||
{(role === 'admin' || role === 'director') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteRow(rule.tempId!)}
|
||||
|
|
@ -295,10 +290,12 @@ export default function RulesPage() {
|
|||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(role === 'admin' || role === 'director') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddRow}
|
||||
|
|
@ -307,14 +304,17 @@ export default function RulesPage() {
|
|||
>
|
||||
+ Agregar Rango / Regla
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={styles.footerActions}>
|
||||
<Link href="/plans" className={styles.btnSecondary} style={{ marginRight: 'auto' }}>
|
||||
Cancelar
|
||||
{ (role === 'admin' || role === 'director') ? 'Cancelar' : 'Volver' }
|
||||
</Link>
|
||||
{(role === 'admin' || role === 'director') && (
|
||||
<button type="submit" className={styles.btnPrimary} id="btn-save-rules">
|
||||
Guardar Reglas
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -109,6 +109,10 @@
|
|||
box-shadow: var(--shadow-md), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.btnPrimary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -164,13 +168,20 @@
|
|||
.badge {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badgeDRAFT {
|
||||
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 {
|
||||
|
|
@ -178,9 +189,19 @@
|
|||
color: hsl(120, 80%, 30%);
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .badgeACTIVE {
|
||||
background-color: hsla(120, 80%, 50%, 0.15);
|
||||
color: hsl(120, 80%, 50%);
|
||||
}
|
||||
|
||||
.badgeINACTIVE {
|
||||
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 {
|
||||
|
|
@ -221,14 +242,19 @@
|
|||
background-color: transparent;
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-md);
|
||||
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;
|
||||
}
|
||||
|
||||
.btnSecondary:hover {
|
||||
background-color: var(--border);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btnSecondary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Modal and Form styling */
|
||||
|
|
@ -296,18 +322,29 @@
|
|||
color: var(--foreground);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
|
||||
}
|
||||
|
||||
.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);
|
||||
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 {
|
||||
|
|
@ -316,3 +353,10 @@
|
|||
gap: var(--space-3);
|
||||
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 { useRouter } from 'next/navigation';
|
||||
import styles from './page.module.css';
|
||||
import Header from '@/components/Header';
|
||||
|
||||
interface Plan {
|
||||
id: number;
|
||||
|
|
@ -27,6 +28,7 @@ export default function PlansPage() {
|
|||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
|
||||
// Form State
|
||||
const [name, setName] = useState('');
|
||||
|
|
@ -54,14 +56,16 @@ export default function PlansPage() {
|
|||
};
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
|
||||
const handleCreatePlan = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -133,33 +137,20 @@ export default function PlansPage() {
|
|||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Shared Dashboard Header */}
|
||||
<header className={styles.header}>
|
||||
<div className={styles.logoArea}>
|
||||
<span className={styles.logoText}>Remuneración Estelar</span>
|
||||
</div>
|
||||
<nav className={styles.nav}>
|
||||
<Link href="/plans" className={`${styles.navLink} ${styles.navLinkActive}`}>
|
||||
Planes de Comisión
|
||||
</Link>
|
||||
<Link href="/goals" className={styles.navLink}>
|
||||
Metas Comerciales
|
||||
</Link>
|
||||
</nav>
|
||||
<button onClick={handleLogout} className={styles.logoutBtn}>
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</header>
|
||||
<Header activeTab="plans" />
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.title}>Planes de Comisión</h1>
|
||||
{(role === 'admin' || role === 'director') && (
|
||||
<button onClick={() => setShowModal(true)} className={styles.btnPrimary} id="btn-create-plan">
|
||||
Nuevo Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>Cargando planes...</div>
|
||||
<div className={styles.loading}>Cargando planes...</div>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{plans.map((plan) => (
|
||||
|
|
@ -199,6 +190,8 @@ export default function PlansPage() {
|
|||
</div>
|
||||
|
||||
<div className={styles.cardFooter}>
|
||||
{ (role === 'admin' || role === 'director') ? (
|
||||
<>
|
||||
<Link href={`/plans/${plan.id}/rules`} className={styles.btnSecondary} id={`btn-rules-${plan.id}`}>
|
||||
Configurar Reglas
|
||||
</Link>
|
||||
|
|
@ -209,6 +202,12 @@ export default function PlansPage() {
|
|||
>
|
||||
{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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,12 @@ export default function SalesImportPage() {
|
|||
setProgress(100);
|
||||
setSuccessData({ count: data.metadata.count || 0 });
|
||||
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) {
|
||||
|
|
|
|||
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) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Session expired or missing.' },
|
||||
{ error: 'No autorizado. La sesión ha expirado o no existe.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (allowedRoles && allowedRoles.length > 0 && !allowedRoles.includes(session.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden. Insufficient permissions.' },
|
||||
{ error: 'Acceso prohibido. Permisos insuficientes.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export function withAuth(
|
|||
} catch (err) {
|
||||
console.error('API guard execution error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ error: 'Error interno del servidor.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ export interface UserSession {
|
|||
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 {
|
||||
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import crypto from 'crypto';
|
|||
import bcrypt from 'bcryptjs';
|
||||
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 {
|
||||
userId: number;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,30 @@ export const getPrisma = (session?: UserSession | null) => {
|
|||
}
|
||||
|
||||
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: {
|
||||
$allModels: {
|
||||
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 (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Session expired or missing.' },
|
||||
{ error: 'No autorizado. La sesión ha expirado o no existe.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||