feat: implement Phase 4 - Excel import, validations, idempotency key, and E2E tests
This commit is contained in:
parent
f79d7db793
commit
b6fa061208
12 changed files with 1923 additions and 4 deletions
167
docs/PHASE_4_IMPLEMENTATION.md
Normal file
167
docs/PHASE_4_IMPLEMENTATION.md
Normal 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.
|
||||
|
|
@ -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
387
prisma/test-phase4-ui.js
Normal 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);
|
||||
});
|
||||
BIN
public/templates/import_sales_template.xlsx
Normal file
BIN
public/templates/import_sales_template.xlsx
Normal file
Binary file not shown.
BIN
public/templates/import_sales_test.xlsx
Normal file
BIN
public/templates/import_sales_test.xlsx
Normal file
Binary file not shown.
90
scripts/generate-sheets.js
Normal file
90
scripts/generate-sheets.js
Normal 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!");
|
||||
136
src/app/api/sales/batch-save/route.ts
Normal file
136
src/app/api/sales/batch-save/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
335
src/app/api/sales/import/route.ts
Normal file
335
src/app/api/sales/import/route.ts
Normal 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']);
|
||||
34
src/app/api/sales/import/status/[key]/route.ts
Normal file
34
src/app/api/sales/import/status/[key]/route.ts
Normal 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']);
|
||||
364
src/app/sales/import/page.module.css
Normal file
364
src/app/sales/import/page.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
330
src/app/sales/import/page.tsx
Normal file
330
src/app/sales/import/page.tsx
Normal 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
75
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue