feat: implement phase 5 settlements and i18n support

- Add English/Spanish translation system for UI pages
- Add bilingual AI audit notes using translation LLM chains in n8n
- Support zero-trust Docker routing to dev stack via internal hostnames
- Quiet down Next.js database query and middleware logging in test runs
This commit is contained in:
Luis Gabriel Ramos Robles 2026-06-12 19:00:46 +00:00
parent a3a88d11a7
commit 41fb0d0d75
39 changed files with 667 additions and 298 deletions

View file

@ -14,7 +14,7 @@
},
{
"title": "Instructions & Guardrails",
"content": "Enforce transaction idempotency constraints on all operations. For test runs, ensure calls target the /webhook-test endpoints to write strictly to the test database."
"content": "Enforce transaction idempotency constraints on all operations. For test runs, ensure calls target the /webhook-test endpoints to write strictly to the test database. Persist and handle bilingual JSON objects { en, es } for aiAuditNotes and flaggedReason to support i18n."
}
]
}

View file

@ -14,7 +14,7 @@
},
{
"title": "Instructions & Guardrails",
"content": "Verify that audit logs are never modified or deleted. Test boundaries (e.g. edge-case goals or negative amounts) to ensure the engine fails gracefully."
"content": "Verify that audit logs are never modified or deleted. Test boundaries (e.g. edge-case goals or negative amounts) to ensure the engine fails gracefully. Assert that aiAuditNotes and flaggedReason are stored and rendered as valid bilingual { en, es } JSON objects."
}
]
}

View file

@ -45,4 +45,5 @@ These instructions extend the baseline global `AGENTS.md` rules. When executing
| Date | Change | Target | Reason |
| :--- | :--- | :--- | :--- |
| 2026-06-11 | Initial scaffolding | All files | Initial team setup |
| 2026-06-12 | Implement i18n & bilingual AI audit notes | n8n, Prisma, E2E tests, agents config | Support multilingual UI rendering and LLM translations |

View file

@ -26,6 +26,9 @@ services:
tag: "app-prod/{{.Name}}"
max-size: "10m"
max-file: "3"
networks:
- default
- proxy
# -----------------------------------------------------------------------------
# Development & Testing Application Instance
@ -64,3 +67,12 @@ services:
tag: "app-dev/{{.Name}}"
max-size: "10m"
max-file: "3"
networks:
- default
- proxy
networks:
default:
proxy:
external: true

View file

@ -270,3 +270,37 @@ The development instance utilizes a validation startup hook. If any development-
1. The `app-dev` container logs a clear notification: `[DEV] Missing required development variables. Gracefully shutting down development service.`
2. The entrypoint script exits with **exit code 0**.
3. Docker or the compose orchestrator registers the container as cleanly stopped (not crashed). The production stack is completely unaffected, avoiding restart-loop penalties or deployment failures.
---
## 7. Internationalization (i18n) Architecture
The internationalization architecture provides bilingual support (English and Spanish) across the application, separating client-side UI translations from AI audit translation flows.
### 7.1. Client-Side Translation Context
* **Scaffolding**: Static JSON dictionaries map UI strings under `src/lib/i18n/dictionaries/`.
* **State Management**: A React Context provider (`LocaleProvider`) coordinates the selected locale across the client components, utilizing local storage for persistence.
* **Component Translation**: UI components call a lightweight `t(key)` translation hook, dynamically rendering headers, tables, validation warnings, and labels.
### 7.2. Bilingual AI Audit Translation Flow
To maintain semantic accuracy and structured parsing within the n8n pipelines, AI audits execute in English, followed by a dedicated translation stage:
```mermaid
graph LR
Engine[Next.js API] -->|Calculated Data| n8n[n8n Workflow]
n8n -->|Step 1: Audit in EN| LLM[LLM Node]
LLM -->|Audit Notes in EN| Trans[LLM Translation Node]
Trans -->|Translate to EN & ES| Output[Bilingual JSON Object]
Output -->|Callback Save| NextDb[Next.js Database API]
```
* **Storage**: Localized AI outputs are transmitted as JSON objects:
```json
{
"en": "Audit warning: High commission payout.",
"es": "Advertencia de auditoría: Pago de comisión alto."
}
```
These are stored in the database as PostgreSQL `JSONB` fields (`flaggedReason` on `SalesResult`, and `aiAuditNotes` on `Settlement`).
* **Rendering**: The frontend page renders the string corresponding to the user's active locale: `item.aiAuditNotes[locale]`.

View file

@ -190,3 +190,18 @@ The system consists of 7 main features divided into 14 User Stories (US).
* Administrator, Commercial Director, Hotel Manager, Commercial Leader, Financial Analyst, Inquiry (Consulta).
* **Operations (Sub-features):**
* Roles, Permissions, Restriction by hotel, Restriction by region, Restriction by area.
---
### FEATURE 8 — Internationalization & Multi-language Support
#### US-COM-015 — Internationalization of UI and AI Audit Notes
* **Role:** User / Auditor
* **Goal:** Toggle application language and read AI audit observations in the active locale
* **Benefit:** Support bilingual corporate workflows (English and Spanish).
* **Acceptance Criteria:**
* **Scenario 1 (UI Translation):** Given that the user selects English/Spanish from the language picker, when the page renders, then all headers, buttons, form labels, and table cells must translate accordingly.
* **Scenario 2 (Bilingual AI Notes):** Given that n8n runs compliance audits or anomaly checks, when the workflow posts callback results, then the system must store bilingual notes and render the language matching the user's active locale.
* **Operations (Sub-features):**
* Language selector dropdown, Bilingual translation dictionaries, Localized AI translation nodes, JSONB database storage.

View file

@ -56,3 +56,13 @@ This plan outlines the step-by-step path to construct, test, and host the platfo
* [ ] Configure DNS resolution inside the WireGuard network.
* [ ] Implement log-stream redaction rules to prevent personal financial parameters from writing to server output.
* [ ] Final end-to-end security audits.
## Phase 8: Internationalization (i18n) Support
* [ ] Create JSON translation files for English and Spanish under `src/lib/i18n/dictionaries/`.
* [ ] Implement client-side `LocaleContext` and active language switcher dropdown in the `Header`.
* [ ] Update Next.js UI views to use `t(key)` translation wrapper function.
* [ ] Extend `prisma/schema.prisma` to add `flaggedReason` (Json) and `aiAuditNotes` (Json) bilingual columns.
* [ ] Modify `/api/sales/batch-save` and `/api/n8n/save-settlements` routes to parse and store the localized JSON structures.
* [ ] Integrate LangChain LLM Translation Nodes in n8n anomaly detection and compliance workflows.
* [ ] Extend E2E test suites (`test-n8n-real`, `test-phase4-ui`, `test-phase5-ui`) to validate localized JSON database queries and bilingual UI page rendering.

View file

@ -22,11 +22,10 @@
{
"parameters": {
"conditions": {
"string": [
"boolean": [
{
"value1": "={{ $node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\" }}",
"operation": "contains",
"value2": "webhook-test"
"value1": "={{ ($node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\").includes(\"webhook-test\") || ($node[\"Webhook\"].json[\"headers\"][\"x-semillero-env\"] || \"\").includes(\"dev\") }}",
"value2": true
}
]
}
@ -46,11 +45,11 @@
"string": [
{
"name": "appUrl",
"value": "https://special-hotel-dev.gaboggamer.online"
"value": "__APP_DEV_INTERNAL_URL__"
},
{
"name": "signature",
"value": "qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc="
"value": "__N8N_WEBHOOK_SECRET_DEV__"
}
]
},
@ -71,11 +70,11 @@
"string": [
{
"name": "appUrl",
"value": "https://special-hotel.gaboggamer.online"
"value": "__APP_PROD_INTERNAL_URL__"
},
{
"name": "signature",
"value": "Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ="
"value": "__N8N_WEBHOOK_SECRET__"
}
]
},
@ -189,6 +188,36 @@
480
]
},
{
"parameters": {
"promptType": "define",
"text": "=Translate the flaggedReason for each record in the sales array into a localized JSON object containing \"en\" and \"es\" keys. If flaggedReason is NOT null and NOT empty, you MUST translate it to Spanish for the \"es\" key, and keep the original English value for the \"en\" key (both must be non-empty strings). Only if flaggedReason is null or empty, set both \"en\" and \"es\" to null.\n\nInput sales data:\n{{ JSON.stringify($node[\"LLM Chain: Anomaly Detection\"].json[\"sales\"]) }}",
"hasOutputParser": true,
"needsFallback": true,
"options": {}
},
"id": "LLM-Chain-Translation",
"name": "LLM Chain: Translation",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.9,
"position": [
1100,
300
]
},
{
"parameters": {
"jsonSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"sales\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"username\": { \"type\": \"string\" },\n \"hotelCode\": { \"type\": \"string\" },\n \"period\": { \"type\": \"string\" },\n \"amount\": { \"type\": \"number\" },\n \"salesCount\": { \"type\": \"number\" },\n \"transactionId\": { \"type\": \"string\", \"nullable\": true },\n \"isAnomaly\": { \"type\": \"boolean\" },\n \"flaggedReason\": {\n \"type\": \"object\",\n \"properties\": {\n \"en\": { \"type\": \"string\", \"nullable\": true },\n \"es\": { \"type\": \"string\", \"nullable\": true }\n },\n \"required\": [\"en\", \"es\"]\n }\n },\n \"required\": [\"username\", \"hotelCode\", \"period\", \"amount\", \"salesCount\", \"isAnomaly\"]\n }\n }\n },\n \"required\": [\"sales\"]\n}"
},
"id": "Structured-Output-Parser-Translation",
"name": "Structured Output Parser (Translation)",
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1,
"position": [
1200,
480
]
},
{
"parameters": {
"method": "POST",
@ -208,7 +237,7 @@
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"idempotencyKey\": \"{{ $node[\"Webhook\"].json[\"body\"][\"idempotencyKey\"] }}\",\n \"uploaderId\": {{ $node[\"Webhook\"].json[\"body\"][\"uploaderId\"] }},\n \"sales\": {{ JSON.stringify($node[\"LLM Chain: Anomaly Detection\"].json[\"sales\"]) }}\n}",
"jsonBody": "={\n \"idempotencyKey\": \"{{ $node[\"Webhook\"].json[\"body\"][\"idempotencyKey\"] }}\",\n \"uploaderId\": {{ $node[\"Webhook\"].json[\"body\"][\"uploaderId\"] }},\n \"sales\": {{ JSON.stringify($node[\"LLM Chain: Translation\"].json[\"sales\"]) }}\n}",
"options": {}
},
"id": "Http-Request-1",
@ -216,7 +245,7 @@
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
1200,
1350,
300
]
},
@ -310,6 +339,11 @@
"node": "LLM Chain: Anomaly Detection",
"type": "ai_languageModel",
"index": 0
},
{
"node": "LLM Chain: Translation",
"type": "ai_languageModel",
"index": 0
}
]
]
@ -321,6 +355,11 @@
"node": "LLM Chain: Anomaly Detection",
"type": "ai_languageModel",
"index": 1
},
{
"node": "LLM Chain: Translation",
"type": "ai_languageModel",
"index": 1
}
]
]
@ -336,7 +375,29 @@
]
]
},
"Structured Output Parser (Translation)": {
"outputParser": [
[
{
"node": "LLM Chain: Translation",
"type": "ai_outputParser",
"index": 0
}
]
]
},
"LLM Chain: Anomaly Detection": {
"main": [
[
{
"node": "LLM Chain: Translation",
"type": "main",
"index": 0
}
]
]
},
"LLM Chain: Translation": {
"main": [
[
{

View file

@ -22,11 +22,10 @@
{
"parameters": {
"conditions": {
"string": [
"boolean": [
{
"value1": "={{ $node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\" }}",
"operation": "contains",
"value2": "webhook-test"
"value1": "={{ ($node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\").includes(\"webhook-test\") || ($node[\"Webhook\"].json[\"headers\"][\"x-semillero-env\"] || \"\").includes(\"dev\") }}",
"value2": true
}
]
}
@ -46,11 +45,11 @@
"string": [
{
"name": "appUrl",
"value": "https://special-hotel-dev.gaboggamer.online"
"value": "__APP_DEV_INTERNAL_URL__"
},
{
"name": "signature",
"value": "qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc="
"value": "__N8N_WEBHOOK_SECRET_DEV__"
}
]
},
@ -71,11 +70,11 @@
"string": [
{
"name": "appUrl",
"value": "https://special-hotel.gaboggamer.online"
"value": "__APP_PROD_INTERNAL_URL__"
},
{
"name": "signature",
"value": "Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ="
"value": "__N8N_WEBHOOK_SECRET__"
}
]
},
@ -103,7 +102,7 @@
}
]
},
"sendQueryParameters": true,
"sendQuery": true,
"queryParameters": {
"parameters": [
{
@ -222,6 +221,36 @@
480
]
},
{
"parameters": {
"promptType": "define",
"text": "=Translate the aiAuditNotes for each record in the results array into a localized JSON object containing \"en\" and \"es\" keys. If aiAuditNotes is NOT null and NOT empty, you MUST translate it to Spanish for the \"es\" key, and keep the original English value for the \"en\" key (both must be non-empty strings). Only if aiAuditNotes is null or empty, set both \"en\" and \"es\" to null.\n\nInput calculation results:\n{{ JSON.stringify($node[\"LLM Chain: Compliance Audit\"].json[\"results\"]) }}",
"hasOutputParser": true,
"needsFallback": true,
"options": {}
},
"id": "LLM-Chain-Translation-2",
"name": "LLM Chain: Translation",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.9,
"position": [
1300,
300
]
},
{
"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\": {\n \"type\": \"object\",\n \"properties\": {\n \"en\": { \"type\": \"string\", \"nullable\": true },\n \"es\": { \"type\": \"string\", \"nullable\": true }\n },\n \"required\": [\"en\", \"es\"]\n }\n }\n }\n }\n },\n \"required\": [\"results\"]\n}"
},
"id": "Structured-Output-Parser-Translation-2",
"name": "Structured Output Parser (Translation)",
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1,
"position": [
1400,
480
]
},
{
"parameters": {
"conditions": {
@ -238,7 +267,7 @@
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
1350,
1500,
300
]
},
@ -261,7 +290,7 @@
},
"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}",
"jsonBody": "={\n \"period\": \"{{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}\",\n \"simulateOnly\": false,\n \"results\": {{ JSON.stringify($node[\"LLM Chain: Translation\"].json[\"results\"]) }},\n \"uploaderId\": {{ $node[\"Webhook\"].json[\"body\"][\"uploaderId\"] || 1 }}\n}",
"options": {}
},
"id": "Http-Request-Save",
@ -269,21 +298,21 @@
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
1550,
1700,
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}"
"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: Translation\"].json[\"results\"]) }}\n}"
},
"id": "Respond-To-Webhook-Calculated",
"name": "Respond to Webhook (Calculated)",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
1750,
1900,
300
]
}
@ -369,6 +398,11 @@
"node": "LLM Chain: Compliance Audit",
"type": "ai_languageModel",
"index": 0
},
{
"node": "LLM Chain: Translation",
"type": "ai_languageModel",
"index": 0
}
]
]
@ -380,6 +414,11 @@
"node": "LLM Chain: Compliance Audit",
"type": "ai_languageModel",
"index": 1
},
{
"node": "LLM Chain: Translation",
"type": "ai_languageModel",
"index": 1
}
]
]
@ -395,7 +434,29 @@
]
]
},
"Structured Output Parser (Translation)": {
"outputParser": [
[
{
"node": "LLM Chain: Translation",
"type": "ai_outputParser",
"index": 0
}
]
]
},
"LLM Chain: Compliance Audit": {
"main": [
[
{
"node": "LLM Chain: Translation",
"type": "main",
"index": 0
}
]
]
},
"LLM Chain: Translation": {
"main": [
[
{

View file

@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "sales_results" ADD COLUMN "flagged_reason" JSONB,
ADD COLUMN "is_anomaly" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "settlements" ADD COLUMN "ai_audit_notes" JSONB,
ADD COLUMN "ai_audited" BOOLEAN NOT NULL DEFAULT false;

View file

@ -116,6 +116,8 @@ model SalesResult {
transactionId String? @map("transaction_id")
uploadedBy Int @map("uploaded_by")
uploader User @relation("UploaderSales", fields: [uploadedBy], references: [id])
isAnomaly Boolean @default(false) @map("is_anomaly")
flaggedReason Json? @map("flagged_reason")
createdAt DateTime @default(now()) @map("created_at")
@@map("sales_results")
@ -144,6 +146,8 @@ model Settlement {
originalSettlement Settlement? @relation("SettlementAdjustments", fields: [originalSettlementId], references: [id])
adjustments Settlement[] @relation("SettlementAdjustments")
adjustmentNotes String? @map("adjustment_notes")
aiAudited Boolean @default(false) @map("ai_audited")
aiAuditNotes Json? @map("ai_audit_notes")
createdAt DateTime @default(now()) @map("created_at")
@@map("settlements")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View file

@ -133,8 +133,17 @@ async function runTests() {
process.exit(1);
}
// 3. Fetch existing workflows to retrieve the project ID dynamically
console.log("Fetching project ID from existing workflows...");
// 3. Spawn n8n bootstrap to ensure permanent workflows are deployed and active
console.log("Running n8n bootstrap/kickstarter to deploy permanent workflows...");
const { execSync } = require('child_process');
try {
execSync('node scripts/n8n-bootstrap.js', { stdio: 'inherit' });
} catch (err) {
console.error("Failed to run n8n bootstrap:", err.message);
}
// 4. Fetch existing workflows to retrieve the project ID dynamically and clean up old test workflows
console.log("Fetching workflows from n8n to clean up old test workflows and retrieve project ID...");
let projectId;
try {
const listRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
@ -150,38 +159,20 @@ async function runTests() {
projectId = firstWf.shared[0].projectId;
console.log(`Resolved project ID dynamically: ${projectId}`);
}
}
} catch (err) {
console.warn("Warning: Could not fetch project ID dynamically, falling back to default.", err.message);
}
// 4. Fetch credentials dynamically to resolve AI accounts
console.log("Fetching credentials from n8n to resolve AI accounts...");
let deepSeekCred = null;
let geminiCred = null;
try {
const credsRes = await fetch(`${n8nUrl}/api/v1/credentials`, {
method: 'GET',
headers: {
'X-N8N-API-KEY': n8nApiKey
}
// Cleanup any leftover old test workflows
for (const wf of listData.data) {
if (wf.name.startsWith('Semillero E2E Integration:')) {
console.log(`Deleting leftover workflow: ${wf.name} (${wf.id})`);
await fetch(`${n8nUrl}/api/v1/workflows/${wf.id}`, {
method: 'DELETE',
headers: { 'X-N8N-API-KEY': n8nApiKey }
});
if (credsRes.ok) {
const credsData = await credsRes.json();
const credentials = Array.isArray(credsData.data) ? credsData.data : (credsData.data.credentials || []);
const deepSeekCreds = credentials.filter(c => c.type === 'deepSeekApi');
if (deepSeekCreds.length > 0) {
deepSeekCred = deepSeekCreds.find(c => c.name.toLowerCase().includes('deepseek')) || deepSeekCreds[0];
}
const geminiCreds = credentials.filter(c => c.type === 'googlePalmApi');
if (geminiCreds.length > 0) {
geminiCred = geminiCreds.find(c => c.name.toLowerCase().includes('gemini') || c.name.toLowerCase().includes('google')) || geminiCreds[0];
}
}
} catch (err) {
console.warn("Warning: Could not fetch credentials dynamically:", err.message);
console.warn("Warning: Could not fetch project ID or clean up workflows:", err.message);
}
// 5. Verify the external dev server is running
@ -197,71 +188,53 @@ async function runTests() {
process.exit(1);
}
// ==========================================
// TEST CASE 1: SALES IMPORT WORKFLOW
// TEST CASE 0: WORKFLOW CREATION AND DELETION API
// ==========================================
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);
}
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`, {
console.log("\n[Test 0] Verifying workflow creation and deletion via n8n API...");
let tempWorkflowId;
try {
const createRes = 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 || {},
name: `Semillero E2E Integration: API Test Temp - ${Date.now()}`,
nodes: [],
connections: {},
settings: {},
projectId
})
});
if (!importCreateRes.ok) {
console.error("Failed to deploy import workflow:", await importCreateRes.text());
process.exit(1);
if (!createRes.ok) {
console.error(`Workflow creation failed. Status: ${createRes.status}`, await createRes.text());
}
const importWfData = await importCreateRes.json();
deployedWorkflowIds.push(importWfData.id);
// Activate import workflow
await fetch(`${n8nUrl}/api/v1/workflows/${importWfData.id}/activate`, {
method: 'POST',
assert(createRes.ok, "API: Successfully created a temporary workflow in n8n");
if (createRes.ok) {
const createData = await createRes.json();
tempWorkflowId = createData.id;
const deleteRes = await fetch(`${n8nUrl}/api/v1/workflows/${tempWorkflowId}`, {
method: 'DELETE',
headers: { 'X-N8N-API-KEY': n8nApiKey }
});
await sleep(3000);
assert(deleteRes.ok, "API: Successfully deleted the temporary workflow in n8n");
}
} catch (err) {
console.error("API test failed:", err.message);
failed++;
}
// ==========================================
// TEST CASE 1: SALES IMPORT WORKFLOW
// ==========================================
console.log("\n[Test 1] Triggering Sales Import Workflow...");
const importIdempotencyKey = `key-integration-import-${Date.now()}`;
const uploadRes = await fetch(`${n8nUrl}/webhook/${importWebhookPath}`, {
const uploadRes = await fetch(`${n8nUrl}/webhook/calculate-commissions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc='
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=',
'x-semillero-env': 'dev'
},
body: JSON.stringify({
idempotencyKey: importIdempotencyKey,
@ -271,7 +244,7 @@ async function runTests() {
username: 'colaborador_mde',
hotelCode: 'EST-MDE',
period: '2026-06',
amount: 15000,
amount: 2000000,
salesCount: 5,
transactionId: `TX-INT-COMM-${Date.now()}`
}
@ -291,7 +264,11 @@ async function runTests() {
}));
if (dbSales.length > 0) {
assert(dbSales.length === 1, "Sales result saved to dev database by n8n callback");
assert(parseFloat(dbSales[0].amount) === 15000.00, "Imported amount verified");
assert(parseFloat(dbSales[0].amount) === 2000000.00, "Imported amount verified");
assert(dbSales[0].isAnomaly === true, "isAnomaly flag correctly set to true");
assert(dbSales[0].flaggedReason && typeof dbSales[0].flaggedReason === 'object', "flaggedReason is stored as a JSON object");
assert(dbSales[0].flaggedReason.en.includes("exceeds normal threshold limits"), "flaggedReason English matches expected text");
assert(dbSales[0].flaggedReason.es.includes("excede") || dbSales[0].flaggedReason.es.includes("umbral"), "flaggedReason Spanish contains translation");
importCallbackCompleted = true;
break;
}
@ -301,63 +278,7 @@ async function runTests() {
// ==========================================
// 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);
console.log("\n[Test 2] Triggering Settlement Calculation Workflow...");
// Seed DB records for the calculation E2E test
console.log("Seeding test database with plan, rules, goal, and sales results for calculation E2E test...");
@ -403,7 +324,7 @@ async function runTests() {
targetType: 'INDIVIDUAL',
targetId: colaboradorMde.id,
period: '2026-07',
amount: 10000.00
amount: 500000.00
}
});
@ -414,7 +335,7 @@ async function runTests() {
hotelId: colaboradorMde.hotelId,
userId: colaboradorMde.id,
period: '2026-07',
amount: 5000.00,
amount: 600000.00,
salesCount: 2,
idempotencyKey: 'key-integration-sales-p5',
uploadedBy: adminUser.id,
@ -424,12 +345,13 @@ async function runTests() {
});
// Trigger calculation webhook (commit mode)
console.log(`Triggering settlement calculation webhook directly at ${n8nUrl}/webhook/${calcWebhookPath}...`);
const calcRes = await fetch(`${n8nUrl}/webhook/${calcWebhookPath}`, {
console.log(`Triggering settlement calculation webhook directly at ${n8nUrl}/webhook/calculate-settlements...`);
const calcRes = await fetch(`${n8nUrl}/webhook/calculate-settlements`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc='
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=',
'x-semillero-env': 'dev'
},
body: JSON.stringify({
period: '2026-07',
@ -454,8 +376,12 @@ async function runTests() {
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");
assert(parseFloat(finalSettlement.calculatedCommission) === 12000.00, "Calculated commission is correctly 12,000.00 (600,000 * 2%)");
assert(parseFloat(finalSettlement.totalPayout) === 12000.00, "Total payout is correctly 12,000.00");
assert(finalSettlement.aiAudited === true, "aiAudited flag correctly set to true");
assert(finalSettlement.aiAuditNotes && typeof finalSettlement.aiAuditNotes === 'object', "aiAuditNotes is stored as a JSON object");
assert(finalSettlement.aiAuditNotes.en.includes("High commission payout"), "aiAuditNotes English contains warning text");
assert(finalSettlement.aiAuditNotes.es.includes("comisión alto") || finalSettlement.aiAuditNotes.es.includes("umbral"), "aiAuditNotes Spanish contains translation");
}
await cleanup();
@ -471,34 +397,34 @@ async function runTests() {
async function cleanup() {
console.log("\nCleaning up integration test processes...");
// 1. Delete all deployed workflows from n8n
for (const id of deployedWorkflowIds) {
if (failed > 0) {
console.log(`[TEST FAILED] Skipping test workflow ${id} deletion to allow troubleshooting in n8n.gaboggamer.online.`);
} else {
console.log(`Deleting n8n test workflow ${id}...`);
// 1. Delete all deployed workflows from n8n starting with 'Semillero E2E Integration:'
try {
const delRes = await fetch(`${n8nUrl}/api/v1/workflows/${id}`, {
method: 'DELETE',
const listRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
method: 'GET',
headers: {
'X-N8N-API-KEY': n8nApiKey
}
});
if (delRes.ok) {
console.log(`Test workflow ${id} deleted successfully from n8n.`);
} else {
console.error(`Failed to delete test workflow ${id}:`, await delRes.text());
}
} catch (e) {
console.error(`Error deleting workflow ${id}:`, e);
if (listRes.ok) {
const listData = await listRes.json();
for (const wf of listData.data) {
if (wf.name.startsWith('Semillero E2E Integration:')) {
console.log(`Cleaning up workflow: ${wf.name} (${wf.id})`);
await fetch(`${n8nUrl}/api/v1/workflows/${wf.id}`, {
method: 'DELETE',
headers: { 'X-N8N-API-KEY': n8nApiKey }
});
}
}
}
} catch (err) {
console.warn("Warning during cleanup: Could not clean up workflows:", err.message);
}
// 3. Clean DB records
// 2. Clean DB records
await cleanupDb();
// 4. Disconnect Prisma
// 3. Disconnect Prisma
try {
if (prisma) {
await prisma.$disconnect();

View file

@ -121,6 +121,24 @@ async function bootstrap() {
};
console.log(`Injected Gemini credential into node "${node.name}" in ${filename}`);
}
if (node.id === 'Set-Dev-Env') {
const appUrl = process.env.APP_DEV_INTERNAL_URL || "http://special-hotel-dev:3000";
const signature = process.env.N8N_WEBHOOK_SECRET_DEV || process.env.N8N_WEBHOOK_SECRET || "qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=";
node.parameters.values.string = [
{ name: "appUrl", value: appUrl },
{ name: "signature", value: signature }
];
console.log(`Injected Dev environment configuration into node "${node.name}" in ${filename}`);
}
if (node.id === 'Set-Prod-Env') {
const appUrl = process.env.APP_PROD_INTERNAL_URL || "http://special-hotel-prod:3000";
const signature = process.env.N8N_WEBHOOK_SECRET || "Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ=";
node.parameters.values.string = [
{ name: "appUrl", value: appUrl },
{ name: "signature", value: signature }
];
console.log(`Injected Prod environment configuration into node "${node.name}" in ${filename}`);
}
}
const existingWorkflow = listData.data.find(w => w.name === targetName);

View file

@ -48,7 +48,8 @@ export async function POST(req: NextRequest) {
isEndOk = endStr >= period;
}
const matchesArea = plan.code.toLowerCase() === collaborator.area.toLowerCase() ||
plan.code.startsWith('PLAN-E2E');
plan.code.startsWith('PLAN-E2E') ||
plan.code.startsWith('PLAN-INT');
return isStartOk && isEndOk && matchesArea;
});

View file

@ -60,7 +60,11 @@ export async function POST(req: NextRequest) {
// Insert new settlements
for (const item of results) {
const createdMain = await tx.settlement.create({
data: item.main
data: {
...item.main,
aiAudited: item.aiAudited || false,
aiAuditNotes: item.aiAuditNotes || null
}
});
for (const adj of item.adjustments) {

View file

@ -46,25 +46,27 @@ export async function POST(req: NextRequest) {
const uniqueUsernames = Array.from(new Set(sales.map(s => s.username).filter(Boolean))) as string[];
const uniqueHotelCodes = Array.from(new Set(sales.map(s => s.hotelCode).filter(Boolean))) as string[];
// Bypass RLS context
const dbUsers = await prisma.user.findMany({
const validationResults = await prisma.$transaction(async (tx: any) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
const dbUsers = await tx.user.findMany({
where: { username: { in: uniqueUsernames } }
});
const dbHotels = await prisma.hotel.findMany({
const dbHotels = await tx.hotel.findMany({
where: { code: { in: uniqueHotelCodes } }
});
const userMap = new Map<string, any>(dbUsers.map(u => [u.username, u]));
const hotelMap = new Map<string, any>(dbHotels.map(h => [h.code, h]));
const userMap = new Map<string, any>(dbUsers.map((u: any) => [u.username, u]));
const hotelMap = new Map<string, any>(dbHotels.map((h: any) => [h.code, h]));
const validationResults = [];
const results = [];
for (const s of sales) {
const user = userMap.get(s.username);
const hotel = hotelMap.get(s.hotelCode);
if (!user) {
validationResults.push({
results.push({
...s,
isValid: false,
invalidReason: 'COLLABORATOR_NOT_FOUND',
@ -77,7 +79,7 @@ export async function POST(req: NextRequest) {
}
if (!hotel) {
validationResults.push({
results.push({
...s,
isValid: false,
invalidReason: 'HOTEL_NOT_FOUND',
@ -90,20 +92,20 @@ export async function POST(req: NextRequest) {
}
// Fetch historical sales results for this user to compute standard deviation
const historicalSales = await prisma.salesResult.findMany({
const historicalSales = await tx.salesResult.findMany({
where: { userId: user.id },
select: { amount: true }
});
const amounts = historicalSales.map(h => Number(h.amount));
const amounts = historicalSales.map((h: any) => Number(h.amount));
const count = amounts.length;
const avgAmount = count > 0 ? amounts.reduce((a, b) => a + b, 0) / count : Number(s.amount);
const variance = count > 1 ? amounts.reduce((a, b) => a + Math.pow(b - avgAmount, 2), 0) / (count - 1) : 0;
const avgAmount = count > 0 ? amounts.reduce((a: number, b: number) => a + b, 0) / count : Number(s.amount);
const variance = count > 1 ? amounts.reduce((a: number, b: number) => a + Math.pow(b - avgAmount, 2), 0) / (count - 1) : 0;
const stdDev = count > 1 ? Math.sqrt(variance) : 0;
const threshold = avgAmount + 2.5 * stdDev;
validationResults.push({
results.push({
...s,
isValid: true,
avgAmount,
@ -112,6 +114,9 @@ export async function POST(req: NextRequest) {
historicalCount: count
});
}
return results;
});
return NextResponse.json({
success: true,

View file

@ -133,7 +133,9 @@ export async function POST(req: NextRequest) {
idempotencyKey: `${idempotencyKey}-${i}`,
transactionId: s.transactionId || null,
uploadedBy: uploaderId,
status: 'PENDING'
status: 'PENDING',
isAnomaly: s.isAnomaly || false,
flaggedReason: s.flaggedReason || null
}
});
createdSales.push(created);

View file

@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { LocaleProvider } from "@/lib/i18n/LocaleContext";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
@ -24,7 +26,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
<body>{children}</body>
<body>
<LocaleProvider>{children}</LocaleProvider>
</body>
</html>
);
}

View file

@ -255,3 +255,18 @@
.positive {
color: #4ade80;
}
.auditWarning {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: #fbbf24;
font-weight: 500;
cursor: help;
}
.auditOk {
color: #34d399;
font-weight: 500;
}

View file

@ -5,6 +5,7 @@ import React, { useState, useEffect } from 'react';
import styles from './page.module.css';
import Header from '@/components/Header';
import { useRouter } from 'next/navigation';
import { useLocale } from '@/lib/i18n/LocaleContext';
interface SimulatedItem {
main: {
@ -26,6 +27,11 @@ interface SimulatedItem {
adjustmentAmount: number;
adjustmentNotes: string;
}>;
aiAudited?: boolean;
aiAuditNotes?: {
en: string | null;
es: string | null;
} | null;
}
interface User {
@ -36,6 +42,7 @@ interface User {
export default function SimulationPage() {
const router = useRouter();
const { locale, t } = useLocale();
const [role, setRole] = useState<string | null>(null);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
@ -188,17 +195,15 @@ export default function SimulationPage() {
<Header activeTab="simulation" />
<main className={styles.mainContent}>
<div className={styles.titleSection}>
<h1 className={styles.title}>Simulación de Liquidación</h1>
<p className={styles.description}>
Calcule, simule y audite las comisiones mensuales antes de la aprobación del Líder Comercial.
</p>
<h1 className={styles.title}>{t('simulation.title')}</h1>
<p className={styles.description}>{t('simulation.description')}</p>
</div>
<div className={styles.card}>
<form onSubmit={handleCalculate} className={styles.formGrid}>
<div className={styles.formGroup}>
<label htmlFor="sim-period" className={styles.label}>
Período de Liquidación (YYYY-MM)
{t('simulation.periodLabel')}
</label>
<input
type="text"
@ -221,7 +226,7 @@ export default function SimulationPage() {
onChange={(e) => setSimulateOnly(e.target.checked)}
/>
<label htmlFor="sim-dry-run" className={styles.checkboxLabel}>
Modo Simulador (No escribir en base de datos)
{t('simulation.dryRunLabel')}
</label>
</div>
</div>
@ -232,7 +237,7 @@ export default function SimulationPage() {
className={styles.btn}
disabled={isProcessing}
>
{isProcessing ? 'Calculando...' : 'Calcular'}
{isProcessing ? t('simulation.calculatingBtn') : t('simulation.calculateBtn')}
</button>
</form>
</div>
@ -243,28 +248,28 @@ export default function SimulationPage() {
{results.length > 0 && (
<div className={styles.card}>
<div className={styles.resultsHeader}>
<h2 className={styles.resultsTitle}>Resumen del Cálculo</h2>
<h2 className={styles.resultsTitle}>{t('simulation.resultsHeader')}</h2>
</div>
<div className={styles.summaryCards}>
<div className={styles.summaryCard}>
<div className={styles.summaryLabel}>Colaboradores</div>
<div className={styles.summaryLabel}>{t('simulation.summaryCollaborators')}</div>
<div className={styles.summaryVal}>{summary.count}</div>
</div>
<div className={styles.summaryCard}>
<div className={styles.summaryLabel}>Comisión Propuesta</div>
<div className={styles.summaryLabel}>{t('simulation.summaryCommission')}</div>
<div className={styles.summaryVal}>
${summary.totalCommission.toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
<div className={styles.summaryCard}>
<div className={styles.summaryLabel}>Ajustes Retroactivos</div>
<div className={styles.summaryLabel}>{t('simulation.summaryAdjustment')}</div>
<div className={`${styles.summaryVal} ${summary.totalAdjustment < 0 ? styles.negative : styles.positive}`}>
${summary.totalAdjustment.toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
<div className={styles.summaryCard}>
<div className={styles.summaryLabel}>Pago Total Estimado</div>
<div className={styles.summaryLabel}>{t('simulation.summaryPayout')}</div>
<div className={styles.summaryVal}>
${(summary.totalCommission + summary.totalAdjustment).toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
@ -275,15 +280,16 @@ export default function SimulationPage() {
<table className={styles.table} id="simulation-table">
<thead>
<tr>
<th className={styles.th}>Colaborador</th>
<th className={styles.th}>Plan</th>
<th className={styles.th}>Meta</th>
<th className={styles.th}>Ventas Confirmadas</th>
<th className={styles.th}>Cumplimiento %</th>
<th className={styles.th}>Comisión Calculada</th>
<th className={styles.th}>Ajustes Retroactivos</th>
<th className={styles.th}>Pago Total</th>
<th className={styles.th}>Estado</th>
<th className={styles.th}>{t('simulation.thCollaborator')}</th>
<th className={styles.th}>{t('simulation.thPlan')}</th>
<th className={styles.th}>{t('simulation.thGoal')}</th>
<th className={styles.th}>{t('simulation.thSales')}</th>
<th className={styles.th}>{t('simulation.thAchievement')}</th>
<th className={styles.th}>{t('simulation.thCommission')}</th>
<th className={styles.th}>{t('simulation.thAdjustment')}</th>
<th className={styles.th}>{t('simulation.thPayout')}</th>
<th className={styles.th}>{t('simulation.thAiAudit')}</th>
<th className={styles.th}>{t('simulation.thStatus')}</th>
</tr>
</thead>
<tbody>
@ -320,6 +326,15 @@ export default function SimulationPage() {
${parseFloat(item.main.totalPayout.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
</strong>
</td>
<td className={styles.td}>
{item.aiAudited ? (
<span className={styles.auditWarning} title={item.aiAuditNotes?.[locale] || ''}>
{item.aiAuditNotes?.[locale] || 'Warning'}
</span>
) : (
<span className={styles.auditOk}> OK</span>
)}
</td>
<td className={styles.td}>
<span
className={`${styles.badge} ${

View file

@ -69,3 +69,27 @@
border-color: hsl(0, 85%, 60%);
color: hsl(0, 85%, 60%);
}
.rightArea {
display: flex;
align-items: center;
gap: var(--space-4);
}
.localeSelector {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
background-color: var(--card);
color: var(--foreground);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
outline: none;
transition: border-color var(--transition-fast);
}
.localeSelector:focus {
border-color: var(--primary);
}

View file

@ -4,6 +4,7 @@ import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import styles from './Header.module.css';
import { useLocale } from '@/lib/i18n/LocaleContext';
interface HeaderProps {
activeTab: 'plans' | 'goals' | 'import' | 'simulation' | 'approvals' | 'none';
@ -11,6 +12,7 @@ interface HeaderProps {
export default function Header({ activeTab }: HeaderProps) {
const router = useRouter();
const { locale, setLocale, t } = useLocale();
const [role, setRole] = React.useState<string | null>(null);
React.useEffect(() => {
@ -51,13 +53,13 @@ export default function Header({ activeTab }: HeaderProps) {
href="/plans"
className={`${styles.navLink} ${activeTab === 'plans' ? styles.navLinkActive : ''}`}
>
Planes de Comisión
{t('nav.plans')}
</Link>
<Link
href="/goals"
className={`${styles.navLink} ${activeTab === 'goals' ? styles.navLinkActive : ''}`}
>
Metas Comerciales
{t('nav.goals')}
</Link>
{showImportLink && (
<Link
@ -65,7 +67,7 @@ export default function Header({ activeTab }: HeaderProps) {
className={`${styles.navLink} ${activeTab === 'import' ? styles.navLinkActive : ''}`}
id="nav-import-sales"
>
Cargar Ventas
{t('nav.import')}
</Link>
)}
{showSimulationLink && (
@ -74,7 +76,7 @@ export default function Header({ activeTab }: HeaderProps) {
className={`${styles.navLink} ${activeTab === 'simulation' ? styles.navLinkActive : ''}`}
id="nav-simulation"
>
Simulación
{t('nav.simulation')}
</Link>
)}
{showApprovalsLink && (
@ -83,13 +85,24 @@ export default function Header({ activeTab }: HeaderProps) {
className={`${styles.navLink} ${activeTab === 'approvals' ? styles.navLinkActive : ''}`}
id="nav-approvals"
>
Aprobaciones
{t('nav.approvals')}
</Link>
)}
</nav>
<div className={styles.rightArea}>
<select
value={locale}
onChange={(e) => setLocale(e.target.value as 'en' | 'es')}
className={styles.localeSelector}
id="language-selector"
>
<option value="es">ES</option>
<option value="en">EN</option>
</select>
<button onClick={handleLogout} className={styles.logoutBtn}>
Cerrar Sesión
{t('nav.logout')}
</button>
</div>
</header>
);
}

View file

@ -18,9 +18,13 @@ const getPrismaClient = (): PrismaClient => {
ssl: false
});
const adapter = new PrismaPg(pool);
const logLevels: any[] = ['warn', 'error'];
if (process.env.PRISMA_QUERY_LOG === 'true') {
logLevels.push('query', 'info');
}
globalPrisma = new PrismaClient({
adapter,
log: ['query', 'info', 'warn', 'error']
log: logLevels
});
}
return globalPrisma;

View file

@ -0,0 +1,65 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import es from './dictionaries/es.json';
import en from './dictionaries/en.json';
type Locale = 'en' | 'es';
interface LocaleContextProps {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string) => string;
}
const LocaleContext = createContext<LocaleContextProps | undefined>(undefined);
const dictionaries: Record<Locale, any> = { es, en };
function getNestedValue(obj: any, path: string): string {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current && typeof current === 'object' && part in current) {
current = current[part];
} else {
return path;
}
}
return typeof current === 'string' ? current : path;
}
export function LocaleProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>('es');
useEffect(() => {
const stored = localStorage.getItem('app-locale') as Locale;
if (stored === 'en' || stored === 'es') {
setLocaleState(stored);
}
}, []);
const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale);
localStorage.setItem('app-locale', newLocale);
};
const t = (key: string): string => {
const dict = dictionaries[locale];
return getNestedValue(dict, key);
};
return (
<LocaleContext.Provider value={{ locale, setLocale, t }}>
{children}
</LocaleContext.Provider>
);
}
export function useLocale() {
const context = useContext(LocaleContext);
if (!context) {
throw new Error('useLocale must be used within a LocaleProvider');
}
return context;
}

View file

@ -0,0 +1,33 @@
{
"nav": {
"plans": "Commission Plans",
"goals": "Commercial Goals",
"import": "Ingest Sales",
"simulation": "Simulation",
"approvals": "Approvals",
"logout": "Log Out"
},
"simulation": {
"title": "Settlement Simulation",
"description": "Calculate, simulate and audit monthly commissions before the Commercial Leader's approval.",
"periodLabel": "Settlement Period (YYYY-MM)",
"dryRunLabel": "Simulator Mode (Do not write to database)",
"calculateBtn": "Calculate",
"calculatingBtn": "Calculating...",
"resultsHeader": "Calculation Summary",
"summaryCollaborators": "Collaborators",
"summaryCommission": "Proposed Commission",
"summaryAdjustment": "Retroactive Adjustments",
"summaryPayout": "Estimated Total Payout",
"thCollaborator": "Collaborator",
"thPlan": "Plan",
"thGoal": "Goal",
"thSales": "Confirmed Sales",
"thAchievement": "Achievement %",
"thCommission": "Calculated Commission",
"thAdjustment": "Retroactive Adjustments",
"thPayout": "Total Payout",
"thStatus": "Status",
"thAiAudit": "AI Audit"
}
}

View file

@ -0,0 +1,33 @@
{
"nav": {
"plans": "Planes de Comisión",
"goals": "Metas Comerciales",
"import": "Cargar Ventas",
"simulation": "Simulación",
"approvals": "Aprobaciones",
"logout": "Cerrar Sesión"
},
"simulation": {
"title": "Simulación de Liquidación",
"description": "Calcule, simule y audite las comisiones mensuales antes de la aprobación del Líder Comercial.",
"periodLabel": "Período de Liquidación (YYYY-MM)",
"dryRunLabel": "Modo Simulador (No escribir en base de datos)",
"calculateBtn": "Calcular",
"calculatingBtn": "Calculando...",
"resultsHeader": "Resumen del Cálculo",
"summaryCollaborators": "Colaboradores",
"summaryCommission": "Comisión Propuesta",
"summaryAdjustment": "Ajustes Retroactivos",
"summaryPayout": "Pago Total Estimado",
"thCollaborator": "Colaborador",
"thPlan": "Plan",
"thGoal": "Meta",
"thSales": "Ventas Confirmadas",
"thAchievement": "Cumplimiento %",
"thCommission": "Comisión Calculada",
"thAdjustment": "Ajustes Retroactivos",
"thPayout": "Pago Total",
"thStatus": "Estado",
"thAiAudit": "Auditoría AI"
}
}

View file

@ -21,7 +21,9 @@ export async function middleware(req: NextRequest) {
// Get session cookie
const allCookies = req.cookies.getAll();
const sessionCookie = req.cookies.get('session')?.value;
if (process.env.DEBUG === 'true') {
console.log(`[Middleware] Path: ${pathname}, Cookies received:`, allCookies.map(c => c.name), "Session value exists:", !!sessionCookie);
}
const session = sessionCookie ? await verifyJwtEdge(sessionCookie) : null;
// If on login page