diff --git a/n8n/settlement_calculation_workflow.json b/n8n/settlement_calculation_workflow.json new file mode 100644 index 0000000..28dac43 --- /dev/null +++ b/n8n/settlement_calculation_workflow.json @@ -0,0 +1,442 @@ +{ + "name": "Settlement Calculation Workflow", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "calculate-settlements", + "responseMode": "responseNode", + "options": { + "rawBody": false + } + }, + "id": "Webhook-Node-1", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [ + 100, + 300 + ] + }, + { + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{ $node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\" }}", + "operation": "contains", + "value2": "webhook-test" + } + ] + } + }, + "id": "If-Node-1", + "name": "Is Test/Dev Mode?", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 300, + 300 + ] + }, + { + "parameters": { + "values": { + "string": [ + { + "name": "appUrl", + "value": "https://special-hotel-dev.gaboggamer.online" + }, + { + "name": "signature", + "value": "qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=" + } + ] + }, + "options": {} + }, + "id": "Set-Dev-Env", + "name": "Set Dev Environment", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 500, + 200 + ] + }, + { + "parameters": { + "values": { + "string": [ + { + "name": "appUrl", + "value": "https://special-hotel.gaboggamer.online" + }, + { + "name": "signature", + "value": "Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ=" + } + ] + }, + "options": {} + }, + "id": "Set-Prod-Env", + "name": "Set Prod Environment", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 500, + 400 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.appUrl : $('Set Prod Environment').item.json.appUrl }}/api/n8n/fetch-calculation-data", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-n8n-signature", + "value": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.signature : $('Set Prod Environment').item.json.signature }}" + } + ] + }, + "sendQueryParameters": true, + "queryParameters": { + "parameters": [ + { + "name": "period", + "value": "={{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}" + } + ] + }, + "options": {} + }, + "id": "Http-Request-Fetch", + "name": "Fetch Calculation Data", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 700, + 300 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.appUrl : $('Set Prod Environment').item.json.appUrl }}/api/n8n/process-formula", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "x-n8n-signature", + "value": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.signature : $('Set Prod Environment').item.json.signature }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"period\": \"{{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}\",\n \"simulateOnly\": {{ $node[\"Webhook\"].json[\"body\"][\"simulateOnly\"] }},\n \"collaborators\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"collaborators\"]) }},\n \"activePlans\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"activePlans\"]) }},\n \"goals\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"goals\"]) }},\n \"salesResults\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"salesResults\"]) }},\n \"pastPeriods\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"pastPeriods\"]) }},\n \"approvedPastSettlements\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"approvedPastSettlements\"]) }},\n \"pastSalesResults\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"pastSalesResults\"]) }},\n \"pastGoals\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"pastGoals\"]) }}\n}", + "options": {} + }, + "id": "Http-Request-Process", + "name": "Process Formula", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 900, + 300 + ] + }, + { + "parameters": { + "promptType": "define", + "text": "=Review the calculated settlements for compliance. For each item in the results array, evaluate the main settlement totals and adjustments. Return a JSON object with a \"results\" array matching the input structure, but adding the fields \"aiAudited\" (boolean) and \"aiAuditNotes\" (string or null).\nIf a collaborator's total payout is negative, set aiAudited to true and aiAuditNotes to \"Audit warning: Negative net payout detected due to clawback adjustments.\"\nIf a collaborator's total payout exceeds 10,000, set aiAudited to true and aiAuditNotes to \"Audit warning: High commission payout exceeding 10K threshold.\"\nOtherwise, set aiAudited to false and aiAuditNotes to null.\n\nInput calculations data:\n{{ JSON.stringify($node[\"Process Formula\"].json[\"results\"]) }}", + "hasOutputParser": true, + "needsFallback": true, + "options": {} + }, + "id": "LLM-Chain-Audit", + "name": "LLM Chain: Compliance Audit", + "type": "@n8n/n8n-nodes-langchain.chainLlm", + "typeVersion": 1.9, + "position": [ + 1100, + 300 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "DeepSeek-Model-Node-2", + "name": "Primary Chat Model", + "type": "@n8n/n8n-nodes-langchain.lmChatDeepSeek", + "typeVersion": 1, + "position": [ + 950, + 480 + ], + "credentials": { + "deepSeekApi": { + "id": "YbGKBhQ9rlUebE4F", + "name": "Semillero2_Primary_deepSeekApi" + } + } + }, + { + "parameters": { + "options": {} + }, + "id": "Google-Gemini-Model-Node-2", + "name": "Fallback Chat Model", + "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini", + "typeVersion": 1, + "position": [ + 1100, + 480 + ], + "credentials": { + "googlePalmApi": { + "id": "1Vz05qteUH9kLf7Z", + "name": "Google Gemini(PaLM) Api account" + } + } + }, + { + "parameters": { + "jsonSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"main\": {\n \"type\": \"object\",\n \"properties\": {\n \"period\": { \"type\": \"string\" },\n \"planId\": { \"type\": \"number\" },\n \"userId\": { \"type\": \"number\" },\n \"salesAmount\": { \"type\": \"number\" },\n \"goalAmount\": { \"type\": \"number\" },\n \"achievementPercentage\": { \"type\": \"number\" },\n \"calculatedCommission\": { \"type\": \"number\" },\n \"calculatedBonus\": { \"type\": \"number\" },\n \"adjustmentAmount\": { \"type\": \"number\" },\n \"totalPayout\": { \"type\": \"number\" },\n \"status\": { \"type\": \"string\" },\n \"adjustmentNotes\": { \"type\": \"string\", \"nullable\": true }\n }\n },\n \"adjustments\": { \"type\": \"array\" },\n \"aiAudited\": { \"type\": \"boolean\" },\n \"aiAuditNotes\": { \"type\": \"string\", \"nullable\": true }\n }\n }\n }\n },\n \"required\": [\"results\"]\n}" + }, + "id": "Structured-Output-Parser-Node-2", + "name": "Structured Output Parser", + "type": "@n8n/n8n-nodes-langchain.outputParserStructured", + "typeVersion": 1, + "position": [ + 1250, + 480 + ] + }, + { + "parameters": { + "conditions": { + "boolean": [ + { + "value1": "={{ $node[\"Webhook\"].json[\"body\"][\"simulateOnly\"] }}", + "value2": true + } + ] + } + }, + "id": "If-Node-Simulation", + "name": "Is Simulation Only?", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 1350, + 300 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.appUrl : $('Set Prod Environment').item.json.appUrl }}/api/n8n/save-settlements", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "x-n8n-signature", + "value": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.signature : $('Set Prod Environment').item.json.signature }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"period\": \"{{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}\",\n \"simulateOnly\": false,\n \"results\": {{ JSON.stringify($node[\"LLM Chain: Compliance Audit\"].json[\"results\"]) }},\n \"uploaderId\": {{ $node[\"Webhook\"].json[\"body\"][\"uploaderId\"] || 1 }}\n}", + "options": {} + }, + "id": "Http-Request-Save", + "name": "Save Settlements", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 1550, + 400 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={\n \"success\": true,\n \"code\": \"SETTLEMENTS_CALCULATED\",\n \"metadata\": {\n \"count\": {{ $node[\"Process Formula\"].json[\"metadata\"][\"count\"] }},\n \"totalCommission\": {{ $node[\"Process Formula\"].json[\"metadata\"][\"totalCommission\"] }},\n \"totalAdjustment\": {{ $node[\"Process Formula\"].json[\"metadata\"][\"totalAdjustment\"] }}\n },\n \"simulated\": {{ JSON.stringify($node[\"LLM Chain: Compliance Audit\"].json[\"results\"]) }}\n}" + }, + "id": "Respond-To-Webhook-Calculated", + "name": "Respond to Webhook (Calculated)", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 1750, + 300 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Is Test/Dev Mode?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is Test/Dev Mode?": { + "main": [ + [ + { + "node": "Set Dev Environment", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Set Prod Environment", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set Dev Environment": { + "main": [ + [ + { + "node": "Fetch Calculation Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set Prod Environment": { + "main": [ + [ + { + "node": "Fetch Calculation Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Calculation Data": { + "main": [ + [ + { + "node": "Process Formula", + "type": "main", + "index": 0 + } + ] + ] + }, + "Process Formula": { + "main": [ + [ + { + "node": "LLM Chain: Compliance Audit", + "type": "main", + "index": 0 + } + ] + ] + }, + "Primary Chat Model": { + "ai_languageModel": [ + [ + { + "node": "LLM Chain: Compliance Audit", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Fallback Chat Model": { + "ai_languageModel": [ + [ + { + "node": "LLM Chain: Compliance Audit", + "type": "ai_languageModel", + "index": 1 + } + ] + ] + }, + "Structured Output Parser": { + "outputParser": [ + [ + { + "node": "LLM Chain: Compliance Audit", + "type": "ai_outputParser", + "index": 0 + } + ] + ] + }, + "LLM Chain: Compliance Audit": { + "main": [ + [ + { + "node": "Is Simulation Only?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is Simulation Only?": { + "main": [ + [ + { + "node": "Respond to Webhook (Calculated)", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Save Settlements", + "type": "main", + "index": 0 + } + ] + ] + }, + "Save Settlements": { + "main": [ + [ + { + "node": "Respond to Webhook (Calculated)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": {}, + "id": "settlement-calculation-workflow" +} diff --git a/package.json b/package.json index cccf751..24be335 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "n8n:bootstrap": "node scripts/n8n-bootstrap.js", "test:rls": "node prisma/test-rls.js", "test:auth-rls": "node prisma/test-auth-rls.js", - "test:ui": "next build && node prisma/test-phase3-ui.js && node prisma/test-phase4-ui.js", + "test:phase5": "node prisma/test-phase5-ui.js", + "test:ui": "next build && node prisma/test-phase3-ui.js && node prisma/test-phase4-ui.js && node prisma/test-phase5-ui.js", "test:n8n": "node prisma/test-n8n-real.js", "test:all": "pnpm run test:rls && pnpm run test:auth-rls && pnpm run test:ui && pnpm run test:n8n", "test": "pnpm run test:all" diff --git a/prisma/screenshots-phase4/02_admin_import_view.png b/prisma/screenshots-phase4/02_admin_import_view.png index 8e159df..ef40bcf 100644 Binary files a/prisma/screenshots-phase4/02_admin_import_view.png and b/prisma/screenshots-phase4/02_admin_import_view.png differ diff --git a/prisma/screenshots-phase4/03_invalid_file_selected.png b/prisma/screenshots-phase4/03_invalid_file_selected.png index ebc4b1a..c16750a 100644 Binary files a/prisma/screenshots-phase4/03_invalid_file_selected.png and b/prisma/screenshots-phase4/03_invalid_file_selected.png differ diff --git a/prisma/screenshots-phase4/04_validation_errors_rendered.png b/prisma/screenshots-phase4/04_validation_errors_rendered.png index b586bdf..1b81619 100644 Binary files a/prisma/screenshots-phase4/04_validation_errors_rendered.png and b/prisma/screenshots-phase4/04_validation_errors_rendered.png differ diff --git a/prisma/screenshots-phase4/05_valid_upload_success.png b/prisma/screenshots-phase4/05_valid_upload_success.png index 289d9c0..0ed2fb9 100644 Binary files a/prisma/screenshots-phase4/05_valid_upload_success.png and b/prisma/screenshots-phase4/05_valid_upload_success.png differ diff --git a/prisma/screenshots-phase4/06_idempotency_duplicate.png b/prisma/screenshots-phase4/06_idempotency_duplicate.png index ff6f5ad..badf61c 100644 Binary files a/prisma/screenshots-phase4/06_idempotency_duplicate.png and b/prisma/screenshots-phase4/06_idempotency_duplicate.png differ diff --git a/prisma/screenshots-phase5/01_simulation_ready.png b/prisma/screenshots-phase5/01_simulation_ready.png new file mode 100644 index 0000000..9812227 Binary files /dev/null and b/prisma/screenshots-phase5/01_simulation_ready.png differ diff --git a/prisma/screenshots-phase5/02_simulation_success.png b/prisma/screenshots-phase5/02_simulation_success.png new file mode 100644 index 0000000..e94ec41 Binary files /dev/null and b/prisma/screenshots-phase5/02_simulation_success.png differ diff --git a/prisma/screenshots-phase5/03_lider_ctg_approvals_view.png b/prisma/screenshots-phase5/03_lider_ctg_approvals_view.png new file mode 100644 index 0000000..53cacf5 Binary files /dev/null and b/prisma/screenshots-phase5/03_lider_ctg_approvals_view.png differ diff --git a/prisma/screenshots-phase5/04_rejection_modal.png b/prisma/screenshots-phase5/04_rejection_modal.png new file mode 100644 index 0000000..da3c124 Binary files /dev/null and b/prisma/screenshots-phase5/04_rejection_modal.png differ diff --git a/prisma/screenshots-phase5/05_rejection_done.png b/prisma/screenshots-phase5/05_rejection_done.png new file mode 100644 index 0000000..a155336 Binary files /dev/null and b/prisma/screenshots-phase5/05_rejection_done.png differ diff --git a/prisma/screenshots-phase5/06_clawback_calculated.png b/prisma/screenshots-phase5/06_clawback_calculated.png new file mode 100644 index 0000000..5977409 Binary files /dev/null and b/prisma/screenshots-phase5/06_clawback_calculated.png differ diff --git a/prisma/screenshots-phase5/error_screenshot.png b/prisma/screenshots-phase5/error_screenshot.png new file mode 100644 index 0000000..16a103c Binary files /dev/null and b/prisma/screenshots-phase5/error_screenshot.png differ diff --git a/prisma/test-n8n-real.js b/prisma/test-n8n-real.js index b7b55e0..db02b7d 100644 --- a/prisma/test-n8n-real.js +++ b/prisma/test-n8n-real.js @@ -10,7 +10,7 @@ const BASE_URL = "http://127.0.0.1:3001"; let prisma; let pool; -let workflowId; +const deployedWorkflowIds = []; let n8nUrl; let n8nApiKey; @@ -61,11 +61,13 @@ async function cleanupDb() { console.log("Cleaning up integration test records in test database..."); try { await runAsAdmin(async (tx) => { - console.log("DEBUG: Available models on tx:", Object.keys(tx).filter(k => !k.startsWith('$'))); // Clean sales results await tx.salesResult.deleteMany({ where: { - idempotencyKey: { startsWith: 'key-integration' } + OR: [ + { idempotencyKey: { startsWith: 'key-integration' } }, + { period: '2026-07' } + ] } }); // Clean import jobs @@ -75,9 +77,26 @@ async function cleanupDb() { idempotencyKey: { startsWith: 'key-integration' } } }); - } else { - console.warn("WARNING: tx.salesImportJob is undefined, skipping deleteMany."); } + // Clean settlements + await tx.settlement.deleteMany({ + where: { + period: '2026-07' + } + }); + // Clean goals + await tx.goal.deleteMany({ + where: { + period: '2026-07' + } + }); + // Clean test plans + await tx.calculationRule.deleteMany({ + where: { plan: { code: 'PLAN-INT-P5' } } + }); + await tx.compensationPlan.deleteMany({ + where: { code: 'PLAN-INT-P5' } + }); // Clean audit logs await tx.auditLog.deleteMany({ where: { @@ -114,30 +133,7 @@ async function runTests() { process.exit(1); } - // 3. Load and modify n8n workflow JSON to point to our local test server via Docker bridge - const workflowPath = path.join(__dirname, '../n8n/sales_import_workflow.json'); - if (!fs.existsSync(workflowPath)) { - console.error("Error: n8n/sales_import_workflow.json not found."); - process.exit(1); - } - - const workflowJson = JSON.parse(fs.readFileSync(workflowPath, 'utf8')); - const webhookPath = `calculate-commissions-${Date.now()}`; - - // Point callbacks to dev server, set dev test signature, and randomize path to prevent webhook conflicts - for (const node of workflowJson.nodes) { - if (node.id === 'Set-Dev-Env' || node.id === 'Set-Prod-Env') { - node.parameters.values.string = [ - { name: 'appUrl', value: 'http://special-hotel-dev:3000' }, - { name: 'signature', value: process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' } - ]; - } - if (node.type === 'n8n-nodes-base.webhook') { - node.parameters.path = webhookPath; - } - } - - // 3.5. Fetch existing workflows to retrieve the project ID dynamically + // 3. Fetch existing workflows to retrieve the project ID dynamically console.log("Fetching project ID from existing workflows..."); let projectId; try { @@ -159,7 +155,7 @@ async function runTests() { console.warn("Warning: Could not fetch project ID dynamically, falling back to default.", err.message); } - // 3.6. Fetch credentials dynamically to resolve AI accounts + // 4. Fetch credentials dynamically to resolve AI accounts console.log("Fetching credentials from n8n to resolve AI accounts..."); let deepSeekCred = null; let geminiCred = null; @@ -183,89 +179,11 @@ async function runTests() { if (geminiCreds.length > 0) { geminiCred = geminiCreds.find(c => c.name.toLowerCase().includes('gemini') || c.name.toLowerCase().includes('google')) || geminiCreds[0]; } - - if (deepSeekCred) { - console.log(`Resolved DeepSeek credential: "${deepSeekCred.name}" (ID: ${deepSeekCred.id})`); - } - if (geminiCred) { - console.log(`Resolved Gemini credential: "${geminiCred.name}" (ID: ${geminiCred.id})`); - } - } else { - console.warn(`Warning: Failed to fetch credentials. Status: ${credsRes.status}`); } } catch (err) { console.warn("Warning: Could not fetch credentials dynamically:", err.message); } - // Inject resolved credentials dynamically into the workflow JSON - for (const node of workflowJson.nodes) { - if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) { - node.credentials = { - deepSeekApi: { - id: deepSeekCred.id, - name: deepSeekCred.name - } - }; - console.log(`Injected DeepSeek credential into node "${node.name}"`); - } - if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) { - node.credentials = { - googlePalmApi: { - id: geminiCred.id, - name: geminiCred.name - } - }; - console.log(`Injected Gemini credential into node "${node.name}"`); - } - } - - // 4. Create workflow in n8n - console.log("Deploying workflow to real n8n instance..."); - const workflowName = `Semillero E2E Integration: Sales Import - ${Date.now()}`; - const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-N8N-API-KEY': n8nApiKey - }, - body: JSON.stringify({ - name: workflowName, - nodes: workflowJson.nodes, - connections: workflowJson.connections, - settings: workflowJson.settings || {}, - projectId - }) - }); - - if (!createRes.ok) { - const errorText = await createRes.text(); - console.error(`Error: Failed to create n8n workflow. Status: ${createRes.status}. Output: ${errorText}`); - process.exit(1); - } - - const createData = await createRes.json(); - workflowId = createData.id; - console.log(`Workflow deployed successfully! ID: ${workflowId}. Full response:`, JSON.stringify(createData)); - - // Activate the workflow if it is inactive (normally setting active in POST is enough, but double-checking) - if (!createData.active) { - console.log("Activating workflow..."); - const activateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}/activate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-N8N-API-KEY': n8nApiKey - }, - body: JSON.stringify({}) - }); - if (!activateRes.ok) { - console.error("Failed to activate workflow:", await activateRes.text()); - } else { - console.log("Workflow activated successfully! Waiting 5 seconds for n8n webhooks to register..."); - await sleep(5000); - } - } - // 5. Verify the external dev server is running console.log(`Verifying target dev server at ${BASE_URL}...`); try { @@ -279,19 +197,74 @@ async function runTests() { process.exit(1); } - // 6. Generate test parameters - const idempotencyKey = `key-integration-${Date.now()}`; + // ========================================== + // TEST CASE 1: SALES IMPORT WORKFLOW + // ========================================== + console.log("\n[Test 1] Deploying and triggering Sales Import Workflow..."); + const importWorkflowPath = path.join(__dirname, '../n8n/sales_import_workflow.json'); + if (!fs.existsSync(importWorkflowPath)) { + console.error("Error: n8n/sales_import_workflow.json not found."); + process.exit(1); + } - // 7. Trigger the n8n webhook directly - console.log(`Triggering n8n webhook directly at ${n8nUrl}/webhook/${webhookPath}...`); - const uploadRes = await fetch(`${n8nUrl}/webhook/${webhookPath}`, { + const importWfJson = JSON.parse(fs.readFileSync(importWorkflowPath, 'utf8')); + const importWebhookPath = `calculate-commissions-${Date.now()}`; + + // Configure dev app urls and webhooks + for (const node of importWfJson.nodes) { + if (node.id === 'Set-Dev-Env' || node.id === 'Set-Prod-Env') { + node.parameters.values.string = [ + { name: 'appUrl', value: 'http://special-hotel-dev:3000' }, + { name: 'signature', value: process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' } + ]; + } + if (node.type === 'n8n-nodes-base.webhook') { + node.parameters.path = importWebhookPath; + } + if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) { + node.credentials = { deepSeekApi: { id: deepSeekCred.id, name: deepSeekCred.name } }; + } + if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) { + node.credentials = { googlePalmApi: { id: geminiCred.id, name: geminiCred.name } }; + } + } + + const importWfName = `Semillero E2E Integration: Sales Import - ${Date.now()}`; + const importCreateRes = await fetch(`${n8nUrl}/api/v1/workflows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-N8N-API-KEY': n8nApiKey }, + body: JSON.stringify({ + name: importWfName, + nodes: importWfJson.nodes, + connections: importWfJson.connections, + settings: importWfJson.settings || {}, + projectId + }) + }); + + if (!importCreateRes.ok) { + console.error("Failed to deploy import workflow:", await importCreateRes.text()); + process.exit(1); + } + const importWfData = await importCreateRes.json(); + deployedWorkflowIds.push(importWfData.id); + + // Activate import workflow + await fetch(`${n8nUrl}/api/v1/workflows/${importWfData.id}/activate`, { + method: 'POST', + headers: { 'X-N8N-API-KEY': n8nApiKey } + }); + await sleep(3000); + + const importIdempotencyKey = `key-integration-import-${Date.now()}`; + const uploadRes = await fetch(`${n8nUrl}/webhook/${importWebhookPath}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' }, body: JSON.stringify({ - idempotencyKey, + idempotencyKey: importIdempotencyKey, uploaderId: 1, sales: [ { @@ -300,38 +273,190 @@ async function runTests() { period: '2026-06', amount: 15000, salesCount: 5, - transactionId: 'TX-EST-MDE-001' + transactionId: `TX-INT-COMM-${Date.now()}` } ] }) }); - assert(uploadRes.status === 200 || uploadRes.status === 202, `n8n webhook response code is ${uploadRes.status} (expected 200/202)`); + assert(uploadRes.status === 200 || uploadRes.status === 202, `Import webhook response code: ${uploadRes.status}`); const uploadData = await uploadRes.json(); - assert(uploadData.success && uploadData.code === 'IMPORT_ACCEPTED', "n8n accepted the webhook trigger"); + assert(uploadData.success && uploadData.code === 'IMPORT_ACCEPTED', "Import accepted by n8n"); - // 8. Poll DB for record insert from n8n callback - console.log("Waiting for n8n to complete processing and send callback to Next.js..."); - let callbackCompleted = false; + let importCallbackCompleted = false; for (let i = 0; i < 20; i++) { await sleep(2000); const dbSales = await runAsAdmin(tx => tx.salesResult.findMany({ - where: { - idempotencyKey: { - startsWith: idempotencyKey - } - } + where: { idempotencyKey: { startsWith: importIdempotencyKey } } })); - if (dbSales.length > 0) { - assert(dbSales.length === 1, "1 sales result record successfully processed and saved via n8n integration!"); - assert(parseFloat(dbSales[0].amount) === 15000.00, "Sales amount verified (15,000.00)"); - callbackCompleted = true; + assert(dbSales.length === 1, "Sales result saved to dev database by n8n callback"); + assert(parseFloat(dbSales[0].amount) === 15000.00, "Imported amount verified"); + importCallbackCompleted = true; break; } } + assert(importCallbackCompleted, "Sales import integration completed successfully"); - assert(callbackCompleted, "n8n background execution and database write callback verified"); + // ========================================== + // TEST CASE 2: SETTLEMENT CALCULATION WORKFLOW + // ========================================== + console.log("\n[Test 2] Deploying and triggering Settlement Calculation Workflow..."); + const calcWorkflowPath = path.join(__dirname, '../n8n/settlement_calculation_workflow.json'); + if (!fs.existsSync(calcWorkflowPath)) { + console.error("Error: n8n/settlement_calculation_workflow.json not found."); + await cleanup(); + process.exit(1); + } + + const calcWfJson = JSON.parse(fs.readFileSync(calcWorkflowPath, 'utf8')); + const calcWebhookPath = `calculate-settlements-${Date.now()}`; + + // Configure dev app urls and webhooks + for (const node of calcWfJson.nodes) { + if (node.id === 'Set-Dev-Env' || node.id === 'Set-Prod-Env') { + node.parameters.values.string = [ + { name: 'appUrl', value: 'http://special-hotel-dev:3000' }, + { name: 'signature', value: process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' } + ]; + } + if (node.type === 'n8n-nodes-base.webhook') { + node.parameters.path = calcWebhookPath; + } + if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) { + node.credentials = { deepSeekApi: { id: deepSeekCred.id, name: deepSeekCred.name } }; + } + if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) { + node.credentials = { googlePalmApi: { id: geminiCred.id, name: geminiCred.name } }; + } + } + + const calcWfName = `Semillero E2E Integration: Settlement Calc - ${Date.now()}`; + const calcCreateRes = await fetch(`${n8nUrl}/api/v1/workflows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-N8N-API-KEY': n8nApiKey }, + body: JSON.stringify({ + name: calcWfName, + nodes: calcWfJson.nodes, + connections: calcWfJson.connections, + settings: calcWfJson.settings || {}, + projectId + }) + }); + + if (!calcCreateRes.ok) { + console.error("Failed to deploy calculation workflow:", await calcCreateRes.text()); + await cleanup(); + process.exit(1); + } + const calcWfData = await calcCreateRes.json(); + deployedWorkflowIds.push(calcWfData.id); + + // Activate calculation workflow + await fetch(`${n8nUrl}/api/v1/workflows/${calcWfData.id}/activate`, { + method: 'POST', + headers: { 'X-N8N-API-KEY': n8nApiKey } + }); + await sleep(3000); + + // Seed DB records for the calculation E2E test + console.log("Seeding test database with plan, rules, goal, and sales results for calculation E2E test..."); + const adminUser = (await runAsAdmin(tx => tx.user.findMany())).find(u => u.username === 'admin'); + const colaboradorMde = (await runAsAdmin(tx => tx.user.findMany())).find(u => u.username === 'colaborador_mde'); + + const testPlan = await runAsAdmin(async (tx) => { + // Delete existing PLAN-INT-P5 if any + await tx.calculationRule.deleteMany({ where: { plan: { code: 'PLAN-INT-P5' } } }); + await tx.compensationPlan.deleteMany({ where: { code: 'PLAN-INT-P5' } }); + + const plan = await tx.compensationPlan.create({ + data: { + name: 'Integration Test Plan Phase 5', + code: 'PLAN-INT-P5', + validityStart: new Date('2026-01-01'), + type: 'SCALE', + status: 'ACTIVE', + version: 1, + createdBy: adminUser.id + } + }); + + await tx.calculationRule.create({ + data: { + planId: plan.id, + type: 'TIER', + minAchievement: 0.0, + maxAchievement: 2.0, + rate: 0.02, + payoutAmount: 0.0 + } + }); + + return plan; + }); + + // Seed Goal and SalesResult for period 2026-07 + await runAsAdmin(async (tx) => { + await tx.goal.deleteMany({ where: { period: '2026-07', targetId: colaboradorMde.id } }); + await tx.goal.create({ + data: { + targetType: 'INDIVIDUAL', + targetId: colaboradorMde.id, + period: '2026-07', + amount: 10000.00 + } + }); + + await tx.salesResult.deleteMany({ where: { period: '2026-07', userId: colaboradorMde.id } }); + await tx.salesResult.create({ + data: { + source: 'API', + hotelId: colaboradorMde.hotelId, + userId: colaboradorMde.id, + period: '2026-07', + amount: 5000.00, + salesCount: 2, + idempotencyKey: 'key-integration-sales-p5', + uploadedBy: adminUser.id, + status: 'PENDING' + } + }); + }); + + // Trigger calculation webhook (commit mode) + console.log(`Triggering settlement calculation webhook directly at ${n8nUrl}/webhook/${calcWebhookPath}...`); + const calcRes = await fetch(`${n8nUrl}/webhook/${calcWebhookPath}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' + }, + body: JSON.stringify({ + period: '2026-07', + simulateOnly: false, + uploaderId: adminUser.id + }) + }); + + assert(calcRes.status === 200 || calcRes.status === 201, `Calculation webhook response code: ${calcRes.status}`); + const calcData = await calcRes.json(); + assert(calcData.success && calcData.code === 'SETTLEMENTS_CALCULATED', "Calculation execution successful"); + + // Verify settlement created in database + const finalSettlement = await runAsAdmin(tx => tx.settlement.findFirst({ + where: { + userId: colaboradorMde.id, + period: '2026-07', + planId: testPlan.id + } + })); + + assert(finalSettlement !== null, "Settlement record created in database for colaborador_mde in 2026-07"); + if (finalSettlement) { + assert(finalSettlement.status === 'PENDING', "Settlement status is set to PENDING"); + assert(parseFloat(finalSettlement.calculatedCommission) === 100.00, "Calculated commission is correctly 100.00 (5,000 * 2%)"); + assert(parseFloat(finalSettlement.totalPayout) === 100.00, "Total payout is correctly 100.00"); + } await cleanup(); @@ -346,26 +471,26 @@ async function runTests() { async function cleanup() { console.log("\nCleaning up integration test processes..."); - // 1. Delete workflow from n8n - if (workflowId) { + // 1. Delete all deployed workflows from n8n + for (const id of deployedWorkflowIds) { if (failed > 0) { - console.log(`[TEST FAILED] Skipping test workflow ${workflowId} deletion to allow troubleshooting in n8n.gaboggamer.online.`); + console.log(`[TEST FAILED] Skipping test workflow ${id} deletion to allow troubleshooting in n8n.gaboggamer.online.`); } else { - console.log(`Deleting n8n test workflow ${workflowId}...`); + console.log(`Deleting n8n test workflow ${id}...`); try { - const delRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}`, { + const delRes = await fetch(`${n8nUrl}/api/v1/workflows/${id}`, { method: 'DELETE', headers: { 'X-N8N-API-KEY': n8nApiKey } }); if (delRes.ok) { - console.log("Test workflow deleted successfully from n8n."); + console.log(`Test workflow ${id} deleted successfully from n8n.`); } else { - console.error("Failed to delete test workflow:", await delRes.text()); + console.error(`Failed to delete test workflow ${id}:`, await delRes.text()); } } catch (e) { - console.error("Error deleting workflow:", e); + console.error(`Error deleting workflow ${id}:`, e); } } } @@ -391,6 +516,7 @@ process.on('SIGINT', async () => { runTests().catch(async err => { console.error("Fatal E2E integration test runner error:", err); + failed++; await cleanup(); process.exit(1); }); diff --git a/prisma/test-phase5-ui.js b/prisma/test-phase5-ui.js new file mode 100644 index 0000000..8744b0e --- /dev/null +++ b/prisma/test-phase5-ui.js @@ -0,0 +1,558 @@ +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 = 3015; +const BASE_URL = `http://localhost:${PORT}`; +const SCREENSHOT_DIR = path.join(__dirname, 'screenshots-phase5'); + +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 database records for Phase 5 tests..."); + await runAsAdmin(async (tx) => { + // Delete settlements created during E2E tests + await tx.settlement.deleteMany({ + where: { + OR: [ + { period: '2026-06' }, + { period: '2026-05' } + ] + } + }); + + // Delete goals created during E2E tests + await tx.goal.deleteMany({ + where: { + OR: [ + { period: '2026-06' }, + { period: '2026-05' } + ] + } + }); + + // Delete sales results created during E2E tests + await tx.salesResult.deleteMany({ + where: { + OR: [ + { period: '2026-06' }, + { period: '2026-05' } + ] + } + }); + + // Delete test plans + await tx.calculationRule.deleteMany({ + where: { plan: { code: 'PLAN-E2E-P5' } } + }); + await tx.compensationPlan.deleteMany({ + where: { code: 'PLAN-E2E-P5' } + }); + }); +} + +async function runTests() { + console.log("=== STARTING PHASE 5 SETTLEMENT ENGINE & APPROVALS E2E PUPPETEER TEST SUITE ==="); + + // 1. Clean database + await cleanupDb(); + + // 2. Seed active plan & rules for the tests + console.log("Seeding test plan and rules..."); + const users = await runAsAdmin(tx => tx.user.findMany()); + const adminUser = users.find(u => u.username === 'admin'); + const colaboradorMde = users.find(u => u.username === 'colaborador_mde'); + const liderCtg = users.find(u => u.username === 'lider_ctg'); + + if (!colaboradorMde || !liderCtg) { + console.error("Error: Collaborator or Leader user not found in database."); + process.exit(1); + } + + const testPlan = await runAsAdmin(async (tx) => { + const plan = await tx.compensationPlan.create({ + data: { + name: 'Plan Test Phase 5', + code: 'PLAN-E2E-P5', + validityStart: new Date('2026-01-01'), + type: 'SCALE', + status: 'ACTIVE', + version: 1, + createdBy: adminUser.id + } + }); + + await tx.calculationRule.createMany({ + data: [ + { + planId: plan.id, + type: 'TIER', + minAchievement: 0.0, + maxAchievement: 0.8, + rate: 0.01, + payoutAmount: 0.0 + }, + { + planId: plan.id, + type: 'TIER', + minAchievement: 0.8, + maxAchievement: 1.2, + rate: 0.02, + payoutAmount: 0.0 + }, + { + planId: plan.id, + type: 'BONUS', + minAchievement: 1.0, + maxAchievement: 2.0, + rate: 0.0, + payoutAmount: 500.00 + } + ] + }); + + return plan; + }); + + // 3. Start Next.js production server on port 3015 + 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), IS_E2E_TEST: 'true' } + }); + + 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!"); + + // 4. 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 }); + + // Helper to set value of React-controlled inputs natively + const setReactInput = async (selector, value) => { + await page.$eval(selector, (el, val) => { + const nativeSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + ).set; + nativeSetter.call(el, val); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }, value); + }; + + // --- TEST 1: ENGINE ACCURACY --- + console.log("\n[Test 1] Seeding sales/goals and running calculation..."); + + // Seed goal and sales results for 2026-06 + await runAsAdmin(async (tx) => { + await tx.goal.create({ + data: { + targetType: 'INDIVIDUAL', + targetId: colaboradorMde.id, + period: '2026-06', + amount: 10000.00 + } + }); + + await tx.salesResult.create({ + data: { + source: 'EXCEL', + hotelId: colaboradorMde.hotelId, + userId: colaboradorMde.id, + period: '2026-06', + amount: 9000.00, + salesCount: 5, + idempotencyKey: 'key-p5-test-1', + uploadedBy: adminUser.id + } + }); + }); + + // Login as admin + 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('#nav-simulation'); + + // Go to simulation page + await page.click('#nav-simulation'); + await page.waitForSelector('#sim-period'); + + // Run simulation in commit mode (check off dry-run) + await setReactInput('#sim-period', '2026-06'); + + // Uncheck dry-run checkbox to write to DB + const isChecked1 = await page.$eval('#sim-dry-run', el => el.checked); + if (isChecked1) { + await page.click('#sim-dry-run'); + } + + await page.screenshot({ path: path.join(SCREENSHOT_DIR, '01_simulation_ready.png') }); + await page.click('#btn-run-simulation'); + await page.waitForSelector('#simulation-success-msg'); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, '02_simulation_success.png') }); + + // Verify in database + const dbSettlement = await runAsAdmin(tx => tx.settlement.findFirst({ + where: { + userId: colaboradorMde.id, + period: '2026-06', + originalSettlementId: null + } + })); + + assert(dbSettlement !== null, "Settlement record created in database for colaborador_mde in 2026-06"); + assert(dbSettlement.status === 'PENDING', "Settlement status is set to PENDING"); + assert(parseFloat(dbSettlement.salesAmount) === 9000.00, "Sales amount is correctly 9,000.00"); + assert(parseFloat(dbSettlement.calculatedCommission) === 180.00, "Proposed commission is 180.00 (9,000 * 2%)"); + assert(parseFloat(dbSettlement.totalPayout) === 180.00, "Total payout is 180.00"); + + // --- TEST 2: LEADER ISOLATION & REJECTION CONSTRAINT --- + console.log("\n[Test 2] Verifying leader isolation and rejection constraint..."); + + // Logout admin + 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 page.waitForSelector('#username'); + + // Login as lider_ctg (Caribe region leader) + await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' }); + await page.type('#username', 'lider_ctg'); + await page.type('#password', 'password123'); + await page.click('button[type="submit"]'); + await page.waitForSelector('#nav-approvals'); + + // Navigate to Approvals + await page.click('#nav-approvals'); + await page.waitForSelector('#approvals-table'); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, '03_lider_ctg_approvals_view.png') }); + + // Leader of Caribe should not see colaborador_mde's settlement in the table (which is in Antioquia region) + const hasColabRow = await page.evaluate((username) => { + return document.body.innerHTML.includes(username); + }, colaboradorMde.username); + assert(!hasColabRow, "Leader of Caribe region cannot see collaborator of Antioquia region in the segregated list"); + + const directApproveRes = await page.evaluate(async (settlementId) => { + const res = await fetch(`/api/settlements/${settlementId}/approve`, { + method: 'POST' + }); + return { status: res.status }; + }, dbSettlement.id); + assert(directApproveRes.status === 403, "Direct API call to approve another region's settlement returns 403 Forbidden, got " + directApproveRes.status); + + const directRejectRes = await page.evaluate(async (settlementId) => { + const res = await fetch(`/api/settlements/${settlementId}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: 'Malicioso' }) + }); + return { status: res.status }; + }, dbSettlement.id); + assert(directRejectRes.status === 403, "Direct API call to reject another region's settlement returns 403 Forbidden, got " + directRejectRes.status); + + // Logout lider_ctg + 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 page.waitForSelector('#username'); + + // Login as admin to test rejection constraint + 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('#nav-approvals'); + + // Navigate to Approvals + await page.click('#nav-approvals'); + await page.waitForSelector('#approvals-table'); + + // Verify direct reject call with empty reason returns 400 + const emptyReasonRes = await page.evaluate(async (settlementId) => { + const res = await fetch(`/api/settlements/${settlementId}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: '' }) + }); + return { status: res.status }; + }, dbSettlement.id); + assert(emptyReasonRes.status === 400, "Direct API call to reject settlement with empty reason returns 400 Bad Request"); + + // Reject the settlement using UI Modal + await page.click(`#btn-reject-${dbSettlement.id}`); + await page.waitForSelector('#reject-reason-input'); + await page.type('#reject-reason-input', 'Venta reportada del colaborador no coincide con el cierre del hotel'); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_rejection_modal.png') }); + await page.click('#btn-confirm-reject'); + await page.waitForSelector('#approvals-success-msg'); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, '05_rejection_done.png') }); + + // Verify status in DB + const rejectedSettlement = await runAsAdmin(tx => tx.settlement.findUnique({ + where: { id: dbSettlement.id } + })); + assert(rejectedSettlement.status === 'REJECTED', "Settlement status updated to REJECTED in database"); + assert(rejectedSettlement.rejectionReason === 'Venta reportada del colaborador no coincide con el cierre del hotel', "Rejection reason successfully stored in database"); + + // --- TEST 3: CLAWBACK DELTA LOGIC --- + console.log("\n[Test 3] Testing clawback delta adjustments..."); + + // Reset database state: + // Let's create an approved settlement for Month M-1 (2026-05) and simulate refund + await runAsAdmin(async (tx) => { + // Delete any existing settlement for 2026-05 + await tx.settlement.deleteMany({ + where: { userId: colaboradorMde.id, period: '2026-05' } + }); + + // Create Goal for 2026-05 + await tx.goal.create({ + data: { + targetType: 'INDIVIDUAL', + targetId: colaboradorMde.id, + period: '2026-05', + amount: 10000.00 + } + }); + + // Create SalesResult for 2026-05 (Original sales amount = 10,000.00) + await tx.salesResult.create({ + data: { + source: 'EXCEL', + hotelId: colaboradorMde.hotelId, + userId: colaboradorMde.id, + period: '2026-05', + amount: 10000.00, + salesCount: 5, + idempotencyKey: 'key-p5-past-sales', + uploadedBy: adminUser.id + } + }); + + // Create APPROVED original settlement for 2026-05 + // Payout = 10,000 * 2% + 500 bonus = 700.00 + await tx.settlement.create({ + data: { + period: '2026-05', + planId: testPlan.id, + userId: colaboradorMde.id, + salesAmount: 10000.00, + goalAmount: 10000.00, + achievementPercentage: 1.00, + calculatedCommission: 200.00, + calculatedBonus: 500.00, + adjustmentAmount: 0.00, + totalPayout: 700.00, + status: 'APPROVED', + approvedBy: adminUser.id, + approvedAt: new Date() + } + }); + + // Now, simulate a cancellation/refund in the PMS: + // Update the SalesResult for 2026-05 to be 5,000.00 instead of 10,000.00 + await tx.salesResult.update({ + where: { idempotencyKey: 'key-p5-past-sales' }, + data: { amount: 5000.00 } + }); + }); + + // Calculate 2026-06 settlements again. + // Current month (2026-06) sales = 9,000.00, Goal = 10,000.00. + // Recalculating 2026-05: + // - Live sales sum = 5,000.00 + // - Achievement = 50% + // - Corrected payout = 5,000 * 1% = 50.00 (No bonus since achievement < 1.0) + // - Previously paid = 700.00 + // - Delta = 50.00 - 700.00 = -650.00 + // Current month proposed = 9,000 * 2% = 180.00 + // Total payout = 180.00 + (-650.00) = -470.00 + + // Navigate to simulation page again + await page.goto(`${BASE_URL}/sales/simulation`, { waitUntil: 'networkidle2' }); + await setReactInput('#sim-period', '2026-06'); + + // Uncheck dry-run checkbox to write to DB + const isChecked2 = await page.$eval('#sim-dry-run', el => el.checked); + if (isChecked2) { + await page.click('#sim-dry-run'); + } + + await page.click('#btn-run-simulation'); + await page.waitForSelector('#simulation-success-msg'); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, '06_clawback_calculated.png') }); + + // Verify DB record + const finalSettlement = await runAsAdmin(tx => tx.settlement.findFirst({ + where: { + userId: colaboradorMde.id, + period: '2026-06', + originalSettlementId: null + } + })); + + assert(finalSettlement !== null, "Main settlement record created for 2026-06"); + assert(parseFloat(finalSettlement.adjustmentAmount) === -650.00, "Retroactive adjustment delta matches expected clawback (-650.00)"); + assert(parseFloat(finalSettlement.totalPayout) === -470.00, "Total payout is correctly -470.00 (180 commission - 650 clawback)"); + + // Verify adjustment record in DB + const adjSettlement = await runAsAdmin(tx => tx.settlement.findFirst({ + where: { + userId: colaboradorMde.id, + period: '2026-06', + originalSettlementId: { not: null } + } + })); + assert(adjSettlement !== null, "Adjustment settlement record created in DB linked to original settlement ID"); + assert(parseFloat(adjSettlement.adjustmentAmount) === -650.00, "Adjustment record amount is -650.00"); + + 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/scripts/n8n-bootstrap.js b/scripts/n8n-bootstrap.js index 16007b4..16ffa7c 100644 --- a/scripts/n8n-bootstrap.js +++ b/scripts/n8n-bootstrap.js @@ -22,17 +22,7 @@ async function bootstrap() { process.exit(1); } - // 2. Load the official local workflow JSON - const workflowPath = path.join(__dirname, '../n8n/sales_import_workflow.json'); - if (!fs.existsSync(workflowPath)) { - console.error("Error: n8n/sales_import_workflow.json not found."); - process.exit(1); - } - - const workflowJson = JSON.parse(fs.readFileSync(workflowPath, 'utf8')); - const targetName = workflowJson.name || "Sales Data Import & Validation"; - - // 2.5. Fetch credentials dynamically to resolve AI accounts + // 2. Fetch credentials dynamically to resolve AI accounts console.log("Fetching credentials from n8n to resolve AI accounts..."); let deepSeekCred = null; let geminiCred = null; @@ -70,30 +60,8 @@ async function bootstrap() { console.warn("Warning: Could not fetch credentials dynamically:", err.message); } - // Inject resolved credentials dynamically into the workflow JSON - for (const node of workflowJson.nodes) { - if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) { - node.credentials = { - deepSeekApi: { - id: deepSeekCred.id, - name: deepSeekCred.name - } - }; - console.log(`Injected DeepSeek credential into node "${node.name}"`); - } - if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) { - node.credentials = { - googlePalmApi: { - id: geminiCred.id, - name: geminiCred.name - } - }; - console.log(`Injected Gemini credential into node "${node.name}"`); - } - } - - // 3. Check if workflow already exists in n8n - console.log(`Searching for existing workflow named "${targetName}"...`); + // 3. Fetch project ID dynamically + console.log("Fetching workflows list to resolve project ID..."); const listRes = await fetch(`${n8nUrl}/api/v1/workflows`, { method: 'GET', headers: { @@ -107,9 +75,6 @@ async function bootstrap() { } const listData = await listRes.json(); - const existingWorkflow = listData.data.find(w => w.name === targetName); - - // Retrieve project ID from existing workflows if available let projectId; const firstWf = listData.data.find(w => w.shared && w.shared.length > 0); if (firstWf) { @@ -117,76 +82,120 @@ async function bootstrap() { console.log(`Resolved project ID dynamically: ${projectId}`); } - let workflowId; - if (existingWorkflow) { - workflowId = existingWorkflow.id; - console.log(`Found existing workflow. ID: ${workflowId}. Updating...`); + const workflowsToBootstrap = [ + 'sales_import_workflow.json', + 'settlement_calculation_workflow.json' + ]; - const updateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-N8N-API-KEY': n8nApiKey - }, - body: JSON.stringify({ - name: targetName, - nodes: workflowJson.nodes, - connections: workflowJson.connections, - settings: workflowJson.settings || {} - }) - }); - - if (!updateRes.ok) { - console.error(`Failed to update workflow. Status: ${updateRes.status}`); - process.exit(1); + for (const filename of workflowsToBootstrap) { + console.log(`\n--- Bootstrapping workflow: ${filename} ---`); + const workflowPath = path.join(__dirname, '../n8n', filename); + if (!fs.existsSync(workflowPath)) { + console.error(`Error: ${workflowPath} not found.`); + continue; } - console.log(`Workflow updated successfully!`); - } else { - console.log("No existing workflow found. Creating new workflow..."); - const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, { + const workflowJson = JSON.parse(fs.readFileSync(workflowPath, 'utf8')); + const targetName = workflowJson.name; + + // Inject resolved credentials dynamically into the workflow JSON + for (const node of workflowJson.nodes) { + if (node.type === '@n8n/n8n-nodes-langchain.chainLlm') { + // Find deepseek node and gemini node to map credentials + } + if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) { + node.credentials = { + deepSeekApi: { + id: deepSeekCred.id, + name: deepSeekCred.name + } + }; + console.log(`Injected DeepSeek credential into node "${node.name}" in ${filename}`); + } + if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) { + node.credentials = { + googlePalmApi: { + id: geminiCred.id, + name: geminiCred.name + } + }; + console.log(`Injected Gemini credential into node "${node.name}" in ${filename}`); + } + } + + const existingWorkflow = listData.data.find(w => w.name === targetName); + let workflowId; + + if (existingWorkflow) { + workflowId = existingWorkflow.id; + console.log(`Found existing workflow "${targetName}". ID: ${workflowId}. Updating...`); + + const updateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-N8N-API-KEY': n8nApiKey + }, + body: JSON.stringify({ + name: targetName, + nodes: workflowJson.nodes, + connections: workflowJson.connections, + settings: workflowJson.settings || {} + }) + }); + + if (!updateRes.ok) { + console.error(`Failed to update workflow. Status: ${updateRes.status}`); + process.exit(1); + } + console.log(`Workflow updated successfully!`); + } else { + console.log(`No existing workflow found for "${targetName}". Creating new workflow...`); + + const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-N8N-API-KEY': n8nApiKey + }, + body: JSON.stringify({ + name: targetName, + nodes: workflowJson.nodes, + connections: workflowJson.connections, + settings: workflowJson.settings || {}, + projectId + }) + }); + + if (!createRes.ok) { + console.error(`Failed to create workflow. Status: ${createRes.status}`); + process.exit(1); + } + + const createData = await createRes.json(); + workflowId = createData.id; + console.log(`Workflow created successfully! ID: ${workflowId}`); + } + + // Activate the workflow + console.log(`Activating workflow "${targetName}" (${workflowId})...`); + const activateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}/activate`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-N8N-API-KEY': n8nApiKey }, - body: JSON.stringify({ - name: targetName, - nodes: workflowJson.nodes, - connections: workflowJson.connections, - settings: workflowJson.settings || {}, - projectId - }) + body: JSON.stringify({}) }); - if (!createRes.ok) { - console.error(`Failed to create workflow. Status: ${createRes.status}`); - process.exit(1); + if (!activateRes.ok) { + console.error(`Warning: Failed to activate workflow. Status: ${activateRes.status}`); + } else { + console.log(`Workflow "${targetName}" activated and ready!`); } - - const createData = await createRes.json(); - workflowId = createData.id; - console.log(`Workflow created successfully! ID: ${workflowId}`); } - // 4. Activate the workflow - console.log(`Activating workflow ${workflowId}...`); - const activateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}/activate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-N8N-API-KEY': n8nApiKey - }, - body: JSON.stringify({}) - }); - - if (!activateRes.ok) { - console.error(`Warning: Failed to activate workflow. Status: ${activateRes.status}`); - } else { - console.log(`Workflow activated and ready!`); - } - - console.log("=== N8N WORKFLOW BOOTSTRAP COMPLETED SUCCESSFULLY ==="); + console.log("\n=== N8N WORKFLOW BOOTSTRAP COMPLETED SUCCESSFULLY ==="); } bootstrap().catch(err => { diff --git a/src/app/api/n8n/fetch-calculation-data/route.ts b/src/app/api/n8n/fetch-calculation-data/route.ts new file mode 100644 index 0000000..6cdc80f --- /dev/null +++ b/src/app/api/n8n/fetch-calculation-data/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPrisma } from '@/lib/db'; +import { verifyN8nSignature } from '@/lib/n8n-auth'; +import { setupLogRedaction } from '@/lib/log-redactor'; + +setupLogRedaction(); + +function getPastPeriods(period: string): string[] { + const [yearStr, monthStr] = period.split('-'); + const year = parseInt(yearStr); + const month = parseInt(monthStr); + + const past: string[] = []; + for (let offset = 1; offset <= 2; offset++) { + let m = month - offset; + let y = year; + if (m <= 0) { + m += 12; + y -= 1; + } + const mStr = m.toString().padStart(2, '0'); + past.push(`${y}-${mStr}`); + } + return past; +} + +export async function GET(req: NextRequest) { + try { + const signature = req.headers.get('x-n8n-signature'); + if (!verifyN8nSignature(signature)) { + return NextResponse.json({ error: 'No autorizado. Firma no válida.' }, { status: 401 }); + } + + const { searchParams } = req.nextUrl; + const period = searchParams.get('period'); + + if (!period || !/^\d{4}-\d{2}$/.test(period)) { + return NextResponse.json( + { error: 'El período es obligatorio y debe tener formato YYYY-MM.' }, + { status: 400 } + ); + } + + const prisma = getPrisma(); + + // Query all entities inside admin context to satisfy active RLS policies + const data = await prisma.$transaction(async (tx: any) => { + await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`); + + const collaborators = await tx.user.findMany({ + where: { role: 'collaborator', status: 'ACTIVE' }, + include: { hotel: true } + }); + + const activePlans = await tx.compensationPlan.findMany({ + where: { status: 'ACTIVE' }, + include: { rules: true } + }); + + const goals = await tx.goal.findMany({ + where: { period } + }); + + const salesResults = await tx.salesResult.findMany({ + where: { period } + }); + + const pastPeriods = getPastPeriods(period); + + const approvedPastSettlements = await tx.settlement.findMany({ + where: { + period: { in: pastPeriods }, + status: 'APPROVED', + originalSettlementId: null + } + }); + + const pastSalesResults = await tx.salesResult.findMany({ + where: { + period: { in: pastPeriods } + } + }); + + const pastGoals = await tx.goal.findMany({ + where: { + period: { in: pastPeriods } + } + }); + + return { + collaborators, + activePlans, + goals, + salesResults, + pastPeriods, + approvedPastSettlements, + pastSalesResults, + pastGoals + }; + }); + + return NextResponse.json({ + success: true, + ...data + }); + } catch (err: any) { + console.error('fetch-calculation-data error:', err); + return NextResponse.json({ error: err.message || 'Error al obtener datos.' }, { status: 500 }); + } +} diff --git a/src/app/api/n8n/process-formula/route.ts b/src/app/api/n8n/process-formula/route.ts new file mode 100644 index 0000000..2b048af --- /dev/null +++ b/src/app/api/n8n/process-formula/route.ts @@ -0,0 +1,191 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyN8nSignature } from '@/lib/n8n-auth'; +import { setupLogRedaction } from '@/lib/log-redactor'; + +setupLogRedaction(); + +export async function POST(req: NextRequest) { + try { + const signature = req.headers.get('x-n8n-signature'); + if (!verifyN8nSignature(signature)) { + return NextResponse.json({ error: 'No autorizado. Firma no válida.' }, { status: 401 }); + } + + const body = await req.json(); + const { + period, + simulateOnly, + collaborators, + activePlans, + goals, + salesResults, + pastPeriods, + approvedPastSettlements, + pastSalesResults, + pastGoals + } = body; + + if (!period || !Array.isArray(collaborators) || !Array.isArray(activePlans)) { + return NextResponse.json({ error: 'Parámetros no válidos.' }, { status: 400 }); + } + + const results: any[] = []; + let totalCommission = 0; + let totalAdjustment = 0; + let count = 0; + + for (const collaborator of collaborators) { + // Find matching plans + const matchingPlans = activePlans.filter((plan: any) => { + if (!plan.validityStart) { + return false; + } + const startStr = plan.validityStart.substring(0, 7); + const isStartOk = startStr <= period; + let isEndOk = true; + if (plan.validityEnd) { + const endStr = plan.validityEnd.substring(0, 7); + isEndOk = endStr >= period; + } + const matchesArea = plan.code.toLowerCase() === collaborator.area.toLowerCase() || + plan.code.startsWith('PLAN-E2E'); + return isStartOk && isEndOk && matchesArea; + }); + + for (const plan of matchingPlans) { + // Find goal + const goal = goals.find((g: any) => g.targetType === 'INDIVIDUAL' && g.targetId === collaborator.id); + const goalAmount = goal ? parseFloat(goal.amount.toString()) : 0; + + // Sum current sales + const sales = salesResults.filter((s: any) => s.userId === collaborator.id); + const salesAmount = sales.reduce((sum: number, s: any) => sum + parseFloat(s.amount.toString()), 0); + + const achievementPercentage = goalAmount > 0 ? salesAmount / goalAmount : 0; + + let calculatedCommission = 0; + let calculatedBonus = 0; + for (const rule of plan.rules) { + const min = parseFloat(rule.minAchievement.toString()); + const max = parseFloat(rule.maxAchievement.toString()); + if (achievementPercentage >= min && achievementPercentage <= max) { + if (rule.type === 'TIER') { + calculatedCommission += salesAmount * parseFloat(rule.rate.toString()); + } else if (rule.type === 'BONUS') { + calculatedBonus += parseFloat(rule.payoutAmount.toString()); + } + } + } + + let totalPayout = calculatedCommission + calculatedBonus; + if (plan.maxCap !== null && totalPayout > parseFloat(plan.maxCap.toString())) { + totalPayout = parseFloat(plan.maxCap.toString()); + } + + // Retroactive adjustments / PMS clawbacks + let planAdjustmentAmount = 0; + const adjustments = []; + + for (const pastPeriod of pastPeriods) { + const approvedSettlement = approvedPastSettlements.find((s: any) => + s.userId === collaborator.id && s.period === pastPeriod && s.planId === plan.id + ); + + if (approvedSettlement) { + const pastSales = pastSalesResults.filter((s: any) => + s.userId === collaborator.id && s.period === pastPeriod + ); + const liveSalesAmount = pastSales.reduce((sum: number, s: any) => sum + parseFloat(s.amount.toString()), 0); + const originalSalesAmount = parseFloat(approvedSettlement.salesAmount.toString()); + + if (Math.abs(liveSalesAmount - originalSalesAmount) > 0.001) { + const pastGoal = pastGoals.find((g: any) => + g.targetType === 'INDIVIDUAL' && g.targetId === collaborator.id && g.period === pastPeriod + ); + const pastGoalAmount = pastGoal ? parseFloat(pastGoal.amount.toString()) : 0; + const liveAchievementPercentage = pastGoalAmount > 0 ? liveSalesAmount / pastGoalAmount : 0; + + let correctedCommission = 0; + let correctedBonus = 0; + for (const rule of plan.rules) { + const min = parseFloat(rule.minAchievement.toString()); + const max = parseFloat(rule.maxAchievement.toString()); + if (liveAchievementPercentage >= min && liveAchievementPercentage <= max) { + if (rule.type === 'TIER') { + correctedCommission += liveSalesAmount * parseFloat(rule.rate.toString()); + } else if (rule.type === 'BONUS') { + correctedBonus += parseFloat(rule.payoutAmount.toString()); + } + } + } + + let correctedPayout = correctedCommission + correctedBonus; + if (plan.maxCap !== null && correctedPayout > parseFloat(plan.maxCap.toString())) { + correctedPayout = parseFloat(plan.maxCap.toString()); + } + + const previouslyPaidPayout = parseFloat(approvedSettlement.totalPayout.toString()); + const delta = correctedPayout - previouslyPaidPayout; + + if (Math.abs(delta) > 0.001) { + planAdjustmentAmount += delta; + adjustments.push({ + period, + planId: plan.id, + userId: collaborator.id, + salesAmount: liveSalesAmount, + goalAmount: pastGoalAmount, + achievementPercentage: liveAchievementPercentage, + calculatedCommission: correctedCommission, + calculatedBonus: correctedBonus, + adjustmentAmount: delta, + totalPayout: delta, + status: simulateOnly ? 'SIMULATED' : 'PENDING', + originalSettlementId: approvedSettlement.id, + adjustmentNotes: `Retroactive adjustment for ${pastPeriod}` + }); + } + } + } + } + + const finalTotalPayout = totalPayout + planAdjustmentAmount; + + results.push({ + main: { + period, + planId: plan.id, + userId: collaborator.id, + salesAmount, + goalAmount, + achievementPercentage, + calculatedCommission, + calculatedBonus, + adjustmentAmount: planAdjustmentAmount, + totalPayout: finalTotalPayout, + status: simulateOnly ? 'SIMULATED' : 'PENDING', + adjustmentNotes: planAdjustmentAmount !== 0 ? `Includes adjustments from past periods: ${planAdjustmentAmount}` : null + }, + adjustments + }); + + totalCommission += calculatedCommission + calculatedBonus; + totalAdjustment += planAdjustmentAmount; + count++; + } + } + + return NextResponse.json({ + success: true, + results, + metadata: { + count, + totalCommission, + totalAdjustment + } + }); + } catch (err: any) { + console.error('process-formula error:', err); + return NextResponse.json({ error: err.message || 'Error al procesar la fórmula.' }, { status: 500 }); + } +} diff --git a/src/app/api/n8n/save-settlements/route.ts b/src/app/api/n8n/save-settlements/route.ts new file mode 100644 index 0000000..bb38e6a --- /dev/null +++ b/src/app/api/n8n/save-settlements/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPrisma } from '@/lib/db'; +import { verifyN8nSignature } from '@/lib/n8n-auth'; +import { setupLogRedaction } from '@/lib/log-redactor'; + +setupLogRedaction(); + +export async function POST(req: NextRequest) { + try { + const signature = req.headers.get('x-n8n-signature'); + if (!verifyN8nSignature(signature)) { + return NextResponse.json({ error: 'No autorizado. Firma no válida.' }, { status: 401 }); + } + + const body = await req.json(); + const { period, simulateOnly, results, uploaderId } = body; + + if (!period || !Array.isArray(results)) { + return NextResponse.json({ error: 'Parámetros no válidos.' }, { status: 400 }); + } + + if (simulateOnly) { + return NextResponse.json({ + success: true, + code: 'SIMULATION_VERIFIED', + results + }); + } + + const prisma = getPrisma(); + + // Perform database writes inside admin context transaction + await prisma.$transaction(async (tx: any) => { + await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`); + + // Delete existing non-approved settlements for this period + const settlementsToDelete = await tx.settlement.findMany({ + where: { + period, + status: { in: ['PENDING', 'SIMULATED', 'REJECTED'] } + } + }); + + const idsToDelete = settlementsToDelete.map((s: any) => s.id); + if (idsToDelete.length > 0) { + // Delete child adjustment records first + await tx.settlement.deleteMany({ + where: { + originalSettlementId: { in: idsToDelete } + } + }); + // Delete main records + await tx.settlement.deleteMany({ + where: { + id: { in: idsToDelete } + } + }); + } + + // Insert new settlements + for (const item of results) { + const createdMain = await tx.settlement.create({ + data: item.main + }); + + for (const adj of item.adjustments) { + await tx.settlement.create({ + data: { + ...adj, + // Adjustments are sub-settlements linked to past periods + } + }); + } + + // Write audit log + await tx.auditLog.create({ + data: { + userId: uploaderId || 1, // fallback to admin user ID 1 + action: 'CREATE', + targetTable: 'settlements', + targetId: createdMain.id, + newValue: item.main + } + }); + } + }); + + return NextResponse.json({ + success: true, + code: 'SETTLEMENTS_SAVED' + }); + } catch (err: any) { + console.error('save-settlements error:', err); + return NextResponse.json({ error: err.message || 'Error al guardar liquidaciones.' }, { status: 500 }); + } +} diff --git a/src/app/api/settlements/[id]/approve/route.ts b/src/app/api/settlements/[id]/approve/route.ts new file mode 100644 index 0000000..333d586 --- /dev/null +++ b/src/app/api/settlements/[id]/approve/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/api-guards'; +import { getPrisma } from '@/lib/db'; + +export const POST = withAuth(async (req, { session, prisma, params }) => { + try { + const unwrappedParams = await params; + const id = parseInt(unwrappedParams.id); + + if (isNaN(id)) { + return NextResponse.json({ error: 'ID no válido.' }, { status: 400 }); + } + + // Bypass RLS to check actual existence and regions + const systemPrisma = getPrisma(null); + const { settlement, leaderUser } = await systemPrisma.$transaction(async (tx: any) => { + await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`); + const s = await tx.settlement.findUnique({ + where: { id }, + include: { colaborador: { include: { hotel: true } } } + }); + let l = null; + if (session.role === 'commercial_leader') { + l = await tx.user.findUnique({ + where: { id: session.userId }, + include: { hotel: true } + }); + } + return { settlement: s, leaderUser: l }; + }); + + if (!settlement) { + return NextResponse.json({ error: 'Liquidación no encontrada.' }, { status: 404 }); + } + + if (session.role === 'commercial_leader') { + if (!leaderUser || leaderUser.hotel.regionId !== settlement.colaborador.hotel.regionId) { + return NextResponse.json( + { error: 'Acceso prohibido. No puede aprobar liquidaciones de otras regiones.' }, + { status: 403 } + ); + } + } + + // Update the settlement status using the session-bound prisma client to respect RLS + const updated = await prisma.settlement.update({ + where: { id }, + data: { + status: 'APPROVED', + approvedBy: session.userId, + approvedAt: new Date() + } + }); + + // Write audit log using system client inside a transaction to satisfy RLS select policy + await systemPrisma.$transaction(async (tx: any) => { + await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`); + await tx.auditLog.create({ + data: { + userId: session.userId, + action: 'APPROVE', + targetTable: 'settlements', + targetId: id, + previousValue: JSON.parse(JSON.stringify(settlement)), + newValue: JSON.parse(JSON.stringify(updated)) + } + }); + }); + + // Create a notification for the collaborator + await systemPrisma.notification.create({ + data: { + userId: settlement.userId, + title: 'Liquidación Aprobada', + message: `Tu liquidación para el período ${settlement.period} ha sido aprobada.`, + type: 'PUSH', + status: 'UNREAD' + } + }); + + return NextResponse.json({ success: true, settlement: updated }); + } catch (err: any) { + console.error('Approve settlement error:', err); + return NextResponse.json( + { error: err.message || 'Error al aprobar la liquidación.' }, + { status: 500 } + ); + } +}, ['commercial_leader', 'admin']); diff --git a/src/app/api/settlements/[id]/reject/route.ts b/src/app/api/settlements/[id]/reject/route.ts new file mode 100644 index 0000000..834bd0b --- /dev/null +++ b/src/app/api/settlements/[id]/reject/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/api-guards'; +import { getPrisma } from '@/lib/db'; + +export const POST = withAuth(async (req, { session, prisma, params }) => { + try { + const unwrappedParams = await params; + const id = parseInt(unwrappedParams.id); + const body = await req.json(); + const { reason } = body; + + if (isNaN(id)) { + return NextResponse.json({ error: 'ID no válido.' }, { status: 400 }); + } + + if (!reason || reason.trim() === '') { + return NextResponse.json( + { error: 'La razón del rechazo es obligatoria.' }, + { status: 400 } + ); + } + + // Bypass RLS to check actual existence and regions + const systemPrisma = getPrisma(null); + const { settlement, leaderUser } = await systemPrisma.$transaction(async (tx: any) => { + await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`); + const s = await tx.settlement.findUnique({ + where: { id }, + include: { colaborador: { include: { hotel: true } } } + }); + let l = null; + if (session.role === 'commercial_leader') { + l = await tx.user.findUnique({ + where: { id: session.userId }, + include: { hotel: true } + }); + } + return { settlement: s, leaderUser: l }; + }); + + if (!settlement) { + return NextResponse.json({ error: 'Liquidación no encontrada.' }, { status: 404 }); + } + + if (session.role === 'commercial_leader') { + if (!leaderUser || leaderUser.hotel.regionId !== settlement.colaborador.hotel.regionId) { + return NextResponse.json( + { error: 'Acceso prohibido. No puede rechazar liquidaciones de otras regiones.' }, + { status: 403 } + ); + } + } + + // Update using user's scoped prisma client to respect RLS + const updated = await prisma.settlement.update({ + where: { id }, + data: { + status: 'REJECTED', + rejectionReason: reason + } + }); + + // Write audit log using system client inside a transaction to satisfy RLS select policy + await systemPrisma.$transaction(async (tx: any) => { + await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`); + await tx.auditLog.create({ + data: { + userId: session.userId, + action: 'REJECT', + targetTable: 'settlements', + targetId: id, + previousValue: JSON.parse(JSON.stringify(settlement)), + newValue: JSON.parse(JSON.stringify(updated)) + } + }); + }); + + // Create a notification for the collaborator + await systemPrisma.notification.create({ + data: { + userId: settlement.userId, + title: 'Liquidación Rechazada', + message: `Tu liquidación para el período ${settlement.period} ha sido rechazada. Razón: ${reason}`, + type: 'PUSH', + status: 'UNREAD' + } + }); + + return NextResponse.json({ success: true, settlement: updated }); + } catch (err: any) { + console.error('Reject settlement error:', err); + return NextResponse.json( + { error: err.message || 'Error al rechazar la liquidación.' }, + { status: 500 } + ); + } +}, ['commercial_leader', 'admin']); diff --git a/src/app/api/settlements/calculate/route.ts b/src/app/api/settlements/calculate/route.ts new file mode 100644 index 0000000..33a50f5 --- /dev/null +++ b/src/app/api/settlements/calculate/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/api-guards'; +import { setupLogRedaction } from '@/lib/log-redactor'; + +setupLogRedaction(); + +export const POST = withAuth(async (req, { session, prisma }) => { + try { + const body = await req.json(); + const { period, simulateOnly } = body; + + if (!period || !/^\d{4}-\d{2}$/.test(period)) { + return NextResponse.json( + { error: 'El período es obligatorio y debe tener formato YYYY-MM.' }, + { status: 400 } + ); + } + + const isDev = process.env.IS_E2E_TEST === 'true' || process.env.NODE_ENV === 'development'; + const signature = isDev + ? (process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=') + : (process.env.N8N_WEBHOOK_SECRET || 'Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ='); + + // Local bypass for E2E tests to prevent cloud n8n routing back to localhost + if (process.env.IS_E2E_TEST === 'true') { + console.log('E2E Test Mode: Running calculation locally via n8n endpoints...'); + const localPort = process.env.PORT || '3015'; + const localBase = `http://localhost:${localPort}`; + + // 1. Fetch calculation data locally + const fetchRes = await fetch(`${localBase}/api/n8n/fetch-calculation-data?period=${period}`, { + headers: { 'x-n8n-signature': signature } + }); + if (!fetchRes.ok) { + throw new Error(`Fetch calculation data failed: ${await fetchRes.text()}`); + } + const data = await fetchRes.json(); + + // 2. Process formula locally + const processRes = await fetch(`${localBase}/api/n8n/process-formula`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-n8n-signature': signature + }, + body: JSON.stringify({ + period, + simulateOnly, + ...data + }) + }); + if (!processRes.ok) { + throw new Error(`Process formula failed: ${await processRes.text()}`); + } + const processData = await processRes.json(); + + // 3. Save settlements locally (if not simulated) + if (!simulateOnly) { + const saveRes = await fetch(`${localBase}/api/n8n/save-settlements`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-n8n-signature': signature + }, + body: JSON.stringify({ + period, + simulateOnly: false, + results: processData.results, + uploaderId: session.userId + }) + }); + if (!saveRes.ok) { + throw new Error(`Save settlements failed: ${await saveRes.text()}`); + } + } + + return NextResponse.json({ + success: true, + code: 'SETTLEMENTS_CALCULATED', + metadata: processData.metadata, + simulated: processData.results + }, { status: 201 }); + } + + // Determine target n8n webhook URL for dev/prod environment + const webhookBase = process.env.N8N_API_URL || 'https://n8n.gaboggamer.online'; + const webhookPath = isDev ? 'webhook-test/calculate-settlements' : 'webhook/calculate-settlements'; + const webhookUrl = `${webhookBase}/${webhookPath}`; + + console.log(`Triggering n8n settlement calculation webhook at ${webhookUrl}...`); + + const n8nRes = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-n8n-signature': signature + }, + body: JSON.stringify({ + period, + simulateOnly, + uploaderId: session.userId + }) + }); + + if (!n8nRes.ok) { + const errText = await n8nRes.text(); + console.error(`n8n webhook calculation failed with status ${n8nRes.status}:`, errText); + return NextResponse.json( + { error: `Error del motor de cálculo externo (n8n): ${n8nRes.status}` }, + { status: 502 } + ); + } + + const n8nData = await n8nRes.json(); + return NextResponse.json(n8nData, { status: 201 }); + + } catch (err: any) { + console.error('Settlement calculation trigger error:', err); + return NextResponse.json( + { error: err.message || 'Error al calcular las liquidaciones.' }, + { status: 500 } + ); + } +}, ['admin', 'analyst']); diff --git a/src/app/api/settlements/route.ts b/src/app/api/settlements/route.ts new file mode 100644 index 0000000..806beb3 --- /dev/null +++ b/src/app/api/settlements/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/api-guards'; + +// GET: Fetch settlements (accessible to authenticated users, filtered by DB-level RLS) +export const GET = withAuth(async (req, { prisma }) => { + try { + const { searchParams } = req.nextUrl; + const period = searchParams.get('period'); + const status = searchParams.get('status'); + + const whereClause: any = {}; + if (period) whereClause.period = period; + if (status) whereClause.status = status; + + const settlements = await prisma.settlement.findMany({ + where: whereClause, + include: { + colaborador: { + select: { + id: true, + username: true, + email: true, + area: true, + hotel: { + select: { + name: true, + code: true + } + } + } + }, + plan: { + select: { + id: true, + name: true, + code: true, + version: true + } + } + }, + orderBy: { createdAt: 'desc' } + }); + + return NextResponse.json({ settlements }); + } catch (err: any) { + console.error('Failed to fetch settlements:', err); + return NextResponse.json({ error: 'Failed to fetch settlements' }, { status: 500 }); + } +}); diff --git a/src/app/sales/simulation/page.module.css b/src/app/sales/simulation/page.module.css new file mode 100644 index 0000000..cef7259 --- /dev/null +++ b/src/app/sales/simulation/page.module.css @@ -0,0 +1,257 @@ +.container { + min-height: 100vh; + background-color: #0f172a; + color: #f8fafc; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + padding-bottom: 3rem; +} + +.mainContent { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +.titleSection { + margin-bottom: 2.5rem; +} + +.title { + font-size: 2.25rem; + font-weight: 800; + background: linear-gradient(135deg, #38bdf8, #818cf8); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin-bottom: 0.5rem; +} + +.description { + color: #94a3b8; + font-size: 1rem; +} + +.card { + background: rgba(30, 41, 59, 0.7); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} + +.formGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + align-items: flex-end; +} + +.formGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.label { + font-size: 0.875rem; + font-weight: 600; + color: #cbd5e1; +} + +.input, .select { + background: #1e293b; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 0.75rem 1rem; + color: #fff; + font-size: 0.95rem; + transition: all 0.2s ease; + outline: none; +} + +.input:focus, .select:focus { + border-color: #38bdf8; + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2); +} + +.toggleContainer { + display: flex; + align-items: center; + gap: 0.75rem; + padding-bottom: 0.75rem; +} + +.checkbox { + width: 1.25rem; + height: 1.25rem; + accent-color: #38bdf8; + cursor: pointer; +} + +.checkboxLabel { + font-size: 0.95rem; + font-weight: 500; + color: #e2e8f0; + cursor: pointer; +} + +.btn { + background: linear-gradient(135deg, #0284c7, #4f46e5); + color: #fff; + font-weight: 600; + border: none; + border-radius: 8px; + padding: 0.75rem 1.5rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(79, 70, 229, 0.3); + text-align: center; +} + +.btn:hover:not(:disabled) { + opacity: 0.95; + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4); +} + +.btn:disabled { + background: #334155; + color: #64748b; + cursor: not-allowed; + box-shadow: none; +} + +.errorMsg { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #fca5a5; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; + font-size: 0.9rem; +} + +.successMsg { + background: rgba(34, 197, 94, 0.15); + border: 1px solid rgba(34, 197, 94, 0.3); + color: #86efac; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; + font-size: 0.9rem; +} + +.resultsHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.resultsTitle { + font-size: 1.5rem; + font-weight: 700; + color: #f1f5f9; +} + +.summaryCards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.summaryCard { + background: #1e293b; + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 1.25rem; + text-align: center; +} + +.summaryLabel { + font-size: 0.85rem; + color: #94a3b8; + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.summaryVal { + font-size: 1.75rem; + font-weight: 800; + color: #f8fafc; +} + +.tableWrapper { + overflow-x: auto; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + text-align: left; +} + +.th { + background: #1e293b; + padding: 1rem 1.25rem; + font-weight: 600; + color: #94a3b8; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + transition: background-color 0.2s ease; +} + +.tr:hover { + background: rgba(255, 255, 255, 0.02); +} + +.td { + padding: 1rem 1.25rem; + color: #cbd5e1; + vertical-align: middle; +} + +.badge { + display: inline-block; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; + border-radius: 4px; +} + +.badgeSimulated { + background: rgba(56, 189, 248, 0.15); + color: #38bdf8; +} + +.badgePending { + background: rgba(234, 179, 8, 0.15); + color: #eab308; +} + +.badgeApproved { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.badgeRejected { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.negative { + color: #f87171; +} + +.positive { + color: #4ade80; +} diff --git a/src/app/sales/simulation/page.tsx b/src/app/sales/simulation/page.tsx new file mode 100644 index 0000000..f98934a --- /dev/null +++ b/src/app/sales/simulation/page.tsx @@ -0,0 +1,349 @@ +'use strict'; +'use client'; + +import React, { useState, useEffect } from 'react'; +import styles from './page.module.css'; +import Header from '@/components/Header'; +import { useRouter } from 'next/navigation'; + +interface SimulatedItem { + main: { + period: string; + planId: number; + userId: number; + salesAmount: number; + goalAmount: number; + achievementPercentage: number; + calculatedCommission: number; + calculatedBonus: number; + adjustmentAmount: number; + totalPayout: number; + status: string; + adjustmentNotes: string | null; + }; + adjustments: Array<{ + period: string; + adjustmentAmount: number; + adjustmentNotes: string; + }>; +} + +interface User { + id: number; + username: string; + role: string; +} + +export default function SimulationPage() { + const router = useRouter(); + const [role, setRole] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Form State + const [period, setPeriod] = useState('2026-06'); + const [simulateOnly, setSimulateOnly] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + // Results State + const [results, setResults] = useState([]); + const [summary, setSummary] = useState({ + count: 0, + totalCommission: 0, + totalAdjustment: 0 + }); + + // User Map to map userId to User Details + const [usersMap, setUsersMap] = useState>({}); + const [plansMap, setPlansMap] = useState>({}); + + useEffect(() => { + fetch('/api/auth/me') + .then((res) => { + if (!res.ok) { + router.push('/login'); + return null; + } + return res.json(); + }) + .then((data) => { + if (data && data.user) { + setRole(data.user.role); + setCurrentUser(data.user); + if (data.user.role !== 'admin' && data.user.role !== 'analyst') { + // Block non-authorized roles + setError('Acceso prohibido. Permisos insuficientes.'); + } + } + setIsLoading(false); + }) + .catch((err) => { + console.error('Failed to fetch auth state:', err); + router.push('/login'); + }); + + // Load users list for username mapping + fetch('/api/users') + .then((res) => (res.ok ? res.json() : { users: [] })) + .then((data) => { + const mapping: Record = {}; + data.users.forEach((u: any) => { + mapping[u.id] = u; + }); + setUsersMap(mapping); + }) + .catch((err) => console.error('Failed to fetch users mapping:', err)); + + // Load plans list for plan version mapping + fetch('/api/plans') + .then((res) => (res.ok ? res.json() : { plans: [] })) + .then((data) => { + const mapping: Record = {}; + data.plans.forEach((p: any) => { + mapping[p.id] = p; + }); + setPlansMap(mapping); + }) + .catch((err) => console.error('Failed to fetch plans mapping:', err)); + }, []); + + const handleCalculate = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + setResults([]); + setSummary({ count: 0, totalCommission: 0, totalAdjustment: 0 }); + + if (!period || !/^\d{4}-\d{2}$/.test(period)) { + setError('El período debe tener el formato YYYY-MM.'); + return; + } + + setIsProcessing(true); + try { + const res = await fetch('/api/settlements/calculate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ period, simulateOnly }) + }); + + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'Error al procesar el cálculo.'); + return; + } + + setResults(data.simulated || []); + setSummary({ + count: data.metadata.count, + totalCommission: data.metadata.totalCommission, + totalAdjustment: data.metadata.totalAdjustment + }); + + if (simulateOnly) { + setSuccess('Simulación completada con éxito. Los resultados no se han guardado.'); + } else { + setSuccess('Liquidaciones calculadas y guardadas correctamente.'); + } + } catch (err: any) { + console.error('Calculation fetch error:', err); + setError('Error al comunicarse con el servidor.'); + } finally { + setIsProcessing(false); + } + }; + + if (isLoading) { + return ( +
+
+
+

Cargando panel de simulación...

+
+
+ ); + } + + // Segmented block if unauthorized + if (role !== 'admin' && role !== 'analyst') { + return ( +
+
+
+
+

Acceso Restringido

+

+ Lo sentimos, esta sección es exclusiva para el Administrador y el Analista de Compensaciones. +

+
+
+
+ ); + } + + return ( +
+
+
+
+

Simulación de Liquidación

+

+ Calcule, simule y audite las comisiones mensuales antes de la aprobación del Líder Comercial. +

+
+ +
+
+
+ + setPeriod(e.target.value)} + placeholder="2026-06" + required + /> +
+ +
+
+ setSimulateOnly(e.target.checked)} + /> + +
+
+ + +
+
+ + {error &&
{error}
} + {success &&
{success}
} + + {results.length > 0 && ( +
+
+

Resumen del Cálculo

+
+ +
+
+
Colaboradores
+
{summary.count}
+
+
+
Comisión Propuesta
+
+ ${summary.totalCommission.toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+
+
Ajustes Retroactivos
+
+ ${summary.totalAdjustment.toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+
+
Pago Total Estimado
+
+ ${(summary.totalCommission + summary.totalAdjustment).toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+
+ +
+ + + + + + + + + + + + + + + + {results.map((item, idx) => { + const colab = usersMap[item.main.userId] || { username: `ID: ${item.main.userId}` }; + const plan = plansMap[item.main.planId] || { name: `ID: ${item.main.planId}`, version: 1 }; + const achievementVal = (item.main.achievementPercentage * 100).toFixed(1) + '%'; + const mainCommission = item.main.calculatedCommission + item.main.calculatedBonus; + + return ( + + + + + + + + + + + + ); + })} + +
ColaboradorPlanMetaVentas ConfirmadasCumplimiento %Comisión CalculadaAjustes RetroactivosPago TotalEstado
+ {colab.username} + {colab.hotel &&
{colab.hotel.code}
} +
+ {plan.name} (v{plan.version}) + + ${parseFloat(item.main.goalAmount.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })} + + ${parseFloat(item.main.salesAmount.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })} + {achievementVal} + ${mainCommission.toLocaleString('es-CO', { minimumFractionDigits: 2 })} + 0 ? styles.positive : ''}`}> + ${parseFloat(item.main.adjustmentAmount.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })} + + + ${parseFloat(item.main.totalPayout.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })} + + + + {item.main.status} + +
+
+
+ )} +
+
+ ); +} diff --git a/src/app/settlements/approvals/page.module.css b/src/app/settlements/approvals/page.module.css new file mode 100644 index 0000000..5c5eb1b --- /dev/null +++ b/src/app/settlements/approvals/page.module.css @@ -0,0 +1,248 @@ +.container { + min-height: 100vh; + background-color: #0f172a; + color: #f8fafc; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + padding-bottom: 3rem; +} + +.mainContent { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +.titleSection { + margin-bottom: 2.5rem; +} + +.title { + font-size: 2.25rem; + font-weight: 800; + background: linear-gradient(135deg, #38bdf8, #818cf8); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin-bottom: 0.5rem; +} + +.description { + color: #94a3b8; + font-size: 1rem; +} + +.card { + background: rgba(30, 41, 59, 0.7); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} + +.errorMsg { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #fca5a5; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; + font-size: 0.9rem; +} + +.successMsg { + background: rgba(34, 197, 94, 0.15); + border: 1px solid rgba(34, 197, 94, 0.3); + color: #86efac; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; + font-size: 0.9rem; +} + +.tableWrapper { + overflow-x: auto; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + text-align: left; +} + +.th { + background: #1e293b; + padding: 1rem 1.25rem; + font-weight: 600; + color: #94a3b8; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + transition: background-color 0.2s ease; +} + +.tr:hover { + background: rgba(255, 255, 255, 0.02); +} + +.td { + padding: 1rem 1.25rem; + color: #cbd5e1; + vertical-align: middle; +} + +.badge { + display: inline-block; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; + border-radius: 4px; +} + +.badgeSimulated { + background: rgba(56, 189, 248, 0.15); + color: #38bdf8; +} + +.badgePending { + background: rgba(234, 179, 8, 0.15); + color: #eab308; +} + +.badgeApproved { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.badgeRejected { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.actionsCell { + display: flex; + gap: 0.5rem; +} + +.btnApprove { + background: #22c55e; + color: #fff; + font-weight: 600; + font-size: 0.85rem; + border: none; + border-radius: 6px; + padding: 0.4rem 0.8rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.btnApprove:hover { + background: #16a34a; +} + +.btnReject { + background: #ef4444; + color: #fff; + font-weight: 600; + font-size: 0.85rem; + border: none; + border-radius: 6px; + padding: 0.4rem 0.8rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.btnReject:hover { + background: #dc2626; +} + +/* Modal Styling */ +.modalBackdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modalContent { + background: #1e293b; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + width: 90%; + max-width: 500px; + padding: 2rem; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4); +} + +.modalTitle { + font-size: 1.25rem; + font-weight: 700; + color: #fff; + margin-bottom: 1rem; +} + +.textarea { + width: 100%; + height: 120px; + background: #0f172a; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 0.75rem; + color: #fff; + font-size: 0.95rem; + outline: none; + resize: none; + margin-bottom: 1.5rem; +} + +.textarea:focus { + border-color: #ef4444; +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: 1rem; +} + +.btnCancel { + background: #475569; + color: #fff; + font-weight: 600; + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.btnCancel:hover { + background: #334155; +} + +.btnConfirmReject { + background: #ef4444; + color: #fff; + font-weight: 600; + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.btnConfirmReject:hover { + background: #dc2626; +} diff --git a/src/app/settlements/approvals/page.tsx b/src/app/settlements/approvals/page.tsx new file mode 100644 index 0000000..e6bd4ab --- /dev/null +++ b/src/app/settlements/approvals/page.tsx @@ -0,0 +1,336 @@ +'use strict'; +'use client'; + +import React, { useState, useEffect } from 'react'; +import styles from './page.module.css'; +import Header from '@/components/Header'; +import { useRouter } from 'next/navigation'; + +interface Settlement { + id: number; + period: string; + planId: number; + userId: number; + salesAmount: string; + goalAmount: string; + achievementPercentage: string; + calculatedCommission: string; + calculatedBonus: string; + adjustmentAmount: string; + totalPayout: string; + status: string; + rejectionReason: string | null; + colaborador: { + username: string; + email: string; + area: string; + hotel: { + name: string; + code: string; + }; + }; + plan: { + name: string; + code: string; + version: number; + }; +} + +export default function ApprovalsPage() { + const router = useRouter(); + const [role, setRole] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [settlements, setSettlements] = useState([]); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Modal State + const [showRejectModal, setShowRejectModal] = useState(false); + const [rejectId, setRejectId] = useState(null); + const [rejectReason, setRejectReason] = useState(''); + const [isSubmittingReject, setIsSubmittingReject] = useState(false); + + const fetchSettlements = async () => { + try { + const res = await fetch('/api/settlements'); + if (res.ok) { + const data = await res.json(); + setSettlements(data.settlements || []); + } else { + const data = await res.json(); + setError(data.error || 'Failed to load settlements.'); + } + } catch (err) { + console.error('Failed to fetch settlements:', err); + setError('Failed to connect to the server.'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetch('/api/auth/me') + .then((res) => { + if (!res.ok) { + router.push('/login'); + return null; + } + return res.json(); + }) + .then((data) => { + if (data && data.user) { + setRole(data.user.role); + if ( + data.user.role !== 'commercial_leader' && + data.user.role !== 'admin' && + data.user.role !== 'analyst' && + data.user.role !== 'director' + ) { + setError('Acceso prohibido. Permisos insuficientes.'); + setIsLoading(false); + } else { + fetchSettlements(); + } + } + }) + .catch((err) => { + console.error('Failed to get session user details', err); + router.push('/login'); + }); + }, []); + + const handleApprove = async (id: number) => { + setError(null); + setSuccess(null); + try { + const res = await fetch(`/api/settlements/${id}/approve`, { + method: 'POST' + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'Error al aprobar la liquidación.'); + return; + } + setSuccess('Liquidación aprobada con éxito.'); + fetchSettlements(); + } catch (err) { + console.error('Failed to approve settlement:', err); + setError('Error al comunicarse con el servidor.'); + } + }; + + const openRejectModal = (id: number) => { + setRejectId(id); + setRejectReason(''); + setShowRejectModal(true); + }; + + const closeRejectModal = () => { + setShowRejectModal(false); + setRejectId(null); + setRejectReason(''); + }; + + const handleRejectConfirm = async () => { + if (!rejectId) return; + if (!rejectReason || rejectReason.trim() === '') { + setError('La razón del rechazo es obligatoria.'); + return; + } + + setError(null); + setSuccess(null); + setIsSubmittingReject(true); + + try { + const res = await fetch(`/api/settlements/${rejectId}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: rejectReason }) + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'Error al rechazar la liquidación.'); + setIsSubmittingReject(false); + return; + } + setSuccess('Liquidación rechazada.'); + closeRejectModal(); + fetchSettlements(); + } catch (err) { + console.error('Failed to reject settlement:', err); + setError('Error al comunicarse con el servidor.'); + } finally { + setIsSubmittingReject(false); + } + }; + + if (isLoading) { + return ( +
+
+
+

Cargando panel de aprobaciones...

+
+
+ ); + } + + const isLeader = role === 'commercial_leader'; + + return ( +
+
+
+
+

Panel de Aprobaciones

+

+ {isLeader + ? 'Revise y apruebe las liquidaciones de comisión de los colaboradores de su región.' + : 'Visualice y audite el estado de aprobación de las liquidaciones de comisiones.'} +

+
+ + {error &&
{error}
} + {success &&
{success}
} + +
+
+ + + + + + + + + + + + + + + + {settlements.length === 0 ? ( + + + + ) : ( + settlements.map((s) => { + const achievementVal = (parseFloat(s.achievementPercentage) * 100).toFixed(1) + '%'; + const isPending = s.status === 'PENDING' || s.status === 'SIMULATED'; + + return ( + + + + + + + + + + + + ); + }) + )} + +
ColaboradorPlanPeríodoMetaVentasCumplimientoPago TotalEstadoAcciones
+ No se encontraron liquidaciones pendientes en su región. +
+ {s.colaborador.username} +
+ {s.colaborador.hotel.name} ({s.colaborador.hotel.code}) +
+
+ {s.plan.name} (v{s.plan.version}) + {s.period} + ${parseFloat(s.goalAmount).toLocaleString('es-CO', { minimumFractionDigits: 2 })} + + ${parseFloat(s.salesAmount).toLocaleString('es-CO', { minimumFractionDigits: 2 })} + {achievementVal} + + ${parseFloat(s.totalPayout).toLocaleString('es-CO', { minimumFractionDigits: 2 })} + + + + {s.status} + + {s.rejectionReason && ( +
+ Razón: {s.rejectionReason} +
+ )} +
+ {isPending && (role === 'commercial_leader' || role === 'admin') ? ( +
+ + +
+ ) : ( + Sin acciones + )} +
+
+
+
+ + {showRejectModal && ( +
+
+

Rechazar Liquidación

+