From b6fa061208b32f39e5d4c27cad7eb074d1ce13cd Mon Sep 17 00:00:00 2001 From: gabogg Date: Thu, 11 Jun 2026 22:28:14 +0000 Subject: [PATCH] feat: implement Phase 4 - Excel import, validations, idempotency key, and E2E tests --- docs/PHASE_4_IMPLEMENTATION.md | 167 ++++++++ package.json | 9 +- prisma/test-phase4-ui.js | 387 ++++++++++++++++++ public/templates/import_sales_template.xlsx | Bin 0 -> 16255 bytes public/templates/import_sales_test.xlsx | Bin 0 -> 17358 bytes scripts/generate-sheets.js | 90 ++++ src/app/api/sales/batch-save/route.ts | 136 ++++++ src/app/api/sales/import/route.ts | 335 +++++++++++++++ .../api/sales/import/status/[key]/route.ts | 34 ++ src/app/sales/import/page.module.css | 364 ++++++++++++++++ src/app/sales/import/page.tsx | 330 +++++++++++++++ src/components/Header.tsx | 75 ++++ 12 files changed, 1923 insertions(+), 4 deletions(-) create mode 100644 docs/PHASE_4_IMPLEMENTATION.md create mode 100644 prisma/test-phase4-ui.js create mode 100644 public/templates/import_sales_template.xlsx create mode 100644 public/templates/import_sales_test.xlsx create mode 100644 scripts/generate-sheets.js create mode 100644 src/app/api/sales/batch-save/route.ts create mode 100644 src/app/api/sales/import/route.ts create mode 100644 src/app/api/sales/import/status/[key]/route.ts create mode 100644 src/app/sales/import/page.module.css create mode 100644 src/app/sales/import/page.tsx create mode 100644 src/components/Header.tsx diff --git a/docs/PHASE_4_IMPLEMENTATION.md b/docs/PHASE_4_IMPLEMENTATION.md new file mode 100644 index 0000000..e33d532 --- /dev/null +++ b/docs/PHASE_4_IMPLEMENTATION.md @@ -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. diff --git a/package.json b/package.json index 1819a60..1fb6277 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/test-phase4-ui.js b/prisma/test-phase4-ui.js new file mode 100644 index 0000000..abda6cb --- /dev/null +++ b/prisma/test-phase4-ui.js @@ -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); +}); diff --git a/public/templates/import_sales_template.xlsx b/public/templates/import_sales_template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..dcccd65ddea4aa99de73a88ea2b0bf3526a68735 GIT binary patch literal 16255 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 QK|p8l?@1WNd&~6nZ@mKeZ~y=R literal 0 HcmV?d00001 diff --git a/public/templates/import_sales_test.xlsx b/public/templates/import_sales_test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..2c35dd6534c551ccf49bcf87429716fc281685b1 GIT binary patch literal 17358 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 { + // 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 }); + } +} diff --git a/src/app/api/sales/import/route.ts b/src/app/api/sales/import/route.ts new file mode 100644 index 0000000..bc57b32 --- /dev/null +++ b/src/app/api/sales/import/route.ts @@ -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']); diff --git a/src/app/api/sales/import/status/[key]/route.ts b/src/app/api/sales/import/status/[key]/route.ts new file mode 100644 index 0000000..e676df6 --- /dev/null +++ b/src/app/api/sales/import/status/[key]/route.ts @@ -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']); diff --git a/src/app/sales/import/page.module.css b/src/app/sales/import/page.module.css new file mode 100644 index 0000000..20900b6 --- /dev/null +++ b/src/app/sales/import/page.module.css @@ -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; + } +} diff --git a/src/app/sales/import/page.tsx b/src/app/sales/import/page.tsx new file mode 100644 index 0000000..b5bd8b7 --- /dev/null +++ b/src/app/sales/import/page.tsx @@ -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 = { + 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(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([]); + const [generalError, setGeneralError] = useState(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) => { + 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 ( +
+
+
+
+
+

Cargar Ventas Comerciales

+

Importe el archivo de resultados para procesar las comisiones del periodo.

+
+ +
+ Utilice la plantilla oficial para evitar inconsistencias: + + Descargar Plantilla (.xlsx) + +
+ + {/* Drag & Drop Area */} +
+ + +
+ + {/* Progress Bar */} + {isUploading && ( +
+
+
+
+
+ Subiendo y verificando archivo... {progress}% + {statusMessage &&

{statusMessage}

} +
+
+ )} + + {/* Action buttons */} +
+ + {file && !isUploading && ( + + )} +
+ + {/* Successful Import Alert */} + {successData && ( +
+ +
+ Carga exitosa: +

Se importaron exitosamente {successData.count} registros de venta.

+ {successData.totalAmount !== undefined &&

Monto consolidado: ${successData.totalAmount.toLocaleString()}

} +
+
+ )} + + {/* General Error Alert */} + {generalError && ( +
+ ⚠️ +
+ Inconsistencia detectada: +

{generalError}

+
+
+ )} + + {/* Row-Level Inconsistency Panel */} + {validationErrors.length > 0 && ( +
+

Detalle de Inconsistencias en las Filas

+
+ + + + + + + + + + + {validationErrors.map((err, idx) => ( + + + + + + + ))} + +
FilaColumnaValor LeídoDetalle del Error
Fila {err.row}{err.column}{String(err.value || '')}{translate(err.code, err.metadata)}
+
+
+ )} + +
+
+
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..e67221d --- /dev/null +++ b/src/components/Header.tsx @@ -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(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 ( +
+
+ Remuneración Estelar +
+ + +
+ ); +}