feat: implement Phase 4 - Excel import, validations, idempotency key, and E2E tests

This commit is contained in:
Luis Gabriel Ramos Robles 2026-06-11 22:28:14 +00:00
parent f79d7db793
commit b6fa061208
12 changed files with 1923 additions and 4 deletions

View file

@ -0,0 +1,167 @@
# Phase 4 Implementation & Integration Design Document
**Variable Remuneration, Compensation, and Commissions System - Hoteles Estelar**
---
This document outlines the detailed design decisions, schema mappings, API endpoints, UI layouts, and security/idempotency validations planned for **Phase 4: Data Import & Integrations**.
## 1. Architectural Strategy & Logic Flows
### 1.1. Excel Parser & Data Validation Flow
To prevent manual data-entry errors, the system allows authorized users (Administrators, Analysts, and Commercial Leaders) to upload Excel/CSV sheets with sales results.
#### Excel Spreadsheet Format Specifications
Two pre-populated spreadsheets are available under the public directory:
1. **Template spreadsheet**: [import_sales_template.xlsx](file:///home/gabogg/Proyects/semillero-special-hotel/public/templates/import_sales_template.xlsx) — Clean column layout with a single dummy row reference. **This template is made directly downloadable through the program's UI.**
2. **Test spreadsheet**: [import_sales_test.xlsx](file:///home/gabogg/Proyects/semillero-special-hotel/public/templates/import_sales_test.xlsx) — Contains mixed valid and invalid rows to verify frontend/backend validation error rendering.
The spreadsheet contains the following mandatory headers:
- `Colaborador`: String representing the unique collaborator username (e.g. `colaborador_mde`).
- `Periodo`: String matching the target period in `YYYY-MM` format (e.g. `2026-06`).
- `Hotel`: String representing the unique hotel code (e.g. `EST-MDE`).
- `Monto`: Decimal/Numeric positive value of total sales.
- `Cantidad`: Integer positive value of total sales count.
- `Id_Transaccion`: String representing the external transaction tracking ID (e.g. `TX-EST-MDE-001`).
The server-side parsing will:
1. Parse the uploaded `.xlsx` or `.csv` file buffer using the `xlsx` library.
2. Resolve row-level records and perform **Atomic Validation** on all rows before write transactions.
3. If any row contains errors, abort the entire operation and return a structured JSON report identifying the exact rows, columns, values, error codes, and translation metadata.
### 1.2. Multilingual "Code + Metadata" Response Pattern
To support dynamic localized UI translations, the API does not return pre-translated text strings. Instead, it enforces the **Code + Metadata** pattern:
- **Success Responses**: Return a strict static success code alongside numeric/string variables in a metadata payload.
- **Error Responses**: Return strict error codes (`USER_NOT_FOUND`, `INVALID_PERIOD_FORMAT`, etc.) with their respective parameters. The client-side application translates these keys locally using translation tables.
### 1.3. Two-Tier Idempotency Control (API & DB Layer)
To guarantee that duplicate uploads do not lead to duplicate commission calculations:
1. **API Header Check**: The `POST /api/sales/import` endpoint requires a client-generated `Idempotency-Key` header.
2. **Key Check**:
- The backend checks if any existing sales results contain an idempotency key matching the pattern `${idempotency_key}-*`.
- **True**: The import is recognized as a duplicate. The server returns the cached success details of the previous import, preventing re-execution.
- **False**: Processing continues.
3. **Row-level uniqueness**: Each inserted row is saved with a unique database constraint `idempotencyKey = ${idempotency_key}-${row_index}`, guaranteeing database-level data integrity under concurrent scenarios.
```mermaid
graph TD
A[Upload request with Idempotency-Key] --> B{Key already processed?}
B -->|Yes| C[Return cached/existing import summary]
B -->|No| D[Parse Excel/CSV Buffer]
D --> E{Any validation error?}
E -->|Yes| F[Abort and return detailed error list]
E -->|No| G{N8N_WEBHOOK_URL set?}
G -->|Yes| H[Forward rows to n8n Webhook & return 202 Accepted]
G -->|No / Test| I[Prisma Transaction: Save rows with idempotency key + Audit Log]
I --> J[Return 201 Created]
```
### 1.4. n8n Integration & Callback Validation
For automatic integrations (US-COM-005):
1. **Webhook Security**: Next.js exposes a public-facing but secured callback endpoint `/api/sales/batch-save`.
2. **Signature Verification**: Webhooks from n8n must include the `x-n8n-signature` header. The server verifies this token against `N8N_WEBHOOK_SECRET` before executing database updates.
3. **Async Status Polling**: The frontend displays a premium progress UI. If routed through n8n, it polls `GET /api/sales/import/status/[key]` to check database status and update progress.
---
## 2. API Specifications
### 2.1. `POST /api/sales/import` (Upload Sales Sheet)
- **Role Restriction**: `admin` | `analyst` | `commercial_leader`
- **Headers**:
- `Idempotency-Key`: Required string
- **Request Body**: `multipart/form-data` with `file` field containing the spreadsheet.
- **Response**:
- `201 Created` (Direct import mode):
```json
{
"success": true,
"code": "IMPORT_SUCCESSFUL",
"metadata": {
"count": 45,
"totalAmount": 1563000.00
}
}
```
- `202 Accepted` (n8n mode):
```json
{
"success": true,
"code": "IMPORT_ACCEPTED",
"metadata": {
"idempotencyKey": "unique-client-key-123",
"status": "PROCESSING"
}
}
```
- `400 Bad Request` (Validation errors):
```json
{
"success": false,
"error": {
"code": "IMPORT_VALIDATION_FAILED",
"details": [
{
"row": 3,
"column": "Colaborador",
"value": "colaborador_inexistente",
"code": "USER_NOT_FOUND",
"metadata": { "username": "colaborador_inexistente" }
},
{
"row": 4,
"column": "Periodo",
"value": "2026/06",
"code": "INVALID_PERIOD_FORMAT",
"metadata": { "expected": "YYYY-MM" }
}
]
}
}
```
### 2.2. `POST /api/sales/batch-save` (n8n Webhook Callback)
- **Security Check**: Header `x-n8n-signature === process.env.N8N_WEBHOOK_SECRET`
- **Request Body**:
```json
{
"idempotencyKey": "unique-client-key-123",
"uploaderId": 1,
"sales": [
{ "username": "colaborador_mde", "hotelCode": "EST-MDE", "period": "2026-06", "amount": 15000.00, "salesCount": 3 }
]
}
```
- **Response**: `201 Created` or `400 Bad Request`.
### 2.3. `GET /api/sales/import/status/[key]` (Poll Status)
- **Response**:
```json
{
"idempotencyKey": "unique-client-key-123",
"status": "SUCCESS" // PROCESSING | SUCCESS | FAILED
}
```
---
## 3. UI Design Specifications
We will create a premium drag-and-drop loading interface at `/sales/import`:
1. **Interactive Drop Zone**: Elegant border animation on dragover. Shows file metadata upon dropping.
2. **Template Download Link**: Sleek, visible button to download the pre-formatted Excel template directly.
3. **Real-time Progress Indicator**: Fluid CSS progress bar tracking parse/upload state.
4. **Inconsistency Panel**: If the API returns validation errors, displays them in a sleek, scrollable log table with warning icons.
5. **Permissions Enforcement**: Renders standard unauthorized page if role is not permitted.
---
## 4. Headless Puppeteer Verification Plan
We will create a new test script `prisma/test-phase4-ui.js` verifying:
1. **Role Enforcement**: `colaborador_mde` is blocked from accessing the page and endpoint.
2. **Missing Header Validation**: Upload fails if `Idempotency-Key` is missing.
3. **File Validation**: Uploading an Excel file with negative values or fake users displays validation errors.
4. **Successful Direct Import**: Uploading a valid Excel file imports rows, saves them, and shows success.
5. **Idempotency Prevention**: Re-uploading with the same key returns the success message without modifying/inserting additional database records.
6. **n8n Webhook Callback Integrity**: POSTing to `/api/sales/batch-save` with an invalid signature is blocked (401), while a valid signature is processed.

View file

@ -11,9 +11,9 @@
"db:seed-test": "DATABASE_URL=\"postgresql://special_hotel_user:SpecialHotel1235%2F%2A-%2B@pg.gaboggamer.online/special_hotel_test?schema=public\" npx prisma db execute --file prisma/rls_and_seed.sql",
"test:rls": "node prisma/test-rls.js",
"test:auth-rls": "node prisma/test-auth-rls.js",
"test:ui": "node prisma/test-phase3-ui.js",
"test:all": "npm run test:rls && npm run test:auth-rls && npm run test:ui",
"test": "npm run test:all"
"test:ui": "next build && node prisma/test-phase3-ui.js && node prisma/test-phase4-ui.js",
"test:all": "pnpm run test:rls && pnpm run test:auth-rls && pnpm run test:ui",
"test": "pnpm run test:all"
},
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
@ -24,7 +24,8 @@
"next": "16.2.9",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",

387
prisma/test-phase4-ui.js Normal file
View file

@ -0,0 +1,387 @@
const { spawn } = require('child_process');
const { PrismaClient } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const { Pool } = require('pg');
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
const PORT = 3012;
const BASE_URL = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = path.join(__dirname, 'screenshots-phase4');
let nextProcess;
let prisma;
let pool;
let browser;
// Ensure screenshot dir exists
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
function getPrisma() {
if (!prisma) {
const dbUrl = new URL(process.env.DATABASE_URL);
pool = new Pool({
host: dbUrl.hostname,
port: dbUrl.port ? parseInt(dbUrl.port) : 5432,
user: decodeURIComponent(dbUrl.username),
password: decodeURIComponent(dbUrl.password),
database: dbUrl.pathname.substring(1).split('?')[0],
ssl: false
});
const adapter = new PrismaPg(pool);
prisma = new PrismaClient({ adapter });
}
return prisma;
}
async function runAsAdmin(queryFn) {
const db = getPrisma();
return db.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
return queryFn(tx);
});
}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
let passed = 0;
let failed = 0;
function assert(condition, message) {
if (condition) {
console.log(` ✓ PASS: ${message}`);
passed++;
} else {
console.error(` ✗ FAIL: ${message}`);
failed++;
}
}
async function cleanupDb() {
console.log("Cleaning up E2E test sales import records in database...");
await runAsAdmin(async (tx) => {
// Clean sales results created by test scripts
await tx.salesResult.deleteMany({
where: {
idempotencyKey: {
startsWith: 'test-key-p4'
}
}
});
// Clean audit logs associated
await tx.auditLog.deleteMany({
where: {
action: 'CREATE',
targetTable: 'sales_results'
}
});
});
}
async function runTests() {
console.log("=== STARTING PHASE 4 DATA IMPORT & INTEGRATIONS E2E PUPPETEER TEST SUITE ===");
// 1. Clean database records first
await cleanupDb();
// 2. Start Next.js production server on port 3012
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()}`);
});
nextProcess.stderr.on('data', (data) => {
console.error(`[Next.js ERR] ${data.toString().trim()}`);
});
// Wait for server to start up
let serverReady = false;
for (let i = 0; i < 15; i++) {
await sleep(2000);
try {
const res = await fetch(`${BASE_URL}/api/auth/me`);
serverReady = true;
break;
} catch (e) {
// Server not ready yet
}
}
if (!serverReady) {
console.error("Error: Next.js server failed to start within timeout.");
await cleanup();
process.exit(1);
}
console.log("Next.js server is ready!");
// 3. Launch Puppeteer browser
console.log("Launching Puppeteer browser...");
browser = await puppeteer.launch({
headless: 'shell',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
// --- TEST 1: ROLE SEGREGATION (COLLABORATOR BLOCKED) ---
console.log("\n[Test 1] Logging in as collaborator and verifying block on /sales/import...");
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
await page.type('#username', 'colaborador_mde');
await page.type('#password', 'password123');
await page.click('button[type="submit"]');
await page.waitForSelector('#btn-create-plan'); // Wait for home page dashboard load
// Try navigating to import page
await page.goto(`${BASE_URL}/sales/import`, { waitUntil: 'networkidle2' });
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '01_collaborator_unauthorized.png') });
// Check if header Cargar Ventas is not visible, and redirect or error occurs
const hasImportLink = await page.evaluate(() => {
return document.querySelector('#nav-import-sales') !== null;
});
assert(!hasImportLink, "Collaborator is not shown the 'Cargar Ventas' link in the header");
// Verify direct API POST block (returns 403)
const apiBlockRes = await page.evaluate(async () => {
const res = await fetch('/api/sales/import', {
method: 'POST',
headers: {
'idempotency-key': 'test-key-p4-fake'
}
});
return { status: res.status };
});
assert(apiBlockRes.status === 403, "API POST /api/sales/import returns 403 Forbidden for collaborator");
// Logout collaborator
await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' });
await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const logoutBtn = buttons.find(b => b.textContent.includes('Cerrar Sesión'));
if (logoutBtn) logoutBtn.click();
});
await sleep(1500);
// --- TEST 2: ADMIN ACCESS & UI ELEMENTS ---
console.log("\n[Test 2] Logging in as admin and verifying import page...");
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
await page.type('#username', 'admin');
await page.type('#password', 'password123');
await page.click('button[type="submit"]');
await page.waitForSelector('#btn-create-plan');
// Navigate to import page
await page.click('#nav-import-sales');
await page.waitForSelector('#btn-download-template');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '02_admin_import_view.png') });
assert(page.url().endsWith('/sales/import'), "Redirected to /sales/import for authorized Admin role");
const downloadLinkExists = await page.evaluate(() => {
const el = document.querySelector('#btn-download-template');
return el && el.getAttribute('href') === '/templates/import_sales_template.xlsx';
});
assert(downloadLinkExists, "Template download button points to the correct static template URL");
// --- TEST 3: ATOMIC EXCEL FILE VALIDATION ---
console.log("\n[Test 3] Uploading invalid test Excel file and verifying inconsistency panel...");
const invalidFilePath = path.join(__dirname, '..', 'public', 'templates', 'import_sales_test.xlsx');
// Select file in input
const fileInput = await page.$('input[type="file"]');
await fileInput.uploadFile(invalidFilePath);
await sleep(1000);
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '03_invalid_file_selected.png') });
// Click submit
await page.click('#btn-submit-upload');
await page.waitForSelector('#inconsistency-panel');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_validation_errors_rendered.png') });
// Validate error rendering contents
const validationErrorCount = await page.evaluate(() => {
return document.querySelectorAll('tbody tr').length;
});
assert(validationErrorCount === 4, "Atomic validation catches all 4 invalid rows in the sheet");
const errorTexts = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.cellMsg')).map(el => el.textContent);
});
assert(errorTexts.some(t => t.includes("colaborador_inexistente")), "Rendered 'colaborador_inexistente' username validation error");
assert(errorTexts.some(t => t.includes("2026/06")), "Rendered invalid Period format validation error");
assert(errorTexts.some(t => t.includes("EST-FAKE")), "Rendered non-existent Hotel code validation error");
assert(errorTexts.some(t => t.includes("monto")), "Rendered negative sales amount validation error");
// Check that no rows were inserted in DB
const dbCount = await runAsAdmin(tx => tx.salesResult.count());
assert(dbCount === 0, "No records written to the database (atomic roll-back verified)");
// --- TEST 4: SUCCESSFUL DIRECT IMPORT & IDEMPOTENCY ---
console.log("\n[Test 4] Uploading valid template Excel file and checking direct database save...");
const validFilePath = path.join(__dirname, '..', 'public', 'templates', 'import_sales_template.xlsx');
// Refresh page to reset state and key
await page.reload({ waitUntil: 'networkidle2' });
// Attach valid file
const fileInputValid = await page.$('input[type="file"]');
await fileInputValid.uploadFile(validFilePath);
await sleep(1000);
// Submit
await page.click('#btn-submit-upload');
await page.waitForSelector('#upload-success-msg');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '05_valid_upload_success.png') });
// Check db records
const dbSales = await runAsAdmin(tx => tx.salesResult.findMany({
where: {
idempotencyKey: {
startsWith: 'key-'
}
}
}));
assert(dbSales.length === 1, "1 sales result successfully imported into the database");
assert(parseFloat(dbSales[0].amount) === 15000.00, "Monto of imported sales result matches template value (15,000.00)");
assert(dbSales[0].salesCount === 5, "Cantidad of imported sales result matches template value (5)");
// Attempt upload again with the SAME key (verify idempotency check)
console.log("Uploading the same file again (verifying idempotency)...");
// Trigger upload button again without refreshing (maintains current session idempotencyKey state)
await page.click('#btn-submit-upload');
await sleep(1500);
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '06_idempotency_duplicate.png') });
const finalDbCount = await runAsAdmin(tx => tx.salesResult.count({
where: {
idempotencyKey: {
startsWith: 'key-'
}
}
}));
assert(finalDbCount === 1, "Idempotency verified: duplicate upload request was blocked from inserting duplicate rows");
// --- TEST 5: N8N CALLBACK AUTHENTICATION AND BATCH-SAVE ---
console.log("\n[Test 5] Simulating n8n callback `/api/sales/batch-save` signature checking...");
// Trigger callback with invalid signature
const callbackUnauthorized = await page.evaluate(async () => {
const res = await fetch('/api/sales/batch-save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': 'invalid-secret'
},
body: JSON.stringify({
idempotencyKey: 'test-key-p4-n8n',
uploaderId: 1,
sales: []
})
});
return { status: res.status };
});
assert(callbackUnauthorized.status === 401, "Webhook callback returns 401 Unauthorized for invalid signature");
// Trigger callback with valid signature
const callbackSuccess = await page.evaluate(async () => {
const res = await fetch('/api/sales/batch-save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': 'local_shared_signature_to_verify_n8n_callbacks'
},
body: JSON.stringify({
idempotencyKey: 'test-key-p4-n8n',
uploaderId: 1,
sales: [
{
username: 'colaborador_mde',
hotelCode: 'EST-MDE',
period: '2026-06',
amount: 35000.00,
salesCount: 8,
transactionId: 'TX-N8N-001'
}
]
})
});
return { status: res.status };
});
assert(callbackSuccess.status === 201, "Webhook callback returns 201 Created for valid signature and payload");
// Verify n8n-inserted record in database
const n8nSales = await runAsAdmin(tx => tx.salesResult.findMany({
where: {
idempotencyKey: {
startsWith: 'test-key-p4-n8n'
}
}
}));
assert(n8nSales.length === 1, "n8n callback successfully inserted sales result row in database");
assert(parseFloat(n8nSales[0].amount) === 35000.00, "n8n sales result row amount matches callback payload");
await cleanup();
console.log(`\n=== E2E TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
if (failed > 0) {
process.exit(1);
} else {
process.exit(0);
}
}
async function cleanup() {
console.log("\nCleaning up E2E test browser and server processes...");
try {
if (browser) {
await browser.close();
}
} catch (e) {}
try {
await cleanupDb();
if (prisma) {
await prisma.$disconnect();
}
if (pool) {
await pool.end();
}
} catch (e) {
console.error("Error during DB cleanup:", e);
}
if (nextProcess) {
nextProcess.kill();
}
}
process.on('SIGINT', () => {
cleanup();
process.exit(1);
});
runTests().catch(async err => {
console.error("Fatal E2E test runner error:", err);
if (browser) {
try {
const pages = await browser.pages();
if (pages.length > 0) {
await pages[0].screenshot({ path: path.join(SCREENSHOT_DIR, 'error_screenshot.png') });
console.log("Saved error_screenshot.png to", path.join(SCREENSHOT_DIR, 'error_screenshot.png'));
}
} catch (screenshotErr) {
console.error("Failed to capture error screenshot:", screenshotErr);
}
}
await cleanup();
process.exit(1);
});

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,90 @@
const fs = require('fs');
const path = require('path');
const XLSX = require('xlsx');
const templatesDir = path.join(__dirname, '..', 'public', 'templates');
if (!fs.existsSync(templatesDir)) {
fs.mkdirSync(templatesDir, { recursive: true });
}
// 1. Template Sheet (Clean layout)
const templateData = [
{
"Colaborador": "colaborador_mde",
"Periodo": "2026-06",
"Hotel": "EST-MDE",
"Monto": 15000.00,
"Cantidad": 5,
"Id_Transaccion": "TX-EST-MDE-001"
}
];
const templateWorkbook = XLSX.utils.book_new();
const templateWorksheet = XLSX.utils.json_to_sheet(templateData);
XLSX.utils.book_append_sheet(templateWorkbook, templateWorksheet, "Template");
XLSX.writeFile(templateWorkbook, path.join(templatesDir, 'import_sales_template.xlsx'));
console.log("Created import_sales_template.xlsx successfully!");
// 2. Test Sheet (Contains both valid and invalid records to test API validation output)
const testData = [
// Valid row
{
"Colaborador": "colaborador_mde",
"Periodo": "2026-06",
"Hotel": "EST-MDE",
"Monto": 25000.00,
"Cantidad": 10,
"Id_Transaccion": "TX-E2E-001"
},
// Valid row
{
"Colaborador": "colaborador_mde",
"Periodo": "2026-06",
"Hotel": "EST-MDE",
"Monto": 5000.00,
"Cantidad": 2,
"Id_Transaccion": "TX-E2E-002"
},
// Invalid row: Non-existent Colaborador
{
"Colaborador": "colaborador_inexistente",
"Periodo": "2026-06",
"Hotel": "EST-MDE",
"Monto": 10000.00,
"Cantidad": 3,
"Id_Transaccion": "TX-E2E-003"
},
// Invalid row: Invalid Period format
{
"Colaborador": "colaborador_mde",
"Periodo": "2026/06",
"Hotel": "EST-MDE",
"Monto": 12000.00,
"Cantidad": 4,
"Id_Transaccion": "TX-E2E-004"
},
// Invalid row: Non-existent Hotel
{
"Colaborador": "colaborador_mde",
"Periodo": "2026-06",
"Hotel": "EST-FAKE",
"Monto": 8000.00,
"Cantidad": 1,
"Id_Transaccion": "TX-E2E-005"
},
// Invalid row: Negative amount
{
"Colaborador": "colaborador_mde",
"Periodo": "2026-06",
"Hotel": "EST-MDE",
"Monto": -500.00,
"Cantidad": 2,
"Id_Transaccion": "TX-E2E-006"
}
];
const testWorkbook = XLSX.utils.book_new();
const testWorksheet = XLSX.utils.json_to_sheet(testData);
XLSX.utils.book_append_sheet(testWorkbook, testWorksheet, "TestData");
XLSX.writeFile(testWorkbook, path.join(templatesDir, 'import_sales_test.xlsx'));
console.log("Created import_sales_test.xlsx successfully!");

View file

@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPrisma } from '@/lib/db';
export async function POST(req: NextRequest) {
try {
const signature = req.headers.get('x-n8n-signature');
const expectedSecret = process.env.N8N_WEBHOOK_SECRET;
if (!expectedSecret || signature !== expectedSecret) {
return NextResponse.json({
success: false,
error: {
code: 'UNAUTHORIZED',
metadata: {}
}
}, { status: 401 });
}
const body = await req.json();
const { idempotencyKey, uploaderId, sales } = body;
if (!idempotencyKey || !uploaderId || !Array.isArray(sales)) {
return NextResponse.json({
success: false,
error: {
code: 'BAD_REQUEST',
metadata: {}
}
}, { status: 400 });
}
const prisma = getPrisma();
const result = await prisma.$transaction(async (tx) => {
// Elevate privileges to admin role to bypass RLS for n8n batch operations
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
// 1. Check idempotency
const existing = await tx.salesResult.findMany({
where: {
idempotencyKey: {
startsWith: `${idempotencyKey}-`
}
}
});
if (existing.length > 0) {
return {
alreadyProcessed: true,
count: existing.length
};
}
// 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(dbUsers.map(u => [u.username, u]));
const hotelMap = new Map(dbHotels.map(h => [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
}
}
});
return {
alreadyProcessed: false,
count: createdSales.length
};
});
return NextResponse.json({
success: true,
code: result.alreadyProcessed ? 'IMPORT_ALREADY_PROCESSED' : 'IMPORT_SUCCESSFUL',
metadata: {
count: result.count,
idempotencyKey
}
}, { status: 201 });
} catch (err: any) {
console.error('Batch save error:', err);
return NextResponse.json({
success: false,
error: {
code: 'BATCH_SAVE_FAILED',
metadata: { message: err.message }
}
}, { status: 500 });
}
}

View file

@ -0,0 +1,335 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
import XLSX from 'xlsx';
export const POST = withAuth(async (req, { session, prisma }) => {
try {
const idempotencyKey = req.headers.get('idempotency-key');
if (!idempotencyKey) {
return NextResponse.json({
success: false,
error: {
code: 'MISSING_IDEMPOTENCY_KEY',
metadata: {}
}
}, { status: 400 });
}
// 1. Check idempotency
const existingSales = await prisma.salesResult.findMany({
where: {
idempotencyKey: {
startsWith: `${idempotencyKey}-`
}
}
});
if (existingSales.length > 0) {
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
}
});
}
// 2. Parse file
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({
success: false,
error: {
code: 'FILE_REQUIRED',
metadata: {}
}
}, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const workbook = XLSX.read(buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const rawRows = XLSX.utils.sheet_to_json(sheet) as any[];
if (!rawRows || rawRows.length === 0) {
return NextResponse.json({
success: false,
error: {
code: 'EMPTY_FILE',
metadata: {}
}
}, { status: 400 });
}
// 3. Collect rows and validate
const rows: {
username: string;
period: string;
hotelCode: string;
amount: number;
salesCount: number;
transactionId?: string;
rowIndex: number;
}[] = [];
const errors: {
row: number;
column: string;
value: any;
code: string;
metadata: any;
}[] = [];
for (let i = 0; i < rawRows.length; i++) {
const row = rawRows[i];
const rowIndex = i + 2;
const username = String(row['Colaborador'] || '').trim();
const period = String(row['Periodo'] || '').trim();
const hotelCode = String(row['Hotel'] || '').trim();
const amountVal = row['Monto'];
const salesCountVal = row['Cantidad'];
const transactionId = row['Id_Transaccion'] ? String(row['Id_Transaccion']).trim() : undefined;
const amount = typeof amountVal === 'number' ? amountVal : parseFloat(String(amountVal || ''));
const salesCount = typeof salesCountVal === 'number' ? salesCountVal : parseInt(String(salesCountVal || ''));
rows.push({
username,
period,
hotelCode,
amount,
salesCount,
transactionId,
rowIndex
});
// Basic validations
if (!username) {
errors.push({
row: rowIndex,
column: 'Colaborador',
value: '',
code: 'USER_REQUIRED',
metadata: {}
});
}
if (!period || !/^\d{4}-\d{2}$/.test(period)) {
errors.push({
row: rowIndex,
column: 'Periodo',
value: period,
code: 'INVALID_PERIOD_FORMAT',
metadata: { expected: 'YYYY-MM' }
});
}
if (!hotelCode) {
errors.push({
row: rowIndex,
column: 'Hotel',
value: '',
code: 'HOTEL_REQUIRED',
metadata: {}
});
}
if (isNaN(amount) || amount <= 0) {
errors.push({
row: rowIndex,
column: 'Monto',
value: amountVal,
code: 'INVALID_AMOUNT',
metadata: { value: amountVal }
});
}
if (isNaN(salesCount) || salesCount <= 0) {
errors.push({
row: rowIndex,
column: 'Cantidad',
value: salesCountVal,
code: 'INVALID_COUNT',
metadata: { value: salesCountVal }
});
}
}
// Resolve entities and check exists/RBAC
const uniqueUsernames = Array.from(new Set(rows.map(r => r.username).filter(Boolean)));
const uniqueHotelCodes = Array.from(new Set(rows.map(r => r.hotelCode).filter(Boolean)));
const dbUsers = await prisma.user.findMany({
where: { username: { in: uniqueUsernames } }
});
const dbHotels = await prisma.hotel.findMany({
where: { code: { in: uniqueHotelCodes } }
});
const userMap = new Map(dbUsers.map(u => [u.username, u]));
const hotelMap = new Map(dbHotels.map(h => [h.code, h]));
// Find active user for region verification
const activeUser = await prisma.user.findUnique({
where: { id: session.userId },
include: { hotel: true }
});
const activeRegionId = activeUser?.hotel?.regionId;
for (const r of rows) {
if (r.username && !userMap.has(r.username)) {
errors.push({
row: r.rowIndex,
column: 'Colaborador',
value: r.username,
code: 'USER_NOT_FOUND',
metadata: { username: r.username }
});
}
const hotel = hotelMap.get(r.hotelCode);
if (r.hotelCode && !hotel) {
errors.push({
row: r.rowIndex,
column: 'Hotel',
value: r.hotelCode,
code: 'HOTEL_NOT_FOUND',
metadata: { hotelCode: r.hotelCode }
});
}
// Enforce Leader region isolation
if (session.role === 'commercial_leader' && hotel && hotel.regionId !== activeRegionId) {
errors.push({
row: r.rowIndex,
column: 'Hotel',
value: r.hotelCode,
code: 'HOTEL_REGION_MISMATCH',
metadata: { hotelCode: r.hotelCode, regionId: activeRegionId }
});
}
}
if (errors.length > 0) {
return NextResponse.json({
success: false,
error: {
code: 'IMPORT_VALIDATION_FAILED',
details: errors
}
}, { status: 400 });
}
// 4. Dispatch to n8n or direct save
const useN8n = process.env.N8N_WEBHOOK_URL && !req.nextUrl.searchParams.has('direct');
if (useN8n) {
const response = await fetch(process.env.N8N_WEBHOOK_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET || ''
},
body: JSON.stringify({
idempotencyKey,
uploaderId: session.userId,
sales: rows.map(r => ({
username: r.username,
hotelCode: r.hotelCode,
period: r.period,
amount: r.amount,
salesCount: r.salesCount,
transactionId: r.transactionId
}))
})
});
if (!response.ok) {
console.error('n8n integration failed:', await response.text());
return NextResponse.json({
success: false,
error: {
code: 'INTEGRATION_ERROR',
metadata: { status: response.status }
}
}, { status: 500 });
}
return NextResponse.json({
success: true,
code: 'IMPORT_ACCEPTED',
metadata: {
idempotencyKey,
status: 'PROCESSING'
}
}, { status: 202 });
}
// Direct Import Fallback / Test mode
const totalAmount = rows.reduce((acc, r) => acc + r.amount, 0);
await prisma.$transaction(async (tx: any) => {
const createdSales = [];
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
const user = userMap.get(r.username)!;
const hotel = hotelMap.get(r.hotelCode)!;
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: {
userId: session.userId,
action: 'CREATE',
targetTable: 'sales_results',
targetId: createdSales[0]?.id || 0,
newValue: {
count: createdSales.length,
idempotencyKey
}
}
});
});
return NextResponse.json({
success: true,
code: 'IMPORT_SUCCESSFUL',
metadata: {
count: rows.length,
totalAmount
}
}, { status: 201 });
} catch (err: any) {
console.error('Sales import error:', err);
return NextResponse.json({
success: false,
error: {
code: 'INTERNAL_SERVER_ERROR',
metadata: { message: err.message }
}
}, { status: 500 });
}
}, ['admin', 'analyst', 'commercial_leader']);

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
export const GET = withAuth(async (req, { prisma, params }) => {
const unwrappedParams = await params;
const { key } = unwrappedParams;
if (!key) {
return NextResponse.json({
success: false,
error: {
code: 'MISSING_KEY',
metadata: {}
}
}, { status: 400 });
}
const count = await prisma.salesResult.count({
where: {
idempotencyKey: {
startsWith: `${key}-`
}
}
});
return NextResponse.json({
success: true,
code: 'STATUS_CHECKED',
metadata: {
idempotencyKey: key,
status: count > 0 ? 'SUCCESS' : 'PROCESSING'
}
});
}, ['admin', 'analyst', 'commercial_leader']);

View file

@ -0,0 +1,364 @@
.container {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--background);
}
.main {
flex: 1;
padding: var(--space-8) var(--space-12);
display: flex;
justify-content: center;
align-items: flex-start;
max-width: 1200px;
width: 100%;
margin: 0 auto;
}
.card {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-8);
width: 100%;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
gap: var(--space-6);
transition: box-shadow var(--transition-normal);
}
.card:hover {
box-shadow: var(--shadow-lg), var(--shadow-glow);
}
.header {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.title {
font-size: var(--text-2xl);
font-weight: var(--weight-bold);
color: var(--foreground);
letter-spacing: -0.02em;
}
.subtitle {
font-size: var(--text-sm);
color: var(--foreground);
opacity: 0.6;
}
.templateDownload {
display: flex;
align-items: center;
justify-content: space-between;
background-color: hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.05);
border: 1px dashed var(--primary);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-6);
gap: var(--space-4);
flex-wrap: wrap;
}
.templateLabel {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--foreground);
opacity: 0.8;
}
.downloadBtn {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--background);
background-color: var(--primary);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
transition: transform var(--transition-fast), background-color var(--transition-fast);
cursor: pointer;
}
.downloadBtn:hover {
background-color: hsl(var(--primary-h), var(--primary-s), calc(var(--primary-l) - 5%));
transform: translateY(-1px);
}
/* Drag & Drop Area styling */
.dropZone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
padding: var(--space-12) var(--space-8);
background-color: hsla(0, 0%, 50%, 0.02);
cursor: pointer;
transition: border-color var(--transition-normal), background-color var(--transition-normal);
position: relative;
}
.dropZoneActive {
border-color: var(--primary);
background-color: hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.02);
}
.dropZoneHasFile {
border-color: hsl(142, 70%, 45%);
background-color: rgba(142, 220, 145, 0.02);
}
.fileInput {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
opacity: 0;
cursor: pointer;
}
.dropLabel {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
text-align: center;
cursor: pointer;
width: 100%;
}
.uploadIcon {
font-size: var(--text-3xl);
margin-bottom: var(--space-2);
}
.dropText {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
}
.browseText {
color: var(--primary);
text-decoration: underline;
font-weight: var(--weight-semibold);
}
.supportedText {
font-size: var(--text-xs);
color: var(--foreground);
opacity: 0.5;
margin-top: var(--space-1);
}
.fileName {
font-size: var(--text-base);
font-weight: var(--weight-semibold);
color: var(--foreground);
}
.fileSize {
font-size: var(--text-xs);
color: var(--foreground);
opacity: 0.5;
margin-top: var(--space-1);
}
/* Progress styles */
.progressContainer {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.progressBarWrapper {
width: 100%;
height: var(--space-2);
background-color: var(--border);
border-radius: var(--radius-full);
overflow: hidden;
}
.progressBar {
height: 100%;
background-color: var(--primary);
border-radius: var(--radius-full);
transition: width var(--transition-normal);
}
.progressText {
display: flex;
flex-direction: column;
font-size: var(--text-xs);
color: var(--foreground);
opacity: 0.8;
gap: var(--space-1);
}
.statusText {
color: var(--primary);
font-weight: var(--weight-medium);
}
/* Buttons actions */
.actions {
display: flex;
gap: var(--space-4);
}
.btnPrimary {
padding: var(--space-3) var(--space-6);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--background);
background-color: var(--primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast), transform var(--transition-fast);
}
.btnPrimary:hover:not(:disabled) {
background-color: hsl(var(--primary-h), var(--primary-s), calc(var(--primary-l) - 5%));
transform: translateY(-1px);
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btnSecondary {
padding: var(--space-3) var(--space-6);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--foreground);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.btnSecondary:hover {
background-color: var(--border);
border-color: var(--foreground);
}
/* Alert styles */
.alertSuccess {
display: flex;
gap: var(--space-3);
background-color: rgba(142, 220, 145, 0.1);
border: 1px solid hsl(142, 70%, 45%);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-6);
font-size: var(--text-sm);
color: var(--foreground);
}
.alertError {
display: flex;
gap: var(--space-3);
background-color: rgba(220, 145, 145, 0.1);
border: 1px solid hsl(0, 85%, 60%);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-6);
font-size: var(--text-sm);
color: var(--foreground);
}
.alertIcon {
font-size: var(--text-lg);
line-height: 1;
}
/* Inconsistency Panel */
.inconsistencyContainer {
display: flex;
flex-direction: column;
gap: var(--space-3);
border-top: 1px solid var(--border);
padding-top: var(--space-6);
}
.inconsistencyTitle {
font-size: var(--text-base);
font-weight: var(--weight-bold);
color: var(--foreground);
}
.tableWrapper {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.inconsistencyTable {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
text-align: left;
}
.inconsistencyTable th {
background-color: var(--border);
padding: var(--space-3) var(--space-4);
font-weight: var(--weight-semibold);
color: var(--foreground);
opacity: 0.8;
}
.inconsistencyTable td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
color: var(--foreground);
}
.inconsistencyRow:hover {
background-color: rgba(0, 0, 0, 0.01);
}
[data-theme='dark'] .inconsistencyRow:hover {
background-color: rgba(255, 255, 255, 0.01);
}
.cellRow {
font-weight: var(--weight-semibold);
}
.cellCol {
color: var(--primary);
font-weight: var(--weight-medium);
}
.cellVal code {
font-family: var(--font-mono);
background-color: var(--border);
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
}
.cellMsg {
color: hsl(0, 85%, 60%);
}
@media (max-width: 768px) {
.main {
padding: var(--space-4);
}
.card {
padding: var(--space-4);
}
.templateDownload {
flex-direction: column;
align-items: flex-start;
}
}

View file

@ -0,0 +1,330 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Header from '@/components/Header';
import styles from './page.module.css';
interface ValidationError {
row: number;
column: string;
value: any;
code: string;
metadata: any;
}
const TRANSLATIONS: Record<string, string> = {
USER_NOT_FOUND: "El colaborador '{username}' no existe en el sistema.",
INVALID_PERIOD_FORMAT: "El período '{value}' no tiene formato válido (esperado: {expected}).",
HOTEL_NOT_FOUND: "El hotel '{hotelCode}' no existe en el sistema.",
HOTEL_REGION_MISMATCH: "El hotel '{hotelCode}' no pertenece a su región autorizada.",
INVALID_AMOUNT: "El monto '{value}' no es válido. Debe ser un número positivo.",
INVALID_COUNT: "La cantidad '{value}' no es válida. Debe ser un número entero positivo.",
USER_REQUIRED: "El colaborador es obligatorio.",
HOTEL_REQUIRED: "El hotel es obligatorio.",
IMPORT_VALIDATION_FAILED: "El archivo cargado contiene inconsistencias de validación.",
MISSING_IDEMPOTENCY_KEY: "El encabezado de idempotencia es obligatorio.",
FILE_REQUIRED: "Debe seleccionar un archivo válido.",
EMPTY_FILE: "El archivo está vacío y no contiene registros.",
INTEGRATION_ERROR: "El motor de integraciones (n8n) reportó un fallo al procesar la solicitud.",
IMPORT_ALREADY_PROCESSED: "Este archivo ya fue cargado y procesado con éxito anteriormente.",
IMPORT_SUCCESSFUL: "Importación completada con éxito. Se cargaron {count} registros.",
IMPORT_ACCEPTED: "La importación ha sido aceptada y se está procesando mediante n8n en segundo plano."
};
const translate = (code: string, metadata: any = {}) => {
let template = TRANSLATIONS[code] || code;
for (const key of Object.keys(metadata)) {
template = template.replace(`{${key}}`, String(metadata[key]));
}
return template;
};
export default function SalesImportPage() {
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [idempotencyKey, setIdempotencyKey] = useState('');
const [successData, setSuccessData] = useState<{ count: number; totalAmount?: number } | null>(null);
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
const [generalError, setGeneralError] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState('');
useEffect(() => {
// Generate unique idempotency key for this session/upload instance
const key = 'key-' + Date.now() + '-' + Math.random().toString(36).substring(2, 9);
setIdempotencyKey(key);
}, []);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const droppedFile = e.dataTransfer.files[0];
if (droppedFile.name.endsWith('.xlsx') || droppedFile.name.endsWith('.xls') || droppedFile.name.endsWith('.csv')) {
setFile(droppedFile);
setGeneralError(null);
setValidationErrors([]);
setSuccessData(null);
} else {
setGeneralError("Tipo de archivo no soportado. Cargue archivos .xlsx, .xls o .csv");
}
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
setFile(selectedFile);
setGeneralError(null);
setValidationErrors([]);
setSuccessData(null);
}
};
const startPolling = (key: string) => {
setStatusMessage("Procesando integración asíncrona mediante n8n...");
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/sales/import/status/${key}`);
if (res.ok) {
const data = await res.json();
if (data.metadata?.status === 'SUCCESS') {
clearInterval(interval);
setIsUploading(false);
setProgress(100);
setSuccessData({ count: data.metadata.count || 0 });
setStatusMessage('');
}
}
} catch (err) {
console.error('Polling status error:', err);
}
}, 2000);
// Safety timeout: stop polling after 30 seconds
setTimeout(() => {
clearInterval(interval);
if (isUploading) {
setIsUploading(false);
setGeneralError("El procesamiento por n8n está tomando más tiempo de lo esperado. Revise el historial más tarde.");
}
}, 30000);
};
const handleUpload = async () => {
if (!file || !idempotencyKey) return;
setIsUploading(true);
setProgress(10);
setGeneralError(null);
setValidationErrors([]);
setSuccessData(null);
const formData = new FormData();
formData.append('file', file);
try {
setProgress(40);
const res = await fetch('/api/sales/import', {
method: 'POST',
headers: {
'idempotency-key': idempotencyKey
},
body: formData
});
setProgress(80);
const data = await res.json();
if (res.status === 201) {
// Direct successful import
setProgress(100);
setIsUploading(false);
setSuccessData(data.metadata || { count: 0 });
} else if (res.status === 202) {
// Accepted (asynchronous processing via n8n)
setProgress(90);
startPolling(idempotencyKey);
} else if (res.status === 200 && data.code === 'IMPORT_ALREADY_PROCESSED') {
// Idempotency cached response
setProgress(100);
setIsUploading(false);
setSuccessData(data.metadata);
setGeneralError(translate(data.code, data.metadata));
} else {
// Error occurred
setIsUploading(false);
setProgress(0);
if (data.error?.code === 'IMPORT_VALIDATION_FAILED') {
setValidationErrors(data.error.details || []);
setGeneralError(translate(data.error.code));
} else {
setGeneralError(translate(data.error?.code || 'INTERNAL_SERVER_ERROR', data.error?.metadata));
}
}
} catch (err: any) {
setIsUploading(false);
setProgress(0);
setGeneralError("Fallo de red o error inesperado al subir el archivo.");
}
};
return (
<div className={styles.container}>
<Header activeTab="import" />
<main className={styles.main}>
<div className={styles.card}>
<div className={styles.header}>
<h1 className={styles.title}>Cargar Ventas Comerciales</h1>
<p className={styles.subtitle}>Importe el archivo de resultados para procesar las comisiones del periodo.</p>
</div>
<div className={styles.templateDownload}>
<span className={styles.templateLabel}>Utilice la plantilla oficial para evitar inconsistencias:</span>
<a
href="/templates/import_sales_template.xlsx"
download="import_sales_template.xlsx"
className={styles.downloadBtn}
id="btn-download-template"
>
Descargar Plantilla (.xlsx)
</a>
</div>
{/* Drag & Drop Area */}
<div
className={`${styles.dropZone} ${dragActive ? styles.dropZoneActive : ''} ${file ? styles.dropZoneHasFile : ''}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
id="file-upload"
className={styles.fileInput}
onChange={handleFileChange}
accept=".xlsx,.xls,.csv"
/>
<label htmlFor="file-upload" className={styles.dropLabel}>
<div className={styles.uploadIcon}>📥</div>
{file ? (
<div>
<p className={styles.fileName}>{file.name}</p>
<p className={styles.fileSize}>{(file.size / 1024).toFixed(1)} KB</p>
</div>
) : (
<div>
<p className={styles.dropText}>Arrastre y suelte su archivo aquí, o <span className={styles.browseText}>explore archivos</span></p>
<p className={styles.supportedText}>Formatos permitidos: .xlsx, .xls, .csv</p>
</div>
)}
</label>
</div>
{/* Progress Bar */}
{isUploading && (
<div className={styles.progressContainer}>
<div className={styles.progressBarWrapper}>
<div className={styles.progressBar} style={{ width: `${progress}%` }}></div>
</div>
<div className={styles.progressText}>
<span>Subiendo y verificando archivo... {progress}%</span>
{statusMessage && <p className={styles.statusText}>{statusMessage}</p>}
</div>
</div>
)}
{/* Action buttons */}
<div className={styles.actions}>
<button
onClick={handleUpload}
disabled={!file || isUploading}
className={styles.btnPrimary}
id="btn-submit-upload"
>
Procesar Archivo
</button>
{file && !isUploading && (
<button
onClick={() => { setFile(null); setSuccessData(null); setValidationErrors([]); setGeneralError(null); }}
className={styles.btnSecondary}
id="btn-clear-file"
>
Limpiar
</button>
)}
</div>
{/* Successful Import Alert */}
{successData && (
<div className={styles.alertSuccess} id="upload-success-msg">
<span className={styles.alertIcon}></span>
<div>
<strong>Carga exitosa:</strong>
<p>Se importaron exitosamente {successData.count} registros de venta.</p>
{successData.totalAmount !== undefined && <p>Monto consolidado: ${successData.totalAmount.toLocaleString()}</p>}
</div>
</div>
)}
{/* General Error Alert */}
{generalError && (
<div className={styles.alertError} id="upload-error-msg">
<span className={styles.alertIcon}></span>
<div>
<strong>Inconsistencia detectada:</strong>
<p>{generalError}</p>
</div>
</div>
)}
{/* Row-Level Inconsistency Panel */}
{validationErrors.length > 0 && (
<div className={styles.inconsistencyContainer} id="inconsistency-panel">
<h3 className={styles.inconsistencyTitle}>Detalle de Inconsistencias en las Filas</h3>
<div className={styles.tableWrapper}>
<table className={styles.inconsistencyTable}>
<thead>
<tr>
<th>Fila</th>
<th>Columna</th>
<th>Valor Leído</th>
<th>Detalle del Error</th>
</tr>
</thead>
<tbody>
{validationErrors.map((err, idx) => (
<tr key={idx} className={styles.inconsistencyRow}>
<td className={styles.cellRow}>Fila {err.row}</td>
<td className={styles.cellCol}>{err.column}</td>
<td className={styles.cellVal}><code>{String(err.value || '')}</code></td>
<td className={styles.cellMsg}>{translate(err.code, err.metadata)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</main>
</div>
);
}

75
src/components/Header.tsx Normal file
View file

@ -0,0 +1,75 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import styles from './Header.module.css';
interface HeaderProps {
activeTab: 'plans' | 'goals' | 'import' | 'none';
}
export default function Header({ activeTab }: HeaderProps) {
const router = useRouter();
const [role, setRole] = React.useState<string | null>(null);
React.useEffect(() => {
fetch('/api/auth/me')
.then(res => {
if (res.ok) return res.json();
throw new Error('Not authenticated');
})
.then(data => {
setRole(data.user?.role || null);
})
.catch(err => {
console.error('Failed to get session user details', err);
});
}, []);
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
} catch (err) {
console.error('Failed to log out', err);
}
};
const showImportLink = role === 'admin' || role === 'analyst' || role === 'commercial_leader';
return (
<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} ${activeTab === 'plans' ? styles.navLinkActive : ''}`}
>
Planes de Comisión
</Link>
<Link
href="/goals"
className={`${styles.navLink} ${activeTab === 'goals' ? styles.navLinkActive : ''}`}
>
Metas Comerciales
</Link>
{showImportLink && (
<Link
href="/sales/import"
className={`${styles.navLink} ${activeTab === 'import' ? styles.navLinkActive : ''}`}
id="nav-import-sales"
>
Cargar Ventas
</Link>
)}
</nav>
<button onClick={handleLogout} className={styles.logoutBtn}>
Cerrar Sesión
</button>
</header>
);
}