commit d6c65c44b1d48ad9a33e2fe0a37e26c12713777d Author: Panchito Date: Fri Jun 26 13:07:13 2026 -0500 Estructura inicial del frontend diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b9e8804 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Credenciales públicas de Supabase para el login (frontend) +# La URL es la misma de tu proyecto. +# La ANON KEY es la PÚBLICA (anon/public), NO la service_role. +VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co +VITE_SUPABASE_ANON_KEY=pega_aqui_tu_anon_public_key diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd8fe26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c04ae2 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Frontend — Dashboard React + +## Requisitos +- Node.js 18+ (descarga: https://nodejs.org) + +## Instalación (una sola vez) +``` +npm install +``` + +## Ejecutar +``` +npm run dev +``` +Abre: http://localhost:3000 + +⚠️ El backend FastAPI debe estar corriendo en http://localhost:8000 +(en otra terminal: `py -3.12 main.py`) + +## Estado +- ✅ Sidebar + navegación +- ✅ Ocupabilidad (conectado al backend) +- 🚧 Ventas, Cobranza, Rentabilidad, Saldo Pendiente, Asesores (próximas sesiones) + +## Cambiar IP del backend +Si el backend corre en otra máquina, edita `src/lib/api.js` línea 3: +```js +const BASE_URL = "http://IP_DEL_SERVIDOR:8000"; +``` diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..b9e8804 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# Credenciales públicas de Supabase para el login (frontend) +# La URL es la misma de tu proyecto. +# La ANON KEY es la PÚBLICA (anon/public), NO la service_role. +VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co +VITE_SUPABASE_ANON_KEY=pega_aqui_tu_anon_public_key diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..dd8fe26 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.env diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..0c04ae2 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,29 @@ +# Frontend — Dashboard React + +## Requisitos +- Node.js 18+ (descarga: https://nodejs.org) + +## Instalación (una sola vez) +``` +npm install +``` + +## Ejecutar +``` +npm run dev +``` +Abre: http://localhost:3000 + +⚠️ El backend FastAPI debe estar corriendo en http://localhost:8000 +(en otra terminal: `py -3.12 main.py`) + +## Estado +- ✅ Sidebar + navegación +- ✅ Ocupabilidad (conectado al backend) +- 🚧 Ventas, Cobranza, Rentabilidad, Saldo Pendiente, Asesores (próximas sesiones) + +## Cambiar IP del backend +Si el backend corre en otra máquina, edita `src/lib/api.js` línea 3: +```js +const BASE_URL = "http://IP_DEL_SERVIDOR:8000"; +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4f9f9a2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Dashboard — Escuela de Refrigeración + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..fb250a2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1980 @@ +{ + "name": "dashboard-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dashboard-frontend", + "version": "1.0.0", + "dependencies": { + "@supabase/supabase-js": "^2.107.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/auth-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz", + "integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz", + "integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz", + "integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz", + "integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz", + "integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz", + "integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.107.0", + "@supabase/functions-js": "2.107.0", + "@supabase/postgrest-js": "2.107.0", + "@supabase/realtime-js": "2.107.0", + "@supabase/storage-js": "2.107.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/exit-on-epipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", + "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fflate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", + "integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==", + "license": "MIT" + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/printj": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz", + "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==", + "license": "Apache-2.0", + "bin": { + "printj": "bin/printj.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz", + "integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.2.0", + "cfb": "^1.1.4", + "codepage": "~1.14.0", + "commander": "~2.17.1", + "crc-32": "~1.2.0", + "exit-on-epipe": "~1.0.1", + "fflate": "^0.3.8", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/adler-32": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz", + "integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==", + "license": "Apache-2.0", + "dependencies": { + "exit-on-epipe": "~1.0.1", + "printj": "~1.1.0" + }, + "bin": { + "adler32": "bin/adler32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/codepage": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz", + "integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==", + "license": "Apache-2.0", + "dependencies": { + "commander": "~2.14.1", + "exit-on-epipe": "~1.0.1" + }, + "bin": { + "codepage": "bin/codepage.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz", + "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==", + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1000243 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "dashboard-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@supabase/supabase-js": "^2.107.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..a6ccd5d --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,65 @@ +// src/App.jsx +import { useState, useEffect } from "react"; +import { useAuth } from "./lib/auth"; +import Sidebar from "./components/Sidebar"; +import Login from "./pages/Login"; +import Ocupabilidad from "./pages/Ocupabilidad"; +import Ventas from "./pages/Ventas"; +import Comisiones from "./pages/Comisiones"; +import Cobranza from "./pages/Cobranza"; +import Rentabilidad from "./pages/Rentabilidad"; +import Asesores from "./pages/Asesores"; +import Usuarios from "./pages/Usuarios"; + +export default function App() { + const { autenticado, cargando, permisos, puedeVer } = useAuth(); + const [pagina, setPagina] = useState(null); + + // Al entrar, seleccionar el primer módulo permitido + useEffect(() => { + if (autenticado && permisos.length > 0 && !pagina) { + setPagina(permisos[0]); + } + if (!autenticado) setPagina(null); + }, [autenticado, permisos, pagina]); + + if (cargando) { + return ( +
+ Cargando... +
+ ); + } + + if (!autenticado) return ; + + function render() { + // Seguridad: si la página actual no está permitida, no renderizar + if (pagina && !puedeVer(pagina)) return ; + switch (pagina) { + case "ocupabilidad": return ; + case "ventas": return ; + case "comisiones": return ; + case "cobranza": return ; + case "rentabilidad": return ; + case "asesores": return ; + case "usuarios": return ; + default: return null; + } + } + + return ( +
+ +
{render()}
+
+ ); +} + +function SinAcceso() { + return ( +
+ 🔒 No tienes acceso a este módulo. +
+ ); +} diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx new file mode 100644 index 0000000..2ad4c3e --- /dev/null +++ b/frontend/src/components/Modal.jsx @@ -0,0 +1,34 @@ +// src/components/Modal.jsx +export default function Modal({ title, onClose, children, width = 900 }) { + return ( +
+
e.stopPropagation()} + style={{ + background: "#fff", borderRadius: 16, width: "100%", maxWidth: width, + maxHeight: "90vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)", + }} + > +
+

{title}

+ +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..39170b9 --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,54 @@ +// src/components/Sidebar.jsx +import { useAuth } from "../lib/auth"; + +const MENU = [ + { id: "ocupabilidad", ico: "📊", label: "Ocupabilidad" }, + { id: "ventas", ico: "💰", label: "Ventas" }, + { id: "comisiones", ico: "🧮", label: "Comisiones" }, + { id: "cobranza", ico: "📋", label: "Cobranza" }, + { id: "rentabilidad", ico: "📈", label: "Rentabilidad" }, + { id: "asesores", ico: "👥", label: "Asesores" }, + { id: "usuarios", ico: "🔐", label: "Usuarios" }, +]; + +export default function Sidebar({ active, onChange }) { + const { perfil, permisos, logout } = useAuth(); + const visibles = MENU.filter((m) => permisos.includes(m.id)); + + return ( + + ); +} diff --git a/frontend/src/components/UI.jsx b/frontend/src/components/UI.jsx new file mode 100644 index 0000000..3ab9ea6 --- /dev/null +++ b/frontend/src/components/UI.jsx @@ -0,0 +1,51 @@ +// src/components/UI.jsx +export function Loader({ text = "Cargando..." }) { + return ( +
+
+
{text}
+
+ ); +} + +export function ErrorBox({ msg }) { + return
⚠️ {msg}
; +} + +export function colorSemaforo(pct) { + if (pct >= 80) return { bg: "#d1fae5", fg: "#065f46", fill: "#10b981" }; + if (pct >= 60) return { bg: "#fef9c3", fg: "#854d0e", fill: "#f59e0b" }; + return { bg: "#fee2e2", fg: "#991b1b", fill: "#ef4444" }; +} + +export function ProgressBar({ pct }) { + const c = colorSemaforo(pct); + const w = Math.min(Math.max(pct, 0), 100); + return ( +
+ {pct.toFixed(1)}% +
+
+
+
+ ); +} + +export function Filters({ children }) { + return
{children}
; +} + +export function Select({ label, value, options, onChange }) { + return ( +
+ {label && } + +
+ ); +} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..958d367 --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,73 @@ +// src/lib/api.js +// Cliente para el backend FastAPI. Cambia BASE_URL si el backend corre en otra IP. +const BASE_URL = "http://localhost:8000"; + +async function get(path, params = {}) { + const qs = new URLSearchParams(params).toString(); + const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`; + const r = await fetch(url); + if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); + return r.json(); +} + +async function postJson(path, body) { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); + return r.json(); +} + +async function post(path, params = {}) { + const qs = new URLSearchParams(params).toString(); + const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`; + const r = await fetch(url, { method: "POST" }); + if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); + return r.json(); +} + +export const api = { + periodoActual: () => get("/api/periodo-actual"), + ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => + get("/api/ocupabilidad", { ano, mes, sede, programa }), + ventas: (ano, mes, sede = "TODOS", programa = "TODOS") => get("/api/ventas", { ano, mes, sede, programa }), + ventasDetalle: (vendedor, ano, mes, tipo, sede = "TODOS", programa = "TODOS") => + get("/api/ventas/detalle", { vendedor, ano, mes, tipo, sede, programa }), + cobranza: (ano, mes, sectorista = "TODOS", agrupacion = "SEDE") => + get("/api/cobranza", { ano, mes, sectorista, agrupacion }), + cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA") => + get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion }), + rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => + get("/api/rentabilidad", { ano, mes, sede, programa }), + rentabilidadDetalle: (programa, ano, mes) => + get("/api/rentabilidad/detalle", { programa, ano, mes }), + rentabilidadCostos: (programa, ano, mes) => + get("/api/rentabilidad/costos", { programa, ano, mes }), + saldoPendiente: (tipo_cuota) => get("/api/saldo-pendiente", { tipo_cuota }), + asesores: () => get("/api/asesores"), + asesorEstado: (agent_id, online) => + post("/api/asesores/estado", { agent_id, online }), + guardarCostos: (num_indice, inicial, actual) => + postJson("/api/rentabilidad/costos/guardar", { num_indice, inicial, actual }), + clasificarSede: (programas) => + postJson("/api/cobranza/clasificar-sede", { programas }), + cobranzaDetalleTodos: (ano, mes, sectorista = "TODOS") => + get("/api/cobranza/detalle-todos", { ano, mes, sectorista }), + usuariosListar: () => get("/api/usuarios"), + usuariosCrear: (email, password, nombre, rol) => + postJson("/api/usuarios/crear", { email, password, nombre, rol }), + usuariosActualizarRol: (id, rol) => postJson("/api/usuarios/rol", { id, rol }), + usuariosEliminar: (id) => postJson("/api/usuarios/eliminar", { id }), + vendedoresManualesListar: (ano, mes) => get("/api/comisiones/vendedores", { ano, mes }), + vendedoresManualesCrear: (datos) => postJson("/api/comisiones/vendedores/crear", datos), + vendedoresManualesEliminar: (id) => postJson("/api/comisiones/vendedores/eliminar", { id }), + vendedoresManualesActualizar: (datos) => postJson("/api/comisiones/vendedores/actualizar", datos), + vendedoresManualesLote: (datos) => postJson("/api/comisiones/vendedores/lote", datos), + comisionesOverrideGuardar: (registros) => postJson("/api/comisiones/override/guardar", { registros }), + comisionesOverrideRestaurar: (num_matricula) => postJson("/api/comisiones/override/restaurar", { num_matricula }), + comisionesDetalleTodos: (ano, mes) => get("/api/comisiones/detalle-todos", { ano, mes }), + comisionesConfig: (ano, mes) => get("/api/comisiones/config", { ano, mes }), + comisionesConfigGuardar: (datos) => postJson("/api/comisiones/config/guardar", datos), +}; diff --git a/frontend/src/lib/auth.jsx b/frontend/src/lib/auth.jsx new file mode 100644 index 0000000..e218452 --- /dev/null +++ b/frontend/src/lib/auth.jsx @@ -0,0 +1,106 @@ +// src/lib/auth.jsx +import { createContext, useContext, useEffect, useState, useCallback } from "react"; +import { supabase } from "./supabase"; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [session, setSession] = useState(null); + const [perfil, setPerfil] = useState(null); // { nombre, rol } + const [permisos, setPermisos] = useState([]); // ["ocupabilidad", "cobranza", ...] + const [cargando, setCargando] = useState(true); + + // Cargar perfil (rol) y permisos del usuario logueado + const cargarPerfil = useCallback(async (userId) => { + // 1) Traer el rol del usuario desde la tabla "perfiles" + const { data: perf, error: e1 } = await supabase + .from("perfiles") + .select("nombre, rol, activo") + .eq("id", userId) + .single(); + + if (e1 || !perf || perf.activo === false) { + setPerfil(null); + setPermisos([]); + return false; + } + + // 2) Traer los módulos permitidos para ese rol desde "permisos" + const { data: perms } = await supabase + .from("permisos") + .select("modulo, puede_ver") + .eq("rol", perf.rol); + + const modulos = (perms || []) + .filter((p) => p.puede_ver) + .map((p) => p.modulo); + + setPerfil({ nombre: perf.nombre, rol: perf.rol }); + setPermisos(modulos); + return true; + }, []); + + // Al iniciar: revisar si ya hay sesión activa + useEffect(() => { + let activo = true; + supabase.auth.getSession().then(async ({ data }) => { + if (!activo) return; + const s = data?.session || null; + setSession(s); + if (s?.user) await cargarPerfil(s.user.id); + setCargando(false); + }); + + // Escuchar cambios de sesión (login/logout) + const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => { + setSession(s); + if (s?.user) await cargarPerfil(s.user.id); + else { setPerfil(null); setPermisos([]); } + }); + + return () => { activo = false; sub?.subscription?.unsubscribe(); }; + }, [cargarPerfil]); + + const login = async (email, password) => { + const { data, error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) return { ok: false, msg: traducirError(error.message) }; + // Verificar que tenga perfil/rol válido + const ok = await cargarPerfil(data.user.id); + if (!ok) { + await supabase.auth.signOut(); + return { ok: false, msg: "Tu usuario no tiene un perfil asignado. Contacta al administrador." }; + } + return { ok: true }; + }; + + const logout = async () => { + await supabase.auth.signOut(); + setPerfil(null); + setPermisos([]); + }; + + const value = { + session, + perfil, + permisos, + cargando, + login, + logout, + autenticado: !!session && !!perfil, + puedeVer: (modulo) => permisos.includes(modulo), + }; + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth debe usarse dentro de AuthProvider"); + return ctx; +} + +function traducirError(msg) { + if (/invalid login credentials/i.test(msg)) return "Correo o contraseña incorrectos."; + if (/email not confirmed/i.test(msg)) return "El correo no está confirmado."; + return msg || "Error al iniciar sesión."; +} diff --git a/frontend/src/lib/supabase.js b/frontend/src/lib/supabase.js new file mode 100644 index 0000000..a3de4b6 --- /dev/null +++ b/frontend/src/lib/supabase.js @@ -0,0 +1,10 @@ +// src/lib/supabase.js +// Cliente de Supabase para autenticación (login). +// IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role). +import { createClient } from "@supabase/supabase-js"; + +// Estos valores se leen de frontend/.env (ver .env.example) +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || ""; +const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || ""; + +export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); diff --git a/frontend/src/lib/useColumnasAjustables.jsx b/frontend/src/lib/useColumnasAjustables.jsx new file mode 100644 index 0000000..88732ee --- /dev/null +++ b/frontend/src/lib/useColumnasAjustables.jsx @@ -0,0 +1,63 @@ +// src/lib/useColumnasAjustables.jsx +// Hook reutilizable para columnas redimensionables (estilo Excel) en cualquier tabla. +import { useState, useEffect } from "react"; + +export function useColumnasAjustables(anchosIniciales) { + const [anchos, setAnchos] = useState(anchosIniciales); + + // Si cambia el número de columnas (ej. Cobranza Sede↔Programa), reiniciar anchos + useEffect(() => { + if (anchos.length !== anchosIniciales.length) { + setAnchos(anchosIniciales); + } + }, [anchosIniciales.length]); + + function iniciarResize(e, i) { + e.preventDefault(); + e.stopPropagation(); + const xInicial = e.clientX; + const anchoInicial = anchos[i]; + function onMove(ev) { + const nuevo = Math.max(50, anchoInicial + (ev.clientX - xInicial)); + setAnchos((prev) => { const c = [...prev]; c[i] = nuevo; return c; }); + } + function onUp() { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + } + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + } + + // Devuelve los elementos para el de la tabla + function ColGroup() { + return ( + + {anchos.map((w, i) => )} + + ); + } + + // Devuelve el divisor arrastrable para poner dentro de cada + function Resizer({ index }) { + return ( + iniciarResize(e, index)} + className="col-resizer" + style={{ position: "absolute", top: 0, right: 0, width: 8, height: "100%", + cursor: "col-resize", userSelect: "none", zIndex: 5 }} + /> + ); + } + + const anchoTotal = anchos.reduce((a, b) => a + b, 0); + + // width:100% → llena el contenedor desde el inicio (sin huecos a la derecha). + // minWidth:anchoTotal → si las columnas crecen más que el contenedor, aparece scroll. + // tableLayout:fixed + colgroup → encabezados y celdas alineados. + const tableProps = { + style: { tableLayout: "fixed", width: "100%", minWidth: anchoTotal }, + }; + + return { anchos, ColGroup, Resizer, anchoTotal, tableProps }; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..8fd46bf --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import { AuthProvider } from "./lib/auth"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + +); diff --git a/frontend/src/pages/Asesores.jsx b/frontend/src/pages/Asesores.jsx new file mode 100644 index 0000000..d71ec72 --- /dev/null +++ b/frontend/src/pages/Asesores.jsx @@ -0,0 +1,105 @@ +// src/pages/Asesores.jsx +import { useState, useEffect, useCallback } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox } from "../components/UI"; + +const ESTADO_INFO = { + online: { txt: "En Línea", color: "#10b981", bg: "#d1fae5", fg: "#065f46" }, + busy: { txt: "Ocupado", color: "#f59e0b", bg: "#fef9c3", fg: "#854d0e" }, + offline:{ txt: "Fuera de Línea", color: "#94a3b8", bg: "#f1f5f9", fg: "#475569" }, +}; + +export default function Asesores() { + const [agentes, setAgentes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [cambiando, setCambiando] = useState(null); + + const cargar = useCallback(() => { + setLoading(true); setError(null); + api.asesores() + .then((res)=>{ setAgentes(res.agentes||[]); setLoading(false); }) + .catch((e)=>{ setError(e.message); setLoading(false); }); + }, []); + + useEffect(() => { cargar(); }, [cargar]); + + async function toggle(ag) { + const online = ag.availability_status !== "online"; + setCambiando(ag.id); + try { + await api.asesorEstado(ag.id, online); + setAgentes((prev)=>prev.map((a)=>a.id===ag.id ? {...a, availability_status: online?"online":"offline"} : a)); + } catch (e) { /* noop */ } + setCambiando(null); + } + + async function todos(online) { + setCambiando("all"); + for (const ag of agentes) { + try { await api.asesorEstado(ag.id, online); } catch (e) {} + } + setAgentes((prev)=>prev.map((a)=>({...a, availability_status: online?"online":"offline"}))); + setCambiando(null); + } + + const total = agentes.length; + const enLinea = agentes.filter((a)=>a.availability_status==="online").length; + const ocupados = agentes.filter((a)=>a.availability_status==="busy").length; + const offline = total - enLinea - ocupados; + + return ( +
+

👥 Asesores

+ + {loading ? : + error ? : + <> +
+
👥
Total Asesores
{total}
+
🟢
En Línea
{enLinea}
+
🟡
Ocupados
{ocupados}
+
🔴
Fuera de Línea
{offline}
+
+ +
+ + + +
+ +
+ + + + {agentes.map((ag)=>{ + const est = ESTADO_INFO[ag.availability_status] || ESTADO_INFO.offline; + const isOnline = ag.availability_status==="online"; + return ( + + + + + + + + ); + })} + +
ASESOREMAILROLESTADOACCIÓN
{ag.name || ag.available_name || "—"}{ag.email || "—"}{ag.role || "agent"} + ● {est.txt} + + +
+
+ } +
+ ); +} diff --git a/frontend/src/pages/Cobranza.jsx b/frontend/src/pages/Cobranza.jsx new file mode 100644 index 0000000..742498a --- /dev/null +++ b/frontend/src/pages/Cobranza.jsx @@ -0,0 +1,660 @@ +// src/pages/Cobranza.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI"; +import Modal from "../components/Modal"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; +const AGRUPACIONES = [{value:"SEDE",label:"Por Sede"},{value:"PROGRAMA",label:"Por Programa"},{value:"ASESOR",label:"Por Asesor"}]; +const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"]; +const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"]; + +// Colores por cartera — diferenciados y legibles (sin azul, reservado para TOTAL) +// ANTERIOR = ámbar suave | MES = verde menta | TOTAL = lila/violeta suave +const BG_ANT = "#fef3c7", BG_MES = "#d1fae5", BG_TOT = "#ede9fe"; +// Versiones más oscuras para hover de cada cartera +const HV_ANT = "#fde68a", HV_MES = "#a7f3d0", HV_TOT = "#ddd6fe"; + +function toMonto(s){ const n = parseFloat(String(s).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; } +function toPct(s){ const n = parseFloat(String(s).replace("%","").trim()); return isNaN(n)?0:n; } +// Formato moneda SIN decimales (solo visualización) +function fmtMoneda(s){ + if (s == null) return ""; + if (!String(s).includes("S/")) return s; // no es monto, devolver tal cual + const n = toMonto(s); + return "S/ " + n.toLocaleString("es-PE", { maximumFractionDigits: 0 }); +} + +function MiniBar({ valStr }) { + if (!valStr || !String(valStr).includes("%")) return ; + const pct = toPct(valStr); + const c = colorSemaforo(pct); + const w = Math.min(Math.max(pct,0),100); + return ( +
+ {pct.toFixed(0)}% +
+
+
+
+ ); +} + +export default function Cobranza() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [mes, setMes] = useState(hoy.getMonth() + 1); + const [agrupacion, setAgrupacion] = useState("SEDE"); + const [sectorista, setSectorista] = useState("TODOS"); + const [sede, setSede] = useState("TODOS"); + const [frec, setFrec] = useState("TODOS"); + const [filas, setFilas] = useState([]); + const [sectoristas, setSectoristas] = useState(["TODOS"]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modal, setModal] = useState(null); + const [sedeMap, setSedeMap] = useState({}); // programa -> sede (sede.json) + const [exportando, setExportando] = useState(false); + const [alumnos, setAlumnos] = useState([]); // todos los alumnos (1 sola consulta) + const [buscadorAbierto, setBuscadorAbierto] = useState(false); + + const esPrograma = agrupacion === "PROGRAMA"; + // índices según agrupación + const IDX = esPrograma + ? { saldo:13, ant:[4,5,6], mes:[7,8,9], tot:[10,11,12] } + : { saldo:10, ant:[1,2,3], mes:[4,5,6], tot:[7,8,9] }; + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.cobranza(ano, mes, sectorista, agrupacion) + .then((res) => { + if (!activo) return; + setFilas(res.filas || []); + setSectoristas(res.sectoristas || ["TODOS"]); + setLoading(false); + }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, agrupacion, sectorista]); + + // Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada + useEffect(() => { + let activo = true; + api.cobranzaDetalleTodos(ano, mes, sectorista) + .then((res) => { if (activo) setAlumnos(res.alumnos || []); }) + .catch(() => { if (activo) setAlumnos([]); }); + return () => { activo = false; }; + }, [ano, mes, sectorista]); + + // Cargar clasificación de sede (sede.json) para los programas visibles + useEffect(() => { + if (!esPrograma) return; + const nombres = filas + .filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL") + .map((f) => String(f[0])); + if (nombres.length === 0) return; + let activo = true; + api.clasificarSede(nombres) + .then((res) => { if (activo) setSedeMap(res.mapa || {}); }) + .catch(() => { if (activo) setSedeMap({}); }); + return () => { activo = false; }; + }, [filas, esPrograma]); + + // Filtro sede/frecuencia (solo PROGRAMA) en cliente — usa sede.json + const datos = useMemo(() => { + let arr = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); + if (esPrograma) { + if (sede !== "TODOS") { + arr = arr.filter((f) => { + const s = (sedeMap[String(f[0])] || "LIMA").toUpperCase(); + return s === sede.toUpperCase(); + }); + } + if (frec !== "TODOS") arr = arr.filter((f) => String(f[1]).toUpperCase() === frec); + } + return arr; + }, [filas, esPrograma, sede, frec, sedeMap]); + + // KPIs: 3 tarjetas (recalculadas con datos filtrados) + const kpis = useMemo(() => { + let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0; + datos.forEach((f) => { + cAnt += toMonto(f[IDX.ant[0]]); obAnt += toMonto(f[IDX.ant[1]]); + cMes += toMonto(f[IDX.mes[0]]); obMes += toMonto(f[IDX.mes[1]]); + cTot += toMonto(f[IDX.tot[0]]); obTot += toMonto(f[IDX.tot[1]]); + }); + return { cAnt,obAnt,cMes,obMes,cTot,obTot, saldo: cTot-obTot }; + }, [datos, IDX]); + + const headers = esPrograma + ? ["PROGRAMA","FREC.","N° CUOTA","VENCIMIENTO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."] + : ["GRUPO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."]; + + // Anchos para columnas redimensionables (1ª columna más ancha, resto estándar) + const anchosCob = headers.map((h, i) => i === 0 ? (esPrograma ? 280 : 200) : (h === "FREC." || h.startsWith("%") || h === "OPC." ? 80 : 120)); + const cols = useColumnasAjustables(anchosCob); + + function cartClase(ci) { + if (IDX.ant.includes(ci)) return "cart-ant"; + if (IDX.mes.includes(ci)) return "cart-mes"; + if (IDX.tot.includes(ci)) return "cart-tot"; + return ""; + } + + // Alumnos visibles según filtros activos (mismo dataset que Ver / Estado de Cuenta) + const alumnosFiltrados = useMemo(() => { + let arr = alumnos; + if (esPrograma) { + if (sede !== "TODOS") arr = arr.filter((a) => String(a.sede).toUpperCase() === sede.toUpperCase()); + if (frec !== "TODOS") arr = arr.filter((a) => String(a.frecuencia).toUpperCase() === frec); + } + return arr; + }, [alumnos, esPrograma, sede, frec]); + + function grupoDe(a) { + if (agrupacion === "SEDE") return a.sede; + if (agrupacion === "ASESOR") return ""; // el sectorista ya filtra; no hay sub-grupo por alumno + return a.programa; // PROGRAMA + } + + // Exportar el DETALLE DE ALUMNOS (rápido: usa el dataset ya cargado, sin N consultas) + async function exportarDetalle() { + setExportando(true); + try { + const base = alumnosFiltrados; + if (base.length === 0) { alert("No hay alumnos para exportar con los filtros actuales."); return; } + + const toDate = (v) => { + if (!v || v === "-") return ""; + const s = String(v).trim().replace(/\//g,"-"); + const p = s.split("-"); + if (p.length === 3 && p[2].length === 4) return new Date(+p[2], +p[1]-1, +p[0]); + return String(v); + }; + const pct = (cob, cta) => cta > 0 ? cob/cta : null; + + const XLSX = await import("xlsx-js-style"); + const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; + + // Colores suaves por cartera (igual que el dashboard): ANT ámbar, MES verde, TOT lila + const CART = { ant:"FEF3C7", mes:"D1FAE5", tot:"EDE9FE" }; + const cartFill = (c) => { + if ([5,6,7].includes(c)) return CART.ant; + if ([8,9,10].includes(c)) return CART.mes; + if ([11,12,13].includes(c)) return CART.tot; + return null; + }; + const headerStyle = { font:{bold:true,color:{rgb:"FFFFFF"},sz:11}, fill:{fgColor:{rgb:"1E40AF"}}, alignment:{horizontal:"center",vertical:"center"} }; + const moneyFmt = '"S/" #,##0'; + const pctFmt = '0%'; + const dateFmt = "dd/mm/yyyy"; + const cellStyle = (c) => { const f = cartFill(c); return f ? { fill:{fgColor:{rgb:f}} } : undefined; }; + + const ws = {}; + COLS.forEach((h,c)=>{ ws[XLSX.utils.encode_cell({r:0,c})] = {v:h,t:"s",s:headerStyle}; }); + + base.forEach((a, ri) => { + const r = ri + 1; + const vals = [ + grupoDe(a), a.matricula, a.alumno, a.num_cuota, toDate(a.fch_venc), + a.cta_ant, a.cob_ant, pct(a.cob_ant,a.cta_ant), + a.cta_cur, a.cob_cur, pct(a.cob_cur,a.cta_cur), + a.cta_tot, a.cob_tot, pct(a.cob_tot,a.cta_tot), + a.saldo, + ]; + vals.forEach((v, c) => { + const ref = XLSX.utils.encode_cell({r,c}); + const st = cellStyle(c); + if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) }; + else if ([7,10,13].includes(c)) ws[ref] = (v===null) ? { v:"-", t:"s", ...(st?{s:st}:{}) } : { v:Number(v), t:"n", z:pctFmt, ...(st?{s:st}:{}) }; + else if (c===4 && v instanceof Date) ws[ref] = { v, t:"d", z:dateFmt }; + else ws[ref] = { v: v ?? "", t:"s" }; + }); + }); + + // TOTAL GENERAL (suma de montos) + const rTot = base.length + 1; + const totalStyle = { font:{bold:true}, fill:{fgColor:{rgb:"EFF6FF"}} }; + const sum = (k) => base.reduce((s,a)=>s+(Number(a[k])||0),0); + const totVals = { 5:sum("cta_ant"),6:sum("cob_ant"),8:sum("cta_cur"),9:sum("cob_cur"),11:sum("cta_tot"),12:sum("cob_tot"),14:sum("saldo") }; + COLS.forEach((col,c)=>{ + const ref = XLSX.utils.encode_cell({r:rTot,c}); + if (c===0) ws[ref] = {v:"TOTAL GENERAL",t:"s",s:totalStyle}; + else if (totVals[c]!==undefined) ws[ref] = {v:totVals[c],t:"n",z:moneyFmt,s:totalStyle}; + else ws[ref] = {v:"",t:"s",s:totalStyle}; + }); + + ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rTot,c:COLS.length-1} }); + ws["!cols"] = [{wch:26},{wch:12},{wch:28},{wch:9},{wch:12},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:13}]; + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Cobranza"); + const sufijo = agrupacion + (esPrograma && sede!=="TODOS" ? `_${sede}` : "") + (sectorista!=="TODOS" ? `_${sectorista}` : ""); + XLSX.writeFile(wb, `cobranza_${sufijo}_${ano}_${mes}.xlsx`); + } finally { + setExportando(false); + } + } + + return ( +
+

📋 Cobranza

+ + + ({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} /> + + {esPrograma && } +
+ + +
+
+ + {loading ? : + error ? : + <> + + +
+ + + {headers.map((h, i)=>)} + + {datos.map((f, ri) => { + // En la tabla principal SIEMPRE son agrupaciones (Sede/Programa/Asesor), + // nunca alumnos individuales → NO aplicar rojo por deuda aquí. + // Regla "-": si % del grupo está vacío, CTA y COB del grupo → "-" + const antVacio = !String(f[IDX.ant[2]]||"").trim() || ["-","—"].includes(String(f[IDX.ant[2]]).trim()); + const mesVacio = !String(f[IDX.mes[2]]||"").trim() || ["-","—"].includes(String(f[IDX.mes[2]]).trim()); + const totVacio = !String(f[IDX.tot[2]]||"").trim() || ["-","—"].includes(String(f[IDX.tot[2]]).trim()); + return ( + + {f.slice(0, headers.length-1).map((v, ci) => { + const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; + let val = v; + if ((ci===IDX.ant[0]||ci===IDX.ant[1]||ci===IDX.ant[2]) && antVacio) val = "—"; + if ((ci===IDX.mes[0]||ci===IDX.mes[1]||ci===IDX.mes[2]) && mesVacio) val = "—"; + if ((ci===IDX.tot[0]||ci===IDX.tot[1]||ci===IDX.tot[2]) && totVacio) val = "—"; + const cartCls = cartClase(ci); + const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:""); + return ( + + ); + })} + + + ); + })} + {datos.length > 0 && (() => { + // Fila TOTAL GENERAL — suma de los datos visibles (respeta filtros) + const t = {}; + [IDX.ant[0],IDX.ant[1],IDX.mes[0],IDX.mes[1],IDX.tot[0],IDX.tot[1],IDX.saldo] + .forEach((ci)=>{ t[ci] = datos.reduce((s,f)=>s+toMonto(f[ci]),0); }); + const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : ""; + const cell = (ci) => { + if (ci===0) return "TOTAL GENERAL"; + if (ci===IDX.ant[2]) return ratio(t[IDX.ant[1]],t[IDX.ant[0]]); + if (ci===IDX.mes[2]) return ratio(t[IDX.mes[1]],t[IDX.mes[0]]); + if (ci===IDX.tot[2]) return ratio(t[IDX.tot[1]],t[IDX.tot[0]]); + if (t[ci]!==undefined) return "S/ " + t[ci].toLocaleString("es-PE",{maximumFractionDigits:0}); + return ""; + }; + return ( + + {Array.from({length: headers.length-1}).map((_,ci)=>{ + const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; + const val = cell(ci); + return ( + + ); + })} + + + ); + })()} + +
{h}
+ {isPct ? (val==="—" ? : ) : fmtMoneda(val)} + + +
+ {isPct ? : val} +
+
+ } + + {modal && ( + setModal(null)} /> + )} + + {buscadorAbierto && ( + setBuscadorAbierto(false)} + /> + )} +
+ ); +} + +function Tarjetas({ kpis }) { + const alDia = kpis.saldo <= 0.01; + if (alDia) { + return ( +
+
+
AL DÍA
+
Sin deuda pendiente
+
+ ); + } + const cards = [ + ["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt], + ["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes], + ["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot], + ]; + return ( +
+ {cards.map(([titulo,color,cta,cob]) => { + const ratio = cta>0 ? (cob/cta*100) : 0; + const c = colorSemaforo(ratio); + const saldo = cta - cob; + return ( +
+
{titulo}
+
+ + + 0?"#dc2626":"#10b981"} /> + {cta>0 && ( +
+
+ % de Pago + {ratio.toFixed(0)}% +
+
+
+
+
+ )} +
+
+ ); + })} +
+ ); +} + +function Row({ k, v, bold, color }) { + return ( +
+ {k} + {v} +
+ ); +} + +function TarjetasResumen({ r }) { + const cards = [ + ["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt], + ["📅 MES EN CURSO","#059669",r.cMes,r.obMes], + ["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot], + ]; + return ( +
+ {cards.map(([titulo,color,cta,cob]) => { + const ratio = cta>0 ? (cob/cta*100) : 0; + const c = colorSemaforo(ratio); + const saldo = cta - cob; + return ( +
+
{titulo}
+
+ + + 0?"#dc2626":"#10b981"} /> + {cta>0 && ( +
+
+ % de Pago + {ratio.toFixed(0)}% +
+
+
+
+
+ )} +
+
+ ); + })} +
+ ); +} + +function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) { + const [texto, setTexto] = useState(""); + const [query, setQuery] = useState(""); + + const resultados = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return []; + return alumnos.filter((a) => + String(a.alumno).toLowerCase().includes(q) || + String(a.programa).toLowerCase().includes(q) || + String(a.matricula).toLowerCase().includes(q) + ); + }, [alumnos, query]); + + const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.", + "CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; + + const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : ""; + + return ( + +
+ setTexto(e.target.value)} + onKeyDown={(e)=>{ if(e.key==="Enter") setQuery(texto); }} + placeholder="Escribe nombre, programa o matrícula (ej: Aro) y presiona Enter" + style={{flex:1,padding:"10px 14px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:14}} + /> + + {query && } +
+ + {!query ? ( +
+ Escribe un texto y presiona Enter para buscar. +
+ ) : resultados.length === 0 ? ( +
+ Sin coincidencias para "{query}". +
+ ) : ( + <> +
+ {resultados.length} alumno(s) encontrado(s) +
+
+ + {COLS.map((c)=>)} + + {resultados.map((a,i)=>{ + const deuda = a.saldo > 0.01; + const grp = agrupacion==="SEDE" ? a.sede : (agrupacion==="ASESOR" ? sectorista : a.programa); + const celdas = [ + {v:grp, cls:"col-name", al:"left"}, + {v:a.matricula}, + {v:a.alumno, cls:"col-name", al:"left"}, + {v:a.num_cuota}, + {v:a.fch_venc}, + {v:fmtMoneda("S/ "+a.cta_ant), al:"right", cart:"cart-ant"}, + {v:fmtMoneda("S/ "+a.cob_ant), al:"right", cart:"cart-ant"}, + {pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"}, + {v:fmtMoneda("S/ "+a.cta_cur), al:"right", cart:"cart-mes"}, + {v:fmtMoneda("S/ "+a.cob_cur), al:"right", cart:"cart-mes"}, + {pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"}, + {v:fmtMoneda("S/ "+a.cta_tot), al:"right", cart:"cart-tot"}, + {v:fmtMoneda("S/ "+a.cob_tot), al:"right", cart:"cart-tot"}, + {pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"}, + {v:fmtMoneda("S/ "+a.saldo), al:"right", saldo:true}, + ]; + return ( + + {celdas.map((c,j)=>( + + ))} + + ); + })} + +
{c}
+ {c.pct!==undefined ? (c.pct ? : "—") : c.v} +
+
+ + )} +
+ ); +} + +function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) { + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let activo = true; + setLoading(true); + api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); + return () => { activo = false; }; + }, [grupo, ano, mes, sectorista, agrupacion]); + + const COLS = ["MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; + + // Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos + const resumen = useMemo(() => { + let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0; + filas.forEach((f) => { + if (String(f[0]).toUpperCase() === "TOTAL") return; + cAnt += toMonto(f[4]); obAnt += toMonto(f[5]); + cMes += toMonto(f[7]); obMes += toMonto(f[8]); + cTot += toMonto(f[10]); obTot += toMonto(f[11]); + }); + return { cAnt,obAnt,cMes,obMes,cTot,obTot }; + }, [filas]); + + return ( + + {loading ? : + filas.length === 0 ?
Sin datos.
: + <> +
+ +
+
+ + {COLS.map((c)=>)} + + {filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL").map((f, i) => { + const saldo = toMonto(f[13]); + const deuda = saldo > 0.01; + // Regla "-": si %ANT vacío → CTA/COB ANT a "—" (igual MES y TOT) + const antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim()); + const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim()); + const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim()); + return ( + + {f.map((v, j) => { + let val = v; + if ((j===4||j===5||j===6) && antVacio) val = "—"; + if ((j===7||j===8||j===9) && mesVacio) val = "—"; + if ((j===10||j===11||j===12) && totVacio) val = "—"; + const isPct = [6,9,12].includes(j); + const isMonto = String(val).includes("S/"); + let cart = ""; + if ([4,5,6].includes(j)) cart = "cart-ant"; + else if ([7,8,9].includes(j)) cart = "cart-mes"; + else if ([10,11,12].includes(j)) cart = "cart-tot"; + const clss = ((j===1?"col-name":"") + (cart?` ${cart}`:"")).trim(); + return ( + + ); + })} + + ); + })} + {(() => { + // TOTAL GENERAL del modal — solo alumnos visibles (respeta la sede filtrada) + const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL"); + if (alumnos.length===0) return null; + const sum = (j)=>alumnos.reduce((s,f)=>s+toMonto(f[j]),0); + const t = {4:sum(4),5:sum(5),7:sum(7),8:sum(8),10:sum(10),11:sum(11),13:sum(13)}; + const r = (cob,cta)=>cta>0?`${Math.round(cob/cta*100)}%`:""; + const celda = (j)=>{ + if (j===0) return "TOTAL GENERAL"; + if (j===1||j===2||j===3) return ""; + if (j===6) return r(t[5],t[4]); + if (j===9) return r(t[8],t[7]); + if (j===12) return r(t[11],t[10]); + if (t[j]!==undefined) return "S/ "+t[j].toLocaleString("es-PE",{maximumFractionDigits:0}); + return ""; + }; + return ( + + {Array.from({length:14}).map((_,j)=>{ + const isPct=[6,9,12].includes(j); + const val=celda(j); + return ( + + ); + })} + + ); + })()} + +
{c}
+ {isPct ? (val==="—"?:) : fmtMoneda(val)} +
+ {isPct?:val} +
+
+ } +
+ ); +} diff --git a/frontend/src/pages/Comisiones.jsx b/frontend/src/pages/Comisiones.jsx new file mode 100644 index 0000000..e519e99 --- /dev/null +++ b/frontend/src/pages/Comisiones.jsx @@ -0,0 +1,1004 @@ +// src/pages/Comisiones.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; +import Modal from "../components/Modal"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; +const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; +const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; +const COLS = ["VENDEDOR","INSCRITOS PAGADOS MES EN CURSO","MONTO PAGADO MES EN CURSO","INSCRITOS PAGADOS MES PASADO","MONTO PAGADO MES PASADO","AVANCE TOTAL","OPCIONES"]; +// Mostaza transparente: resalta filas de alumnos con edición (override en Supabase) +const BG_EDITADO = "rgba(217,160,28,0.14)"; + +// Filtro del popup → tipo de lista que ya maneja ventasDetalle +const OPCIONES_POPUP = [ + { value: "Venta P.C", label: "INSCRITOS PAGADOS MES EN CURSO" }, + { value: "Venta Pendientes", label: "INSCRITOS PAGADOS MES PASADO" }, +]; + +function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; } +// Formatea cualquier monto a "S/ 1,234" (sin decimales) +function fmt(v){ return "S/ " + toNum(v).toLocaleString("es-PE",{maximumFractionDigits:0}); } +// True si la fecha de matrícula (dd/mm/yyyy) es >= 01/01/2025 +function fechaMatDesde2025(fch){ + const s = String(fch || "").trim(); + const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); + if (!m) return false; // sin fecha válida → excluir + const d = new Date(+m[3], +m[2]-1, +m[1]); + return d >= new Date(2025, 0, 1); +} + +export default function Comisiones() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [mes, setMes] = useState(hoy.getMonth() + 1); + const [sede, setSede] = useState("TODOS"); + const [programa, setPrograma] = useState("TODOS"); + const [filas, setFilas] = useState([]); + const [manualesAno, setManualesAno] = useState([]); // todos los del año (cache) + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modal, setModal] = useState(null); // { vendedor, manual? } + const [modalEdit, setModalEdit] = useState(null); // { vendedor } + const [modalEditManual, setModalEditManual] = useState(null); // { nombre } + const [modalAdd, setModalAdd] = useState(false); + const [modalBuscar, setModalBuscar] = useState(false); + const [modalConfig, setModalConfig] = useState(false); + const cols = useColumnasAjustables([200, 160, 150, 160, 150, 130, 110]); + + // Carga TODOS los manuales del año (una sola vez por año) → cambio de mes instantáneo + function cargarManuales() { + api.vendedoresManualesListar(ano, 0) + .then((res) => setManualesAno(res.vendedores || [])) + .catch(() => setManualesAno([])); + } + + // Manuales del mes actual: filtrado en memoria (instantáneo, sin esperar red) + const manuales = useMemo( + () => manualesAno.filter((m) => Number(m.mes) === Number(mes)), + [manualesAno, mes] + ); + + // Tabla de ventas (depende del mes, sede y programa) + function cargarTabla() { + setLoading(true); setError(null); + api.ventas(ano, mes, sede, programa) + .then((res) => { setFilas(res.filas || []); setLoading(false); }) + .catch((e) => { setError(e.message); setLoading(false); }); + } + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.ventas(ano, mes, sede, programa) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, sede, programa]); + + // Cargar manuales del año cuando cambia el año (o al inicio) + useEffect(() => { cargarManuales(); }, [ano]); + + const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]); + const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]); + + // Agrupar vendedores manuales por nombre (cada nombre = 1 fila, suma de montos) + const manualesAgrupados = useMemo(() => { + const map = {}; + manuales.forEach((m) => { + if (!map[m.nombre]) map[m.nombre] = { nombre: m.nombre, totalMonto: 0, filas: [] }; + map[m.nombre].totalMonto += toNum(m.monto); + map[m.nombre].filas.push(m); + }); + return Object.values(map); + }, [manuales]); + + // Total general de AVANCE TOTAL = ventas + montos manuales + // (los manuales solo cuentan cuando NO hay filtro de sede/programa) + const totalAvance = useMemo(() => { + let s = total ? toNum(total[7]) : 0; + if (sede==="TODOS" && programa==="TODOS") manuales.forEach((m) => { s += toNum(m.monto); }); + return s; + }, [total, manuales, sede, programa]); + + const C = { textAlign: "center" }; // todas las columnas centradas + + return ( +
+

🧮 Comisiones

+ + + ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> + +
+ + + +
+
+ + {loading ? : + error ? : +
+ + + {COLS.map((c, i) => )} + + {datos.map((f, i) => ( + + + + + + + + ))} + + {/* Asesores manuales: solo cuando NO hay filtro de sede/programa */} + {sede==="TODOS" && programa==="TODOS" && manualesAgrupados.map((g) => ( + + + + + + + + ))} + + {total && ( + + + + + + + )} + +
{c}
{f[0]}{f[3]}{fmt(f[4])}{f[5]}{fmt(f[6])}{fmt(f[7])} + + +
{g.nombre}{fmt(g.totalMonto)} + + +
TOTAL GENERAL{total[3]}{fmt(total[4])}{total[5]}{fmt(total[6])}{fmt(totalAvance)}
+
} + + {modal && ( + setModal(null)} + onEliminar={() => { setModal(null); cargarManuales(); }} /> + )} + + {modalEdit && ( + setModalEdit(null)} onGuardado={cargarTabla} /> + )} + + {modalEditManual && ( + m.nombre===modalEditManual.nombre)} + onClose={() => setModalEditManual(null)} + onCambio={cargarManuales} /> + )} + + {modalConfig && ( + setModalConfig(false)} onGuardado={cargarTabla} /> + )} + + {modalBuscar && ( + setModalBuscar(false)} onGuardado={cargarTabla} /> + )} + + {modalAdd && ( + setModalAdd(false)} + onGuardado={() => { setModalAdd(false); cargarManuales(); }} /> + )} +
+ ); +} + +// Celda editable con validación según tipo (fecha / dinero / numero / texto) +function CeldaEdit({ tipo, valor, onChange }) { + const baseStyle = { width:"100%", minWidth:90, padding:"5px 6px", border:"1px solid #cbd5e1", + borderRadius:6, fontSize:12, textAlign:"center", boxSizing:"border-box" }; + + // Convierte dd/mm/yyyy → yyyy-mm-dd (para input date) + function aISO(v) { + const s = String(v || "").trim(); + if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0,10); + const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); + if (m) return `${m[3]}-${m[2].padStart(2,"0")}-${m[1].padStart(2,"0")}`; + return ""; + } + // Convierte yyyy-mm-dd → dd/mm/yyyy (para guardar) + function aDMY(iso) { + const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/); + return m ? `${m[3]}/${m[2]}/${m[1]}` : iso; + } + + if (tipo === "fecha") { + return ( + onChange(e.target.value ? aDMY(e.target.value) : "")} + style={baseStyle} /> + ); + } + + if (tipo === "dinero") { + const num = toNum(valor); + const display = (valor === "" || valor === "-" || valor == null) ? "" : "S/ " + num.toLocaleString("es-PE",{maximumFractionDigits:0}); + return ( + { + // Solo dígitos: "5500" → guarda "S/ 5500" + const limpio = e.target.value.replace(/[^\d]/g, ""); + onChange(limpio === "" ? "" : "S/ " + parseInt(limpio,10).toLocaleString("es-PE")); + }} + placeholder="S/ 0" style={baseStyle} /> + ); + } + + if (tipo === "numero") { + return ( + onChange(e.target.value.replace(/[^\d-]/g, ""))} + style={baseStyle} /> + ); + } + + // texto + return onChange(e.target.value)} style={baseStyle} />; +} + +function ModalEditar({ vendedor, ano, mes, sede = "TODOS", programa = "TODOS", onClose, onGuardado }) { + const [tipo, setTipo] = useState(OPCIONES_POPUP[0].value); + const [filas, setFilas] = useState([]); + const [edits, setEdits] = useState({}); // { num_matricula: { idx: valor } } + const [loading, setLoading] = useState(true); + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(null); + const [confirmar, setConfirmar] = useState(null); // matrícula a restaurar + + function cargar() { + setLoading(true); + api.ventasDetalle(vendedor, ano, mes, tipo, sede, programa) + .then((res) => { + const soloAlumnos = (res.filas || []).filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); + setFilas(soloAlumnos); + setEdits({}); + setLoading(false); + }) + .catch(() => { setFilas([]); setLoading(false); }); + } + useEffect(() => { cargar(); }, [vendedor, ano, mes, tipo, sede, programa]); + + // Columnas editables: etiqueta + índice en la fila + clave en tabla override + const CAMPOS = [ + { label: "F. MATRÍCULA", idx: 2, col: "fch_matricula", tipo: "fecha" }, + { label: "INVERSIÓN NETA", idx: 7, col: "inversion_neta", tipo: "dinero" }, + { label: "SALDO MATRÍCULA", idx: 10, col: "saldo_matricula", tipo: "dinero" }, + { label: "SALDO 1° CUOTA", idx: 11, col: "saldo_cuota1", tipo: "dinero" }, + { label: "FECHA INICIO", idx: 9, col: "fecha_inicio", tipo: "fecha" }, + { label: "FECHA CANCELACIÓN 1", idx: 3, col: "fecha_cancelacion1", tipo: "fecha" }, + { label: "PROMEDIO CUOTA", idx: 5, col: "promedio_cuota", tipo: "dinero" }, + { label: "DÍAS ANTICIPACIÓN", idx: 12, col: "dias_anticipacion", tipo: "numero" }, + { label: "VALOR CUOTA ADICIONAL", idx: 13, col: "valor_cuota_adicional", tipo: "dinero" }, + { label: "SEDE", idx: 14, col: "tipo_programa", tipo: "texto" }, + ]; + + function valorActual(f, idx) { + const mat = String(f[15]); + if (edits[mat] && edits[mat][idx] !== undefined) return edits[mat][idx]; + return f[idx] ?? ""; + } + function cambiar(mat, idx, valor) { + setEdits((prev) => ({ ...prev, [mat]: { ...(prev[mat]||{}), [idx]: valor } })); + } + + async function guardar() { + setGuardando(true); setMsg(null); + try { + const registros = Object.keys(edits).map((mat) => { + const r = { num_matricula: mat }; + CAMPOS.forEach((c) => { + const e = edits[mat][c.idx]; + // Campo borrado a propósito → marcador "__VACIO__" (fuerza vacío en el override) + if (e !== undefined) r[c.col] = (String(e).trim() === "") ? "__VACIO__" : String(e); + }); + return r; + }); + if (registros.length === 0) { setMsg({ok:false,txt:"No hay cambios para guardar."}); setGuardando(false); return; } + await api.comisionesOverrideGuardar(registros); + setMsg({ ok:true, txt:"✅ Cambios guardados." }); + cargar(); + if (onGuardado) onGuardado(); // recarga la tabla principal + } catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); } + setGuardando(false); + } + + async function restaurar(mat) { + try { await api.comisionesOverrideRestaurar(mat); cargar(); if (onGuardado) onGuardado(); } + catch (e) { setMsg({ ok:false, txt:"No se pudo restaurar: " + e.message }); } + } + + const th = { background:"#334155", color:"#f1f5f9", padding:8, fontSize:10, fontWeight:600, whiteSpace:"nowrap", textAlign:"center", position:"sticky", top:0, borderRight:"1px solid #475569" }; + + // Columnas redimensionables: ALUMNO + PROGRAMA + CAMPOS + ACCIÓN + const colsEdit = useColumnasAjustables([200, 220, ...CAMPOS.map(()=>130), 80]); + + return ( + +
+ cambiarRow(i,"descripcion",e.target.value)} style={inp} /> + cambiarRow(i,"fch_emision",e.target.value)} style={inp} /> + cambiarRow(i,"monto",e.target.value)} style={inp} /> + + + + + ))} + + +
+ + + + {msg && ( +
{msg.txt}
+ )} +
+ + +
+
+ ); +} + +function ModalConfig({ ano, mes, onClose, onGuardado }) { + const TIPOS = ["TEAC", "TERC", "SEMINARIOS", "OTROS"]; + const [tc, setTc] = useState(""); // IMP_TC único del mes + const [filas, setFilas] = useState([]); // META y COMISIÓN por tipo + const [loading, setLoading] = useState(true); + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(null); + + useEffect(() => { + setLoading(true); + api.comisionesConfig(ano, mes) + .then((res) => { + const guardadas = res.config || []; + // TC: tomar el primero que tenga valor (es único por mes) + const tcGuardado = guardadas.find((x) => +x.imp_tc > 0); + setTc(tcGuardado ? String(tcGuardado.imp_tc) : ""); + const arr = TIPOS.map((t) => { + const g = guardadas.find((x) => x.tipo_programa === t) || {}; + return { tipo_programa: t, meta: g.meta ?? "", comision: g.comision ?? "" }; + }); + setFilas(arr); setLoading(false); + }) + .catch(() => { setTc(""); setFilas(TIPOS.map((t)=>({tipo_programa:t,meta:"",comision:""}))); setLoading(false); }); + }, [ano, mes]); + + function cambiar(i, campo, val) { + setFilas((prev) => prev.map((f, idx) => idx === i ? { ...f, [campo]: val } : f)); + } + + async function guardar() { + setGuardando(true); setMsg(null); + try { + // El mismo TC del mes se guarda en todas las filas (es único por mes) + const payload = filas.map((f) => ({ + tipo_programa: f.tipo_programa, + imp_tc: toNum(tc), meta: toNum(f.meta), comision: toNum(f.comision), + })); + await api.comisionesConfigGuardar({ ano, mes, filas: payload }); + if (onGuardado) onGuardado(); // recarga la tabla principal + onClose(); + } catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); setGuardando(false); } + } + + const inp = { width:"100%", padding:"7px 9px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:13, textAlign:"center", boxSizing:"border-box" }; + const MESES_N = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; + + return ( + +
+ Configuración para {MESES_N[mes-1]} {ano}. +
+ {loading ? : <> + {/* IMP_TC único del mes */} +
+ + setTc(e.target.value)} + style={{...inp,maxWidth:160}} /> + Se usará para convertir dólares este mes. Si lo dejas vacío, usa el TC de la base. +
+ + {/* META y COMISIÓN por tipo de programa */} +
+ + + + + + + + {filas.map((f, i) => ( + + + + + + ))} + +
TIPO PROGRAMAMETA (S/)COMISIÓN (%)
{f.tipo_programa}cambiar(i,"meta",e.target.value)} style={inp} />cambiar(i,"comision",e.target.value)} style={inp} />
+
+ } + {msg && ( +
{msg.txt}
+ )} +
+ + +
+
+ ); +} + +function ModalBuscar({ ano, mes, onClose, onGuardado }) { + const [todos, setTodos] = useState([]); + const [loading, setLoading] = useState(true); + const [q, setQ] = useState(""); // lo que se escribe + const [qAplicado, setQAplicado] = useState(""); // lo que se busca (con debounce) + const [modo, setModo] = useState("VER"); // VER | EDITAR + const [edits, setEdits] = useState({}); // { num_matricula: { idx: valor } } + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(null); + const [confirmar, setConfirmar] = useState(null); // matrícula a restaurar + + function cargar() { + setLoading(true); + api.comisionesDetalleTodos(ano, mes) + .then((res) => { setTodos(res.filas || []); setLoading(false); }) + .catch(() => { setTodos([]); setLoading(false); }); + } + useEffect(() => { cargar(); }, [ano, mes]); + + async function restaurar(mat) { + try { + await api.comisionesOverrideRestaurar(mat); + setEdits((p) => { const c = {...p}; delete c[mat]; return c; }); + cargar(); + if (onGuardado) onGuardado(); // recarga la tabla principal + setMsg({ ok:true, txt:"✅ Alumno restaurado a sus valores originales." }); + } catch (e) { setMsg({ ok:false, txt:"No se pudo restaurar: " + e.message }); } + } + + // Debounce: aplica la búsqueda 250ms después de dejar de escribir (evita lag) + useEffect(() => { + const t = setTimeout(() => setQAplicado(q.trim().toLowerCase()), 250); + return () => clearTimeout(t); + }, [q]); + + // Filtrado en memoria, limitado a 100 resultados para no congelar el render + const resultados = useMemo(() => { + if (!qAplicado) return []; + const out = []; + for (let i = 0; i < todos.length && out.length < 100; i++) { + const f = todos[i]; + if (String(f[1]).toLowerCase().includes(qAplicado) || + String(f[8]).toLowerCase().includes(qAplicado) || + String(f[0]).toLowerCase().includes(qAplicado)) { + out.push(f); + } + } + return out; + }, [qAplicado, todos]); + + const HEADERS = ["VENDEDOR","ALUMNO","PROGRAMA","F. MATRÍCULA","INVERSIÓN NETA","SALDO MATRÍCULA","SALDO 1° CUOTA","FECHA INICIO","FECHA CANCELACIÓN 1","PROMEDIO CUOTA","DÍAS ANTICIPACIÓN","VALOR CUOTA ADICIONAL","SEDE"]; + const ORDEN = [0, 1, 8, 2, 7, 10, 11, 9, 3, 5, 12, 13, 14]; + const COL_MONTO = new Set([4, 5, 6, 9, 11]); + // Campos editables (mismos que el Editar normal): idx de fila → tipo + const EDITABLES = { 2:"fecha", 7:"dinero", 10:"dinero", 11:"dinero", 9:"fecha", 3:"fecha", 5:"dinero", 12:"numero", 13:"dinero", 14:"texto" }; + + // Anchos iniciales por columna (VENDEDOR/ALUMNO/PROGRAMA más anchas); +ACCIÓN en modo EDITAR + const anchosBuscar = [180, 220, 240, ...Array(HEADERS.length-3).fill(130), ...(modo==="EDITAR" ? [80] : [])]; + const colsBuscar = useColumnasAjustables(anchosBuscar); + + function setEdit(mat, idx, val) { + setEdits((p) => ({ ...p, [mat]: { ...(p[mat]||{}), [idx]: val } })); + } + + async function guardar() { + setGuardando(true); setMsg(null); + try { + const registros = Object.entries(edits).map(([mat, campos]) => { + const r = { num_matricula: mat }; + Object.entries(campos).forEach(([idx, val]) => { + const map = {2:"fch_matricula",7:"inversion_neta",10:"saldo_matricula",11:"saldo_cuota1",9:"fecha_inicio",3:"fecha_cancelacion1",5:"promedio_cuota",12:"dias_anticipacion",13:"valor_cuota_adicional",14:"tipo_programa"}; + // Campo borrado a propósito → marcador "__VACIO__" + if (map[idx]) r[map[idx]] = (String(val).trim() === "") ? "__VACIO__" : val; + }); + return r; + }); + await api.comisionesOverrideGuardar(registros); + setEdits({}); + if (onGuardado) onGuardado(); + setMsg({ ok:true, txt:"✅ Cambios guardados." }); + setGuardando(false); + } catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); setGuardando(false); } + } + + return ( + +
+ setQ(e.target.value)} + placeholder="Escribe nombre de alumno, programa o vendedor..." + style={{flex:1,padding:"11px 14px",border:"1px solid #cbd5e1",borderRadius:10,fontSize:14,boxSizing:"border-box"}} /> + {/* Switch VER / EDITAR */} +
+ {["VER","EDITAR"].map((m) => ( + + ))} +
+
+ + {loading ? : + !qAplicado ?
Escribe para buscar.
: + resultados.length === 0 ?
Sin coincidencias.
: +
+
+ {resultados.length}{resultados.length===100?"+":""} resultado(s) {modo==="EDITAR" && "— modo edición"} +
+ + + + {HEADERS.map((h,hi)=>)} + {modo==="EDITAR" && } + + + {resultados.map((f, i) => { + const mat = f[15]; + return ( + + {ORDEN.map((idx, pos) => { + const esTexto = pos===0||pos===1||pos===2; + const editable = modo==="EDITAR" && EDITABLES[idx] && idx!==0 && idx!==1 && idx!==8; + const valActual = (edits[mat] && edits[mat][idx] !== undefined) ? edits[mat][idx] : (f[idx] ?? ""); + return ( + + ); + })} + {modo==="EDITAR" && ( + + )} + + );})} + +
{h}ACCIÓN
+ {editable + ? setEdit(mat, idx, v)} /> + : (COL_MONTO.has(pos) ? fmt(f[idx]) : (f[idx] ?? ""))} + + +
+
} + + {msg && ( +
{msg.txt}
+ )} + {modo==="EDITAR" && ( +
+ +
+ )} + + {confirmar && ( + setConfirmar(null)} + onConfirmar={async () => { await restaurar(confirmar); setConfirmar(null); }} + /> + )} +
+ ); +} + +function ModalAgregar({ ano, mes, onClose, onGuardado }) { + const [nombre, setNombre] = useState(""); + const [descripcion, setDescripcion] = useState(""); + const [fchEmision, setFchEmision] = useState(""); + const [monto, setMonto] = useState(""); + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(null); + + async function guardar() { + if (!nombre.trim()) { setMsg("El nombre es obligatorio."); return; } + setGuardando(true); setMsg(null); + try { + await api.vendedoresManualesCrear({ + nombre: nombre.trim(), descripcion: descripcion.trim(), + fch_emision: fchEmision, monto: toNum(monto), ano, mes, + }); + onGuardado(); + } catch (e) { + setMsg("Error al guardar: " + e.message); + setGuardando(false); + } + } + + const inp = { width:"100%", padding:"9px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, boxSizing:"border-box" }; + + return ( + +
+
+ + setNombre(e.target.value)} style={inp} placeholder="Nombre del vendedor" autoFocus /> +
+
+ + setDescripcion(e.target.value)} style={inp} placeholder="Descripción" /> +
+
+ + setFchEmision(e.target.value)} style={inp} /> +
+
+ + setMonto(e.target.value)} style={inp} placeholder="0" /> +
+ {msg &&
{msg}
} +
+ + +
+
+
+ ); +} + +const lbl = { fontSize:12, fontWeight:600, color:"#475569", display:"block", marginBottom:5 }; + +function ModalDetalle({ vendedor, manual, ano, mes, sede = "TODOS", programa = "TODOS", onClose, onEliminar }) { + const [tipo, setTipo] = useState(OPCIONES_POPUP[0].value); + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + const [eliminando, setEliminando] = useState(false); + + useEffect(() => { + if (manual) { setLoading(false); return; } // manual no consulta detalle + let activo = true; + setLoading(true); + api.ventasDetalle(vendedor, ano, mes, tipo, sede, programa) + .then((res) => { + if (!activo) return; + const soloAlumnos = (res.filas || []).filter( + (f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL" && fechaMatDesde2025(f[2]) + ); + setFilas(soloAlumnos); + setLoading(false); + }) + .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); + return () => { activo = false; }; + }, [vendedor, ano, mes, tipo, manual, sede, programa]); + + async function eliminar() { + if (!confirm(`¿Eliminar al vendedor "${manual.nombre}"?`)) return; + setEliminando(true); + try { await api.vendedoresManualesEliminar(manual.id); onEliminar(); } + catch (e) { alert("No se pudo eliminar: " + e.message); setEliminando(false); } + } + + // ── Vista para vendedor MANUAL ── + if (manual) { + const filasM = manual.filas || [manual]; + const totalM = filasM.reduce((s,r)=>s+toNum(r.monto),0); + return ( + +
+ + + + + + + + + {filasM.map((r) => ( + + + + + + + ))} + + + + + + +
VENDEDORDESCRIPCIÓNFECHA EMISIÓNMONTO
{r.nombre}{r.descripcion || "—"}{r.fch_emision || "—"}{fmt(r.monto)}
TOTAL GENERAL{fmt(totalM)}
+
+
+ +
+
+ ); + } + + // Orden solicitado de columnas + const HEADERS = ["VENDEDOR","ALUMNO","PROGRAMA","F. MATRÍCULA","INVERSIÓN NETA","SALDO MATRÍCULA","SALDO 1° CUOTA","FECHA INICIO","FECHA CANCELACIÓN 1","PROMEDIO CUOTA","DÍAS ANTICIPACIÓN","VALOR CUOTA ADICIONAL","SEDE"]; + const ORDEN = [0, 1, 8, 2, 7, 10, 11, 9, 3, 5, 12, 13, 14]; + const COL_MONTO = new Set([4, 5, 6, 9, 11]); + + // Anchos iniciales por columna (px) — el usuario los puede arrastrar + const ANCHOS_INI = [180, 240, 280, 110, 130, 130, 130, 110, 140, 120, 120, 150, 130]; + const [anchos, setAnchos] = useState(ANCHOS_INI); + + function iniciarResize(e, i) { + e.preventDefault(); + const xInicial = e.clientX; + const anchoInicial = anchos[i]; + function onMove(ev) { + const nuevo = Math.max(60, anchoInicial + (ev.clientX - xInicial)); + setAnchos((prev) => { const c = [...prev]; c[i] = nuevo; return c; }); + } + function onUp() { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + } + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + } + + const totalInv = useMemo(() => filas.reduce((s,f)=>s+toNum(f[7]),0), [filas]); + + return ( + +
+ setEmail(e.target.value)} + placeholder="usuario@escuela.com" required autoFocus + style={inputStyle} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" required + style={inputStyle} + /> +
+ + {error && ( +
⚠️ {error}
+ )} + + + +
+
+ ); +} + +const inputStyle = { + width: "100%", padding: "11px 14px", border: "1px solid #cbd5e1", borderRadius: 10, + fontSize: 14, outline: "none", boxSizing: "border-box", +}; diff --git a/frontend/src/pages/Ocupabilidad.jsx b/frontend/src/pages/Ocupabilidad.jsx new file mode 100644 index 0000000..ff3d98a --- /dev/null +++ b/frontend/src/pages/Ocupabilidad.jsx @@ -0,0 +1,194 @@ +// src/pages/Ocupabilidad.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; +const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; +const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; + +const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD"]; + +function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; } + +export default function Ocupabilidad() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [mes, setMes] = useState(hoy.getMonth() + 1); + const [sede, setSede] = useState("TODOS"); + const [programa, setPrograma] = useState("TODOS"); + const [mostrarRepro, setMostrarRepro] = useState(true); + const [datos, setDatos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140]); + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.ocupabilidad(ano, mes, sede, programa) + .then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, sede, programa]); + + // Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio + const datosVisibles = useMemo(() => { + if (mostrarRepro) return datos; + return datos.filter((d) => String(d.dias_para_inicio).trim().toUpperCase() !== "REPROGRAMADO"); + }, [datos, mostrarRepro]); + + const kpis = useMemo(() => { + if (!datosVisibles.length) return null; + const esRepro = (d) => String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO"; + // Las filas REPROGRAMADO (inicios) no entran en las métricas (igual que escritorio) + const validos = datosVisibles.filter((d) => !esRepro(d)); + const insc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Activos) || 0), 0); + const totalInsc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Totales) || 0), 0); + const retirados = validos.reduce((s, d) => s + (parseInt(d.Retirados ?? d.Inscritos_Retirados) || 0), 0); + const meta = validos.reduce((s, d) => s + (parseInt(d.Meta_Curso) || 0), 0); + return { + cursos: validos.length, // solo programados (sin reprogramados) + reprogramados: datosVisibles.filter(esRepro).length, // inicios rojos (REPROGRAMADO) + inscritos: totalInsc, // columna TOTAL INSCRITOS + meta, + avance: meta > 0 ? (totalInsc / meta * 100) : 0, + desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0, + sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length, + pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0), + }; + }, [datosVisibles]); + + const totales = useMemo(() => { + const t = { mes:0, total:0, pc:0, retirados:0, activos:0, refri:0, cont:0, meta:0 }; + datosVisibles.forEach((d) => { + t.mes += parseInt(d.Inscritos_Mes)||0; + t.total += parseInt(d.Inscritos_Totales)||0; + t.pc += parseInt(d.Inscritos_PC)||0; + t.retirados += parseInt(d.Retirados ?? d.Inscritos_Retirados)||0; + t.activos += parseInt(d.Inscritos_Activos)||0; + t.refri += parseInt(d.Descuento)||0; + t.cont += parseInt(d.Inscritos_Continuidad)||0; + t.meta += parseInt(d.Meta_Curso)||0; + }); + return t; + }, [datosVisibles]); + + const KPI_CARDS = kpis ? [ + ["📚","Cursos Activos", kpis.cursos, "programas este mes"], + ["🔁","Cursos Reprogramados", kpis.reprogramados, "inicios reprogramados"], + ["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"], + ["🎯","Meta Total", kpis.meta, "inscritos objetivo"], + ["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"], + ["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, "retirados / total inscritos"], + ] : []; + + async function exportarExcel() { + if (!datosVisibles.length) return; + // Cargar SheetJS desde CDN solo cuando se necesita + const XLSX = await import("xlsx"); + const filas = datosVisibles.map((d) => ({ + "PROGRAMA": d.programa_frecuencia || d.dsc_programa || "", + "FECHA INICIO": d.fch_inicio || "", + "DÍAS PARA INICIO": d.dias_para_inicio ?? "", + "TOTAL INSCRITOS": parseInt(d.Inscritos_Totales)||0, + "RETIRADOS": parseInt(d.Retirados ?? d.Inscritos_Retirados)||0, + "INSCRITOS EN CURSO": parseInt(d.Inscritos_Activos)||0, + "META INSCRITOS": parseInt(d.Meta_Curso)||0, + "AVANCE INSCRITOS": d.Avance_Inscritos || "", + "INSCRITOS MES": parseInt(d.Inscritos_Mes)||0, + "INSCRITOS P.C": parseInt(d.Inscritos_PC)||0, + "INSCRITOS REFRIPERU": parseInt(d.Descuento)||0, + "INSCRITOS CONTINUIDAD": parseInt(d.Inscritos_Continuidad)||0, + })); + const ws = XLSX.utils.json_to_sheet(filas); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad"); + XLSX.writeFile(wb, `cursos_${ano}_${mes}.xlsx`); + } + + return ( +
+

📊 Ocupabilidad

+ + + ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> + +
+ + +
+ +
+ + {loading ? : + error ? : + <> +
+ {KPI_CARDS.map(([ico,label,val,sub]) => ( +
+
{ico}
+
{label}
+
{val}
+
{sub}
+
+ ))} +
+ +
+ + + {COLS.map((c, i) => )} + + {datosVisibles.map((d, i) => { + const prog = d.programa_frecuencia || d.dsc_programa || ""; + const dias = parseInt(d.dias_para_inicio); + const pct = toNum(d.Avance_Inscritos); + // Inicio reprogramado (existe en Supabase pero no en SQL este mes) → fila roja + const esInicio = String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO"; + const cel = (v) => esInicio ? "-" : v; + return ( + + + + + + + + + + + + + + + ); + })} + {datosVisibles.length > 0 && ( + + + + + + + + + + + )} + +
{c}
{prog}{d.fch_inicio || ""}{d.dias_para_inicio ?? ""}{cel(parseInt(d.Inscritos_Totales)||0)}{cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)}{cel(parseInt(d.Inscritos_Activos)||0)}{cel(parseInt(d.Meta_Curso)||0)}{esInicio ? "-" : }{cel(parseInt(d.Inscritos_Mes)||0)}{cel(parseInt(d.Inscritos_PC)||0)}{cel(parseInt(d.Descuento)||0)}{cel(parseInt(d.Inscritos_Continuidad)||0)}
TOTAL GENERAL{totales.total}{totales.retirados}{totales.activos}{totales.meta}0 ? totales.total/totales.meta*100 : 0} />{totales.mes}{totales.pc}{totales.refri}{totales.cont}
+
+ } +
+ ); +} diff --git a/frontend/src/pages/Rentabilidad.jsx b/frontend/src/pages/Rentabilidad.jsx new file mode 100644 index 0000000..da70a05 --- /dev/null +++ b/frontend/src/pages/Rentabilidad.jsx @@ -0,0 +1,270 @@ +// src/pages/Rentabilidad.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; +import Modal from "../components/Modal"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; +const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"]; +const PROGRAMAS = ["TODOS","SEMINARIOS","OTROS","TEAC","TERC"]; + +const COLS = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."]; +// índices: inicial = 7,8,9 | actual = 10,11,12 +const BG_INI = "#eef4ff", BG_ACT = "#edfff6"; + +function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; } + +export default function Rentabilidad() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [mes, setMes] = useState(hoy.getMonth() + 1); + const [sede, setSede] = useState("TODOS"); + const [programa, setPrograma] = useState("TODOS"); + const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalVer, setModalVer] = useState(null); + // Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado + const IDX_INSCRITOS = [2, 3, 4]; + const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i); + const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80]; + const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i)); + const cols = useColumnasAjustables(anchosVisibles); + const [modalCostos, setModalCostos] = useState(null); + + function cargar() { + setLoading(true); setError(null); + api.rentabilidad(ano, mes, sede, programa) + .then((res) => { setFilas(res.filas || []); setLoading(false); }) + .catch((e) => { setError(e.message); setLoading(false); }); + } + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.rentabilidad(ano, mes, sede, programa) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, sede, programa]); + + const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]); + const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]); + + const kpis = useMemo(() => { + if (!total) return null; + return { + programas: datos.length, + enCurso: total[4], + ventaAct: total[10], + costoAct: total[11], + mbAct: total[12], + }; + }, [datos, total]); + + function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; } + + return ( +
+

📈 Rentabilidad

+ + + ({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} /> + +
+ + +
+
+ + {loading ? : + error ? : + <> + {kpis && ( +
+ {[["📚","Programas",kpis.programas,"este mes"], + ["🎓","En Curso",kpis.enCurso,"alumnos activos"], + ["💵","Venta Actual",kpis.ventaAct,"ingreso total"], + ["💸","Costo Actual",kpis.costoAct,"gasto total"], + ["📊","M.B. Actual",kpis.mbAct,"margen bruto"]].map(([ico,label,val,sub])=>( +
+
{ico}
{label}
+
{val}
{sub}
+
+ ))} +
+ )} + +
+ + + {COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=>)} + + {datos.map((f, ri) => ( + + {f.slice(0,13).map((v,ci)=>{ + if (ocultar(ci)) return null; + const isPct = ci===9||ci===12; + return ( + + ); + })} + + + ))} + {total && ( + + {total.slice(0,13).map((v,ci)=>( + ocultar(ci) ? null : + + ))} + + + )} + +
{c}
{v} + + +
{v}
+
+ } + + {modalVer && setModalVer(null)} />} + {modalCostos && setModalCostos(null)} onGuardado={cargar} />} +
+ ); +} + +function ModalVerDetalle({ programa, ano, mes, onClose }) { + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + useEffect(() => { + let activo = true; setLoading(true); + api.rentabilidadDetalle(programa, ano, mes) + .then((res)=>{ if(activo){setFilas(res.filas||[]);setLoading(false);} }) + .catch(()=>{ if(activo){setFilas([]);setLoading(false);} }); + return ()=>{activo=false;}; + }, [programa, ano, mes]); + + const COLS_DET = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","VALOR CUOTA","PROM. DESC. E.","VALOR VENTA"]; + + return ( + + {loading ? : + filas.length===0 ?
Sin alumnos.
: +
+ + {COLS_DET.map((c)=>)} + + {filas.map((f,i)=>( + + {f.map((v,j)=>( + + ))} + + ))} + +
{c}
=5?"right":(j===1?"left":"center")}}>{v}
+
} +
+ ); +} + +function ModalCostos({ programa, ano, mes, onClose, onGuardado }) { + const [costos, setCostos] = useState(null); + const [loading, setLoading] = useState(true); + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(null); + // [keyGuardar, etiqueta, prefijoBackend] + const CAMPOS = [["epp","COSTO EPP","epp"],["certificado","COSTO CERTIFICADO","cert"],["docente","COSTO DOCENTE","doc"],["marketing","COSTO MARKETING","mkt"],["consumibles","COSTO CONSUMIBLES","cons"]]; + + const [ini, setIni] = useState({}); + const [act, setAct] = useState({}); + + useEffect(() => { + let activo = true; setLoading(true); + api.rentabilidadCostos(programa, ano, mes) + .then((res)=>{ + if(!activo) return; + const c = res.costos || {}; + setCostos(c); + const gi={}, ga={}; + CAMPOS.forEach(([k,,bp])=>{ gi[k]=+(c[`${bp}_inicial`]||0); ga[k]=+(c[`${bp}_actual`]||0); }); + setIni(gi); setAct(ga); setLoading(false); + }) + .catch(()=>{ if(activo){setCostos(null);setLoading(false);} }); + return ()=>{activo=false;}; + }, [programa, ano, mes]); + + const totIni = Object.values(ini).reduce((a,b)=>a+(+b||0),0); + const totAct = Object.values(act).reduce((a,b)=>a+(+b||0),0); + + async function guardar() { + setGuardando(true); setMsg(null); + try { + await api.guardarCostos(costos.num_indice, ini, act); + if (onGuardado) onGuardado(); // recarga la tabla principal + onClose(); // cierra el modal + } catch (e) { + setMsg({ ok:false, txt:"❌ Error al guardar. Verifica Supabase." }); + setGuardando(false); + } + } + + return ( + + {loading ? : + !costos ?
Sin datos de costos.
: + <> +
+ + +
+ {msg &&
{msg.txt}
} +
+ + +
+ } +
+ ); +} + +function Columna({ titulo, color, campos, valores, setVal, total }) { + return ( +
+
{titulo}
+ {campos.map(([k,label])=>( +
+ + setVal({...valores,[k]:+e.target.value})} + style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} /> +
+ ))} +
+ TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})} +
+
+ ); +} diff --git a/frontend/src/pages/SaldoPendiente.jsx b/frontend/src/pages/SaldoPendiente.jsx new file mode 100644 index 0000000..62ac521 --- /dev/null +++ b/frontend/src/pages/SaldoPendiente.jsx @@ -0,0 +1,96 @@ +// src/pages/SaldoPendiente.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; + +const CUOTAS = ["1° Cuota","2° Cuota","3° Cuota","4° Cuota","5° Cuota"]; + +function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; } +function fmt(v){ const n=toNum(v); return "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); } + +export default function SaldoPendiente() { + const [tipoCuota, setTipoCuota] = useState("1° Cuota"); + const [datos, setDatos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [vendedor, setVendedor] = useState("TODOS"); + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.saldoPendiente(tipoCuota) + .then((res)=>{ if(activo){setDatos(res.datos||[]);setLoading(false);} }) + .catch((e)=>{ if(activo){setError(e.message);setLoading(false);} }); + return ()=>{activo=false;}; + }, [tipoCuota]); + + const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`; + const vendedores = useMemo(() => { + const set = new Set(datos.map((d)=>String(d.VENDEDOR||"").trim()).filter(Boolean)); + return ["TODOS", ...Array.from(set).sort()]; + }, [datos]); + + const filtrados = useMemo(() => { + let arr = datos.filter((d)=>toNum(d[colSaldo])>0.01); + if (vendedor!=="TODOS") arr = arr.filter((d)=>String(d.VENDEDOR||"").trim().toUpperCase()===vendedor.toUpperCase()); + return arr; + }, [datos, vendedor, colSaldo]); + + const totalSaldo = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d[colSaldo]),0),[filtrados,colSaldo]); + const totalMat = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d["SALDO MAT."]),0),[filtrados]); + + const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; + const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; + + return ( +
+

⏳ Saldo Pendiente

+ + + + + + {loading ? : + error ? : + <> +
+
👥
Alumnos con Saldo
{filtrados.length}
+
Saldo {tipoCuota}
{fmt(totalSaldo)}
+
📋
Saldo Matrícula
{fmt(totalMat)}
+
+ +
+ + {COLS.map((c)=>)} + + {filtrados.map((d,i)=>( + + {KEYS.map((k,j)=>{ + const isMonto = k.includes("SALDO")||k.includes("INV"); + return ( + + ); + })} + + ))} + {filtrados.length>0 && ( + + + + + + + )} + +
{c}
+ {isMonto ? fmt(d[k]) : (d[k] ?? "")} +
TOTAL GENERAL ({filtrados.length}){fmt(totalMat)}{fmt(totalSaldo)}
+
+ } +
+ ); +} diff --git a/frontend/src/pages/Usuarios.jsx b/frontend/src/pages/Usuarios.jsx new file mode 100644 index 0000000..e4da63d --- /dev/null +++ b/frontend/src/pages/Usuarios.jsx @@ -0,0 +1,130 @@ +// src/pages/Usuarios.jsx +import { useState, useEffect, useCallback } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; + +const ROLES = ["ADMINISTRADOR", "VENTAS", "COBRANZA"]; + +export default function Usuarios() { + const [usuarios, setUsuarios] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // form nuevo usuario + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [nombre, setNombre] = useState(""); + const [rol, setRol] = useState("COBRANZA"); + const [creando, setCreando] = useState(false); + const [msg, setMsg] = useState(null); + + const cargar = useCallback(() => { + setLoading(true); setError(null); + api.usuariosListar() + .then((res) => { setUsuarios(res.usuarios || []); setLoading(false); }) + .catch((e) => { setError(e.message); setLoading(false); }); + }, []); + + useEffect(() => { cargar(); }, [cargar]); + + async function crear() { + setMsg(null); + if (!email.trim() || !password.trim()) { setMsg({ ok:false, txt:"Correo y contraseña son obligatorios." }); return; } + setCreando(true); + try { + await api.usuariosCrear(email.trim(), password, nombre.trim(), rol); + setMsg({ ok:true, txt:`Usuario ${email} creado como ${rol}.` }); + setEmail(""); setPassword(""); setNombre(""); setRol("COBRANZA"); + cargar(); + } catch (e) { + setMsg({ ok:false, txt:"Error: " + e.message }); + } + setCreando(false); + } + + async function cambiarRol(id, nuevoRol) { + try { await api.usuariosActualizarRol(id, nuevoRol); cargar(); } + catch (e) { alert("No se pudo cambiar el rol: " + e.message); } + } + + async function eliminar(id, correo) { + if (!confirm(`¿Eliminar al usuario ${correo}? Esta acción no se puede deshacer.`)) return; + try { await api.usuariosEliminar(id); cargar(); } + catch (e) { alert("No se pudo eliminar: " + e.message); } + } + + return ( +
+

🔐 Usuarios

+ + {/* Crear nuevo usuario */} +
+

➕ Crear nuevo usuario

+
+ + setEmail(e.target.value)} placeholder="usuario@escuela.com" style={inp} /> + + + setPassword(e.target.value)} placeholder="mín. 6 caracteres" style={inp} /> + + + setNombre(e.target.value)} placeholder="Nombre completo" style={inp} /> + + + cambiarRol(u.id, e.target.value)} + style={{ padding:"5px 8px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12 }}> + {ROLES.map((r)=>)} + + + + + + + ))} + {usuarios.length === 0 && ( + Sin usuarios. + )} + + +
} +
+ ); +} + +function Campo({ label, children }) { + return ( +
+ + {children} +
+ ); +} + +const inp = { padding:"8px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, minWidth:180 }; diff --git a/frontend/src/pages/Ventas.jsx b/frontend/src/pages/Ventas.jsx new file mode 100644 index 0000000..4f7696a --- /dev/null +++ b/frontend/src/pages/Ventas.jsx @@ -0,0 +1,266 @@ +// src/pages/Ventas.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; +import Modal from "../components/Modal"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; +const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; +const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; +const COLS = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."]; +const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"]; +const HEADERS_DETALLE = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"]; + +export default function Ventas() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [mes, setMes] = useState(hoy.getMonth() + 1); + const [sede, setSede] = useState("TODOS"); + const [programa, setPrograma] = useState("TODOS"); + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modal, setModal] = useState(null); // { vendedor } + const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]); + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.ventas(ano, mes, sede, programa) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, sede, programa]); + + const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]); + const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]); + + const KPIS = total ? [ + ["👤","Vendedores", datos.length, "activos este mes"], + ["🎓","Total Inscritos", total[1], "matriculados"], + ["💵","Venta Total", total[2], "inversión neta"], + ["✅","Venta P.C del Mes", total[4], "pagos completos"], + ["🚀","Avance P.C Total", total[7], "P.C + Pendientes"], + ] : []; + + // Exportar saldos por cuota — Excel con formato (anchos, encabezado de color, + // fechas reales, montos numéricos en soles y fila TOTAL GENERAL). + async function exportarCuota(tipoCuota) { + const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }; + // Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original + const toDate = (v) => { + if (!v) return ""; + const s = String(v).trim().replace(/\//g,"-"); + const p = s.split("-"); + if (p.length === 3) { + let [d,m,y] = p; + if (y.length === 4) return new Date(+y, +m-1, +d); + } + return String(v); + }; + + let res; + try { + res = await api.saldoPendiente(tipoCuota); + } catch (e) { + alert("No se pudo obtener los datos de saldos: " + e.message); + return; + } + const datosS = res.datos || []; + const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`; + const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01); + if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; } + + const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; + const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; + const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"]; + const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"]; + + const XLSX = await import("xlsx-js-style"); + + // Construir matriz de celdas (AOA) con tipos correctos + const headerStyle = { + font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 }, + fill: { fgColor: { rgb: "1E40AF" } }, + alignment: { horizontal: "center", vertical: "center" }, + }; + const moneyFmt = '"S/" #,##0'; + const dateFmt = "dd/mm/yyyy"; + + const ws = {}; + const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } }; + + // Encabezado + COLS.forEach((h, c) => { + const ref = XLSX.utils.encode_cell({ r: 0, c }); + ws[ref] = { v: h, t: "s", s: headerStyle }; + }); + + // Filas de datos + filtrados.forEach((d, ri) => { + KEYS.forEach((k, c) => { + const ref = XLSX.utils.encode_cell({ r: ri + 1, c }); + const col = COLS[c]; + if (COL_MONTO.includes(col)) { + ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt }; + } else if (COL_FECHA.includes(col)) { + const dt = toDate(d[k]); + if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt }; + else ws[ref] = { v: dt, t: "s" }; + } else { + ws[ref] = { v: d[k] ?? "", t: "s" }; + } + }); + }); + + // Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA + const rTot = filtrados.length + 1; + const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0); + const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0; + const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } }; + COLS.forEach((col, c) => { + const ref = XLSX.utils.encode_cell({ r: rTot, c }); + if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle }; + else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle }; + else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle }; + else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle }; + else ws[ref] = { v: "", t: "s", s: totalStyle }; + }); + + ws["!ref"] = XLSX.utils.encode_range(range); + ws["!cols"] = [ + { wch: 12 }, // MATRÍCULA + { wch: 22 }, // VENDEDOR + { wch: 28 }, // ALUMNO + { wch: 32 }, // PROGRAMA + { wch: 12 }, // F. INICIO + { wch: 12 }, // F. MATR. + { wch: 13 }, // SALDO MAT. + { wch: 15 }, // SALDO CUOTA + { wch: 13 }, // VENCIMIENTO + { wch: 13 }, // INV. NETA + ]; + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Saldos"); + const nombre = tipoCuota.replace("°","").replace(/\s/g,"_"); + XLSX.writeFile(wb, `saldos_${nombre}.xlsx`); + } + + return ( +
+

🛒 Ventas

+ + + ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> + +
+ + +
+
+ + {loading ? : + error ? : + <> +
+ {KPIS.map(([ico,label,val,sub]) => ( +
+
{ico}
+
{label}
+
{val}
+
{sub}
+
+ ))} +
+ +
+ + + {COLS.map((c, i) => )} + + {datos.map((f, i) => ( + + + + + + + + + ))} + {total && ( + + + + + + + + )} + +
{c}
{f[0]}{f[1]}{f[2]}{f[3]}{f[4]}{f[5]}{f[6]}{f[7]} + +
TOTAL GENERAL{total[1]}{total[2]}{total[3]}{total[4]}{total[5]}{total[6]}{total[7]}
+
+ } + + {modal && ( + setModal(null)} /> + )} +
+ ); +} + +function ModalDetalle({ vendedor, ano, mes, onClose }) { + const [tipo, setTipo] = useState(TIPOS[0]); + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let activo = true; + setLoading(true); + api.ventasDetalle(vendedor, ano, mes, tipo) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); + return () => { activo = false; }; + }, [vendedor, ano, mes, tipo]); + + // Columnas redimensionables (igual que las tablas principales) + const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]); + + return ( + +
+ onChange(e.target.value)}> + {options.map((o) => { + const val = typeof o === "object" ? o.value : o; + const txt = typeof o === "object" ? o.label : o; + return ; + })} + +
+ ); +} diff --git a/src/lib/api.js b/src/lib/api.js new file mode 100644 index 0000000..958d367 --- /dev/null +++ b/src/lib/api.js @@ -0,0 +1,73 @@ +// src/lib/api.js +// Cliente para el backend FastAPI. Cambia BASE_URL si el backend corre en otra IP. +const BASE_URL = "http://localhost:8000"; + +async function get(path, params = {}) { + const qs = new URLSearchParams(params).toString(); + const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`; + const r = await fetch(url); + if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); + return r.json(); +} + +async function postJson(path, body) { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); + return r.json(); +} + +async function post(path, params = {}) { + const qs = new URLSearchParams(params).toString(); + const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`; + const r = await fetch(url, { method: "POST" }); + if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); + return r.json(); +} + +export const api = { + periodoActual: () => get("/api/periodo-actual"), + ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => + get("/api/ocupabilidad", { ano, mes, sede, programa }), + ventas: (ano, mes, sede = "TODOS", programa = "TODOS") => get("/api/ventas", { ano, mes, sede, programa }), + ventasDetalle: (vendedor, ano, mes, tipo, sede = "TODOS", programa = "TODOS") => + get("/api/ventas/detalle", { vendedor, ano, mes, tipo, sede, programa }), + cobranza: (ano, mes, sectorista = "TODOS", agrupacion = "SEDE") => + get("/api/cobranza", { ano, mes, sectorista, agrupacion }), + cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA") => + get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion }), + rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => + get("/api/rentabilidad", { ano, mes, sede, programa }), + rentabilidadDetalle: (programa, ano, mes) => + get("/api/rentabilidad/detalle", { programa, ano, mes }), + rentabilidadCostos: (programa, ano, mes) => + get("/api/rentabilidad/costos", { programa, ano, mes }), + saldoPendiente: (tipo_cuota) => get("/api/saldo-pendiente", { tipo_cuota }), + asesores: () => get("/api/asesores"), + asesorEstado: (agent_id, online) => + post("/api/asesores/estado", { agent_id, online }), + guardarCostos: (num_indice, inicial, actual) => + postJson("/api/rentabilidad/costos/guardar", { num_indice, inicial, actual }), + clasificarSede: (programas) => + postJson("/api/cobranza/clasificar-sede", { programas }), + cobranzaDetalleTodos: (ano, mes, sectorista = "TODOS") => + get("/api/cobranza/detalle-todos", { ano, mes, sectorista }), + usuariosListar: () => get("/api/usuarios"), + usuariosCrear: (email, password, nombre, rol) => + postJson("/api/usuarios/crear", { email, password, nombre, rol }), + usuariosActualizarRol: (id, rol) => postJson("/api/usuarios/rol", { id, rol }), + usuariosEliminar: (id) => postJson("/api/usuarios/eliminar", { id }), + vendedoresManualesListar: (ano, mes) => get("/api/comisiones/vendedores", { ano, mes }), + vendedoresManualesCrear: (datos) => postJson("/api/comisiones/vendedores/crear", datos), + vendedoresManualesEliminar: (id) => postJson("/api/comisiones/vendedores/eliminar", { id }), + vendedoresManualesActualizar: (datos) => postJson("/api/comisiones/vendedores/actualizar", datos), + vendedoresManualesLote: (datos) => postJson("/api/comisiones/vendedores/lote", datos), + comisionesOverrideGuardar: (registros) => postJson("/api/comisiones/override/guardar", { registros }), + comisionesOverrideRestaurar: (num_matricula) => postJson("/api/comisiones/override/restaurar", { num_matricula }), + comisionesDetalleTodos: (ano, mes) => get("/api/comisiones/detalle-todos", { ano, mes }), + comisionesConfig: (ano, mes) => get("/api/comisiones/config", { ano, mes }), + comisionesConfigGuardar: (datos) => postJson("/api/comisiones/config/guardar", datos), +}; diff --git a/src/lib/auth.jsx b/src/lib/auth.jsx new file mode 100644 index 0000000..e218452 --- /dev/null +++ b/src/lib/auth.jsx @@ -0,0 +1,106 @@ +// src/lib/auth.jsx +import { createContext, useContext, useEffect, useState, useCallback } from "react"; +import { supabase } from "./supabase"; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [session, setSession] = useState(null); + const [perfil, setPerfil] = useState(null); // { nombre, rol } + const [permisos, setPermisos] = useState([]); // ["ocupabilidad", "cobranza", ...] + const [cargando, setCargando] = useState(true); + + // Cargar perfil (rol) y permisos del usuario logueado + const cargarPerfil = useCallback(async (userId) => { + // 1) Traer el rol del usuario desde la tabla "perfiles" + const { data: perf, error: e1 } = await supabase + .from("perfiles") + .select("nombre, rol, activo") + .eq("id", userId) + .single(); + + if (e1 || !perf || perf.activo === false) { + setPerfil(null); + setPermisos([]); + return false; + } + + // 2) Traer los módulos permitidos para ese rol desde "permisos" + const { data: perms } = await supabase + .from("permisos") + .select("modulo, puede_ver") + .eq("rol", perf.rol); + + const modulos = (perms || []) + .filter((p) => p.puede_ver) + .map((p) => p.modulo); + + setPerfil({ nombre: perf.nombre, rol: perf.rol }); + setPermisos(modulos); + return true; + }, []); + + // Al iniciar: revisar si ya hay sesión activa + useEffect(() => { + let activo = true; + supabase.auth.getSession().then(async ({ data }) => { + if (!activo) return; + const s = data?.session || null; + setSession(s); + if (s?.user) await cargarPerfil(s.user.id); + setCargando(false); + }); + + // Escuchar cambios de sesión (login/logout) + const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => { + setSession(s); + if (s?.user) await cargarPerfil(s.user.id); + else { setPerfil(null); setPermisos([]); } + }); + + return () => { activo = false; sub?.subscription?.unsubscribe(); }; + }, [cargarPerfil]); + + const login = async (email, password) => { + const { data, error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) return { ok: false, msg: traducirError(error.message) }; + // Verificar que tenga perfil/rol válido + const ok = await cargarPerfil(data.user.id); + if (!ok) { + await supabase.auth.signOut(); + return { ok: false, msg: "Tu usuario no tiene un perfil asignado. Contacta al administrador." }; + } + return { ok: true }; + }; + + const logout = async () => { + await supabase.auth.signOut(); + setPerfil(null); + setPermisos([]); + }; + + const value = { + session, + perfil, + permisos, + cargando, + login, + logout, + autenticado: !!session && !!perfil, + puedeVer: (modulo) => permisos.includes(modulo), + }; + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth debe usarse dentro de AuthProvider"); + return ctx; +} + +function traducirError(msg) { + if (/invalid login credentials/i.test(msg)) return "Correo o contraseña incorrectos."; + if (/email not confirmed/i.test(msg)) return "El correo no está confirmado."; + return msg || "Error al iniciar sesión."; +} diff --git a/src/lib/supabase.js b/src/lib/supabase.js new file mode 100644 index 0000000..a3de4b6 --- /dev/null +++ b/src/lib/supabase.js @@ -0,0 +1,10 @@ +// src/lib/supabase.js +// Cliente de Supabase para autenticación (login). +// IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role). +import { createClient } from "@supabase/supabase-js"; + +// Estos valores se leen de frontend/.env (ver .env.example) +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || ""; +const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || ""; + +export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); diff --git a/src/lib/useColumnasAjustables.jsx b/src/lib/useColumnasAjustables.jsx new file mode 100644 index 0000000..88732ee --- /dev/null +++ b/src/lib/useColumnasAjustables.jsx @@ -0,0 +1,63 @@ +// src/lib/useColumnasAjustables.jsx +// Hook reutilizable para columnas redimensionables (estilo Excel) en cualquier tabla. +import { useState, useEffect } from "react"; + +export function useColumnasAjustables(anchosIniciales) { + const [anchos, setAnchos] = useState(anchosIniciales); + + // Si cambia el número de columnas (ej. Cobranza Sede↔Programa), reiniciar anchos + useEffect(() => { + if (anchos.length !== anchosIniciales.length) { + setAnchos(anchosIniciales); + } + }, [anchosIniciales.length]); + + function iniciarResize(e, i) { + e.preventDefault(); + e.stopPropagation(); + const xInicial = e.clientX; + const anchoInicial = anchos[i]; + function onMove(ev) { + const nuevo = Math.max(50, anchoInicial + (ev.clientX - xInicial)); + setAnchos((prev) => { const c = [...prev]; c[i] = nuevo; return c; }); + } + function onUp() { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + } + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + } + + // Devuelve los elementos para el de la tabla + function ColGroup() { + return ( + + {anchos.map((w, i) => )} + + ); + } + + // Devuelve el divisor arrastrable para poner dentro de cada + function Resizer({ index }) { + return ( + iniciarResize(e, index)} + className="col-resizer" + style={{ position: "absolute", top: 0, right: 0, width: 8, height: "100%", + cursor: "col-resize", userSelect: "none", zIndex: 5 }} + /> + ); + } + + const anchoTotal = anchos.reduce((a, b) => a + b, 0); + + // width:100% → llena el contenedor desde el inicio (sin huecos a la derecha). + // minWidth:anchoTotal → si las columnas crecen más que el contenedor, aparece scroll. + // tableLayout:fixed + colgroup → encabezados y celdas alineados. + const tableProps = { + style: { tableLayout: "fixed", width: "100%", minWidth: anchoTotal }, + }; + + return { anchos, ColGroup, Resizer, anchoTotal, tableProps }; +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..8fd46bf --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import { AuthProvider } from "./lib/auth"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + +); diff --git a/src/pages/Asesores.jsx b/src/pages/Asesores.jsx new file mode 100644 index 0000000..d71ec72 --- /dev/null +++ b/src/pages/Asesores.jsx @@ -0,0 +1,105 @@ +// src/pages/Asesores.jsx +import { useState, useEffect, useCallback } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox } from "../components/UI"; + +const ESTADO_INFO = { + online: { txt: "En Línea", color: "#10b981", bg: "#d1fae5", fg: "#065f46" }, + busy: { txt: "Ocupado", color: "#f59e0b", bg: "#fef9c3", fg: "#854d0e" }, + offline:{ txt: "Fuera de Línea", color: "#94a3b8", bg: "#f1f5f9", fg: "#475569" }, +}; + +export default function Asesores() { + const [agentes, setAgentes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [cambiando, setCambiando] = useState(null); + + const cargar = useCallback(() => { + setLoading(true); setError(null); + api.asesores() + .then((res)=>{ setAgentes(res.agentes||[]); setLoading(false); }) + .catch((e)=>{ setError(e.message); setLoading(false); }); + }, []); + + useEffect(() => { cargar(); }, [cargar]); + + async function toggle(ag) { + const online = ag.availability_status !== "online"; + setCambiando(ag.id); + try { + await api.asesorEstado(ag.id, online); + setAgentes((prev)=>prev.map((a)=>a.id===ag.id ? {...a, availability_status: online?"online":"offline"} : a)); + } catch (e) { /* noop */ } + setCambiando(null); + } + + async function todos(online) { + setCambiando("all"); + for (const ag of agentes) { + try { await api.asesorEstado(ag.id, online); } catch (e) {} + } + setAgentes((prev)=>prev.map((a)=>({...a, availability_status: online?"online":"offline"}))); + setCambiando(null); + } + + const total = agentes.length; + const enLinea = agentes.filter((a)=>a.availability_status==="online").length; + const ocupados = agentes.filter((a)=>a.availability_status==="busy").length; + const offline = total - enLinea - ocupados; + + return ( +
+

👥 Asesores

+ + {loading ? : + error ? : + <> +
+
👥
Total Asesores
{total}
+
🟢
En Línea
{enLinea}
+
🟡
Ocupados
{ocupados}
+
🔴
Fuera de Línea
{offline}
+
+ +
+ + + +
+ +
+ + + + {agentes.map((ag)=>{ + const est = ESTADO_INFO[ag.availability_status] || ESTADO_INFO.offline; + const isOnline = ag.availability_status==="online"; + return ( + + + + + + + + ); + })} + +
ASESOREMAILROLESTADOACCIÓN
{ag.name || ag.available_name || "—"}{ag.email || "—"}{ag.role || "agent"} + ● {est.txt} + + +
+
+ } +
+ ); +} diff --git a/src/pages/Cobranza.jsx b/src/pages/Cobranza.jsx new file mode 100644 index 0000000..742498a --- /dev/null +++ b/src/pages/Cobranza.jsx @@ -0,0 +1,660 @@ +// src/pages/Cobranza.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI"; +import Modal from "../components/Modal"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; +const AGRUPACIONES = [{value:"SEDE",label:"Por Sede"},{value:"PROGRAMA",label:"Por Programa"},{value:"ASESOR",label:"Por Asesor"}]; +const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"]; +const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"]; + +// Colores por cartera — diferenciados y legibles (sin azul, reservado para TOTAL) +// ANTERIOR = ámbar suave | MES = verde menta | TOTAL = lila/violeta suave +const BG_ANT = "#fef3c7", BG_MES = "#d1fae5", BG_TOT = "#ede9fe"; +// Versiones más oscuras para hover de cada cartera +const HV_ANT = "#fde68a", HV_MES = "#a7f3d0", HV_TOT = "#ddd6fe"; + +function toMonto(s){ const n = parseFloat(String(s).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; } +function toPct(s){ const n = parseFloat(String(s).replace("%","").trim()); return isNaN(n)?0:n; } +// Formato moneda SIN decimales (solo visualización) +function fmtMoneda(s){ + if (s == null) return ""; + if (!String(s).includes("S/")) return s; // no es monto, devolver tal cual + const n = toMonto(s); + return "S/ " + n.toLocaleString("es-PE", { maximumFractionDigits: 0 }); +} + +function MiniBar({ valStr }) { + if (!valStr || !String(valStr).includes("%")) return ; + const pct = toPct(valStr); + const c = colorSemaforo(pct); + const w = Math.min(Math.max(pct,0),100); + return ( +
+ {pct.toFixed(0)}% +
+
+
+
+ ); +} + +export default function Cobranza() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [mes, setMes] = useState(hoy.getMonth() + 1); + const [agrupacion, setAgrupacion] = useState("SEDE"); + const [sectorista, setSectorista] = useState("TODOS"); + const [sede, setSede] = useState("TODOS"); + const [frec, setFrec] = useState("TODOS"); + const [filas, setFilas] = useState([]); + const [sectoristas, setSectoristas] = useState(["TODOS"]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modal, setModal] = useState(null); + const [sedeMap, setSedeMap] = useState({}); // programa -> sede (sede.json) + const [exportando, setExportando] = useState(false); + const [alumnos, setAlumnos] = useState([]); // todos los alumnos (1 sola consulta) + const [buscadorAbierto, setBuscadorAbierto] = useState(false); + + const esPrograma = agrupacion === "PROGRAMA"; + // índices según agrupación + const IDX = esPrograma + ? { saldo:13, ant:[4,5,6], mes:[7,8,9], tot:[10,11,12] } + : { saldo:10, ant:[1,2,3], mes:[4,5,6], tot:[7,8,9] }; + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.cobranza(ano, mes, sectorista, agrupacion) + .then((res) => { + if (!activo) return; + setFilas(res.filas || []); + setSectoristas(res.sectoristas || ["TODOS"]); + setLoading(false); + }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, agrupacion, sectorista]); + + // Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada + useEffect(() => { + let activo = true; + api.cobranzaDetalleTodos(ano, mes, sectorista) + .then((res) => { if (activo) setAlumnos(res.alumnos || []); }) + .catch(() => { if (activo) setAlumnos([]); }); + return () => { activo = false; }; + }, [ano, mes, sectorista]); + + // Cargar clasificación de sede (sede.json) para los programas visibles + useEffect(() => { + if (!esPrograma) return; + const nombres = filas + .filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL") + .map((f) => String(f[0])); + if (nombres.length === 0) return; + let activo = true; + api.clasificarSede(nombres) + .then((res) => { if (activo) setSedeMap(res.mapa || {}); }) + .catch(() => { if (activo) setSedeMap({}); }); + return () => { activo = false; }; + }, [filas, esPrograma]); + + // Filtro sede/frecuencia (solo PROGRAMA) en cliente — usa sede.json + const datos = useMemo(() => { + let arr = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); + if (esPrograma) { + if (sede !== "TODOS") { + arr = arr.filter((f) => { + const s = (sedeMap[String(f[0])] || "LIMA").toUpperCase(); + return s === sede.toUpperCase(); + }); + } + if (frec !== "TODOS") arr = arr.filter((f) => String(f[1]).toUpperCase() === frec); + } + return arr; + }, [filas, esPrograma, sede, frec, sedeMap]); + + // KPIs: 3 tarjetas (recalculadas con datos filtrados) + const kpis = useMemo(() => { + let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0; + datos.forEach((f) => { + cAnt += toMonto(f[IDX.ant[0]]); obAnt += toMonto(f[IDX.ant[1]]); + cMes += toMonto(f[IDX.mes[0]]); obMes += toMonto(f[IDX.mes[1]]); + cTot += toMonto(f[IDX.tot[0]]); obTot += toMonto(f[IDX.tot[1]]); + }); + return { cAnt,obAnt,cMes,obMes,cTot,obTot, saldo: cTot-obTot }; + }, [datos, IDX]); + + const headers = esPrograma + ? ["PROGRAMA","FREC.","N° CUOTA","VENCIMIENTO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."] + : ["GRUPO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."]; + + // Anchos para columnas redimensionables (1ª columna más ancha, resto estándar) + const anchosCob = headers.map((h, i) => i === 0 ? (esPrograma ? 280 : 200) : (h === "FREC." || h.startsWith("%") || h === "OPC." ? 80 : 120)); + const cols = useColumnasAjustables(anchosCob); + + function cartClase(ci) { + if (IDX.ant.includes(ci)) return "cart-ant"; + if (IDX.mes.includes(ci)) return "cart-mes"; + if (IDX.tot.includes(ci)) return "cart-tot"; + return ""; + } + + // Alumnos visibles según filtros activos (mismo dataset que Ver / Estado de Cuenta) + const alumnosFiltrados = useMemo(() => { + let arr = alumnos; + if (esPrograma) { + if (sede !== "TODOS") arr = arr.filter((a) => String(a.sede).toUpperCase() === sede.toUpperCase()); + if (frec !== "TODOS") arr = arr.filter((a) => String(a.frecuencia).toUpperCase() === frec); + } + return arr; + }, [alumnos, esPrograma, sede, frec]); + + function grupoDe(a) { + if (agrupacion === "SEDE") return a.sede; + if (agrupacion === "ASESOR") return ""; // el sectorista ya filtra; no hay sub-grupo por alumno + return a.programa; // PROGRAMA + } + + // Exportar el DETALLE DE ALUMNOS (rápido: usa el dataset ya cargado, sin N consultas) + async function exportarDetalle() { + setExportando(true); + try { + const base = alumnosFiltrados; + if (base.length === 0) { alert("No hay alumnos para exportar con los filtros actuales."); return; } + + const toDate = (v) => { + if (!v || v === "-") return ""; + const s = String(v).trim().replace(/\//g,"-"); + const p = s.split("-"); + if (p.length === 3 && p[2].length === 4) return new Date(+p[2], +p[1]-1, +p[0]); + return String(v); + }; + const pct = (cob, cta) => cta > 0 ? cob/cta : null; + + const XLSX = await import("xlsx-js-style"); + const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; + + // Colores suaves por cartera (igual que el dashboard): ANT ámbar, MES verde, TOT lila + const CART = { ant:"FEF3C7", mes:"D1FAE5", tot:"EDE9FE" }; + const cartFill = (c) => { + if ([5,6,7].includes(c)) return CART.ant; + if ([8,9,10].includes(c)) return CART.mes; + if ([11,12,13].includes(c)) return CART.tot; + return null; + }; + const headerStyle = { font:{bold:true,color:{rgb:"FFFFFF"},sz:11}, fill:{fgColor:{rgb:"1E40AF"}}, alignment:{horizontal:"center",vertical:"center"} }; + const moneyFmt = '"S/" #,##0'; + const pctFmt = '0%'; + const dateFmt = "dd/mm/yyyy"; + const cellStyle = (c) => { const f = cartFill(c); return f ? { fill:{fgColor:{rgb:f}} } : undefined; }; + + const ws = {}; + COLS.forEach((h,c)=>{ ws[XLSX.utils.encode_cell({r:0,c})] = {v:h,t:"s",s:headerStyle}; }); + + base.forEach((a, ri) => { + const r = ri + 1; + const vals = [ + grupoDe(a), a.matricula, a.alumno, a.num_cuota, toDate(a.fch_venc), + a.cta_ant, a.cob_ant, pct(a.cob_ant,a.cta_ant), + a.cta_cur, a.cob_cur, pct(a.cob_cur,a.cta_cur), + a.cta_tot, a.cob_tot, pct(a.cob_tot,a.cta_tot), + a.saldo, + ]; + vals.forEach((v, c) => { + const ref = XLSX.utils.encode_cell({r,c}); + const st = cellStyle(c); + if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) }; + else if ([7,10,13].includes(c)) ws[ref] = (v===null) ? { v:"-", t:"s", ...(st?{s:st}:{}) } : { v:Number(v), t:"n", z:pctFmt, ...(st?{s:st}:{}) }; + else if (c===4 && v instanceof Date) ws[ref] = { v, t:"d", z:dateFmt }; + else ws[ref] = { v: v ?? "", t:"s" }; + }); + }); + + // TOTAL GENERAL (suma de montos) + const rTot = base.length + 1; + const totalStyle = { font:{bold:true}, fill:{fgColor:{rgb:"EFF6FF"}} }; + const sum = (k) => base.reduce((s,a)=>s+(Number(a[k])||0),0); + const totVals = { 5:sum("cta_ant"),6:sum("cob_ant"),8:sum("cta_cur"),9:sum("cob_cur"),11:sum("cta_tot"),12:sum("cob_tot"),14:sum("saldo") }; + COLS.forEach((col,c)=>{ + const ref = XLSX.utils.encode_cell({r:rTot,c}); + if (c===0) ws[ref] = {v:"TOTAL GENERAL",t:"s",s:totalStyle}; + else if (totVals[c]!==undefined) ws[ref] = {v:totVals[c],t:"n",z:moneyFmt,s:totalStyle}; + else ws[ref] = {v:"",t:"s",s:totalStyle}; + }); + + ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rTot,c:COLS.length-1} }); + ws["!cols"] = [{wch:26},{wch:12},{wch:28},{wch:9},{wch:12},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:13}]; + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Cobranza"); + const sufijo = agrupacion + (esPrograma && sede!=="TODOS" ? `_${sede}` : "") + (sectorista!=="TODOS" ? `_${sectorista}` : ""); + XLSX.writeFile(wb, `cobranza_${sufijo}_${ano}_${mes}.xlsx`); + } finally { + setExportando(false); + } + } + + return ( +
+

📋 Cobranza

+ + + ({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} /> + + {esPrograma && } +
+ + +
+
+ + {loading ? : + error ? : + <> + + +
+ + + {headers.map((h, i)=>)} + + {datos.map((f, ri) => { + // En la tabla principal SIEMPRE son agrupaciones (Sede/Programa/Asesor), + // nunca alumnos individuales → NO aplicar rojo por deuda aquí. + // Regla "-": si % del grupo está vacío, CTA y COB del grupo → "-" + const antVacio = !String(f[IDX.ant[2]]||"").trim() || ["-","—"].includes(String(f[IDX.ant[2]]).trim()); + const mesVacio = !String(f[IDX.mes[2]]||"").trim() || ["-","—"].includes(String(f[IDX.mes[2]]).trim()); + const totVacio = !String(f[IDX.tot[2]]||"").trim() || ["-","—"].includes(String(f[IDX.tot[2]]).trim()); + return ( + + {f.slice(0, headers.length-1).map((v, ci) => { + const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; + let val = v; + if ((ci===IDX.ant[0]||ci===IDX.ant[1]||ci===IDX.ant[2]) && antVacio) val = "—"; + if ((ci===IDX.mes[0]||ci===IDX.mes[1]||ci===IDX.mes[2]) && mesVacio) val = "—"; + if ((ci===IDX.tot[0]||ci===IDX.tot[1]||ci===IDX.tot[2]) && totVacio) val = "—"; + const cartCls = cartClase(ci); + const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:""); + return ( + + ); + })} + + + ); + })} + {datos.length > 0 && (() => { + // Fila TOTAL GENERAL — suma de los datos visibles (respeta filtros) + const t = {}; + [IDX.ant[0],IDX.ant[1],IDX.mes[0],IDX.mes[1],IDX.tot[0],IDX.tot[1],IDX.saldo] + .forEach((ci)=>{ t[ci] = datos.reduce((s,f)=>s+toMonto(f[ci]),0); }); + const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : ""; + const cell = (ci) => { + if (ci===0) return "TOTAL GENERAL"; + if (ci===IDX.ant[2]) return ratio(t[IDX.ant[1]],t[IDX.ant[0]]); + if (ci===IDX.mes[2]) return ratio(t[IDX.mes[1]],t[IDX.mes[0]]); + if (ci===IDX.tot[2]) return ratio(t[IDX.tot[1]],t[IDX.tot[0]]); + if (t[ci]!==undefined) return "S/ " + t[ci].toLocaleString("es-PE",{maximumFractionDigits:0}); + return ""; + }; + return ( + + {Array.from({length: headers.length-1}).map((_,ci)=>{ + const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; + const val = cell(ci); + return ( + + ); + })} + + + ); + })()} + +
{h}
+ {isPct ? (val==="—" ? : ) : fmtMoneda(val)} + + +
+ {isPct ? : val} +
+
+ } + + {modal && ( + setModal(null)} /> + )} + + {buscadorAbierto && ( + setBuscadorAbierto(false)} + /> + )} +
+ ); +} + +function Tarjetas({ kpis }) { + const alDia = kpis.saldo <= 0.01; + if (alDia) { + return ( +
+
+
AL DÍA
+
Sin deuda pendiente
+
+ ); + } + const cards = [ + ["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt], + ["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes], + ["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot], + ]; + return ( +
+ {cards.map(([titulo,color,cta,cob]) => { + const ratio = cta>0 ? (cob/cta*100) : 0; + const c = colorSemaforo(ratio); + const saldo = cta - cob; + return ( +
+
{titulo}
+
+ + + 0?"#dc2626":"#10b981"} /> + {cta>0 && ( +
+
+ % de Pago + {ratio.toFixed(0)}% +
+
+
+
+
+ )} +
+
+ ); + })} +
+ ); +} + +function Row({ k, v, bold, color }) { + return ( +
+ {k} + {v} +
+ ); +} + +function TarjetasResumen({ r }) { + const cards = [ + ["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt], + ["📅 MES EN CURSO","#059669",r.cMes,r.obMes], + ["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot], + ]; + return ( +
+ {cards.map(([titulo,color,cta,cob]) => { + const ratio = cta>0 ? (cob/cta*100) : 0; + const c = colorSemaforo(ratio); + const saldo = cta - cob; + return ( +
+
{titulo}
+
+ + + 0?"#dc2626":"#10b981"} /> + {cta>0 && ( +
+
+ % de Pago + {ratio.toFixed(0)}% +
+
+
+
+
+ )} +
+
+ ); + })} +
+ ); +} + +function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) { + const [texto, setTexto] = useState(""); + const [query, setQuery] = useState(""); + + const resultados = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return []; + return alumnos.filter((a) => + String(a.alumno).toLowerCase().includes(q) || + String(a.programa).toLowerCase().includes(q) || + String(a.matricula).toLowerCase().includes(q) + ); + }, [alumnos, query]); + + const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.", + "CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; + + const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : ""; + + return ( + +
+ setTexto(e.target.value)} + onKeyDown={(e)=>{ if(e.key==="Enter") setQuery(texto); }} + placeholder="Escribe nombre, programa o matrícula (ej: Aro) y presiona Enter" + style={{flex:1,padding:"10px 14px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:14}} + /> + + {query && } +
+ + {!query ? ( +
+ Escribe un texto y presiona Enter para buscar. +
+ ) : resultados.length === 0 ? ( +
+ Sin coincidencias para "{query}". +
+ ) : ( + <> +
+ {resultados.length} alumno(s) encontrado(s) +
+
+ + {COLS.map((c)=>)} + + {resultados.map((a,i)=>{ + const deuda = a.saldo > 0.01; + const grp = agrupacion==="SEDE" ? a.sede : (agrupacion==="ASESOR" ? sectorista : a.programa); + const celdas = [ + {v:grp, cls:"col-name", al:"left"}, + {v:a.matricula}, + {v:a.alumno, cls:"col-name", al:"left"}, + {v:a.num_cuota}, + {v:a.fch_venc}, + {v:fmtMoneda("S/ "+a.cta_ant), al:"right", cart:"cart-ant"}, + {v:fmtMoneda("S/ "+a.cob_ant), al:"right", cart:"cart-ant"}, + {pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"}, + {v:fmtMoneda("S/ "+a.cta_cur), al:"right", cart:"cart-mes"}, + {v:fmtMoneda("S/ "+a.cob_cur), al:"right", cart:"cart-mes"}, + {pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"}, + {v:fmtMoneda("S/ "+a.cta_tot), al:"right", cart:"cart-tot"}, + {v:fmtMoneda("S/ "+a.cob_tot), al:"right", cart:"cart-tot"}, + {pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"}, + {v:fmtMoneda("S/ "+a.saldo), al:"right", saldo:true}, + ]; + return ( + + {celdas.map((c,j)=>( + + ))} + + ); + })} + +
{c}
+ {c.pct!==undefined ? (c.pct ? : "—") : c.v} +
+
+ + )} +
+ ); +} + +function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) { + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let activo = true; + setLoading(true); + api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); + return () => { activo = false; }; + }, [grupo, ano, mes, sectorista, agrupacion]); + + const COLS = ["MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; + + // Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos + const resumen = useMemo(() => { + let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0; + filas.forEach((f) => { + if (String(f[0]).toUpperCase() === "TOTAL") return; + cAnt += toMonto(f[4]); obAnt += toMonto(f[5]); + cMes += toMonto(f[7]); obMes += toMonto(f[8]); + cTot += toMonto(f[10]); obTot += toMonto(f[11]); + }); + return { cAnt,obAnt,cMes,obMes,cTot,obTot }; + }, [filas]); + + return ( + + {loading ? : + filas.length === 0 ?
Sin datos.
: + <> +
+ +
+
+ + {COLS.map((c)=>)} + + {filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL").map((f, i) => { + const saldo = toMonto(f[13]); + const deuda = saldo > 0.01; + // Regla "-": si %ANT vacío → CTA/COB ANT a "—" (igual MES y TOT) + const antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim()); + const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim()); + const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim()); + return ( + + {f.map((v, j) => { + let val = v; + if ((j===4||j===5||j===6) && antVacio) val = "—"; + if ((j===7||j===8||j===9) && mesVacio) val = "—"; + if ((j===10||j===11||j===12) && totVacio) val = "—"; + const isPct = [6,9,12].includes(j); + const isMonto = String(val).includes("S/"); + let cart = ""; + if ([4,5,6].includes(j)) cart = "cart-ant"; + else if ([7,8,9].includes(j)) cart = "cart-mes"; + else if ([10,11,12].includes(j)) cart = "cart-tot"; + const clss = ((j===1?"col-name":"") + (cart?` ${cart}`:"")).trim(); + return ( + + ); + })} + + ); + })} + {(() => { + // TOTAL GENERAL del modal — solo alumnos visibles (respeta la sede filtrada) + const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL"); + if (alumnos.length===0) return null; + const sum = (j)=>alumnos.reduce((s,f)=>s+toMonto(f[j]),0); + const t = {4:sum(4),5:sum(5),7:sum(7),8:sum(8),10:sum(10),11:sum(11),13:sum(13)}; + const r = (cob,cta)=>cta>0?`${Math.round(cob/cta*100)}%`:""; + const celda = (j)=>{ + if (j===0) return "TOTAL GENERAL"; + if (j===1||j===2||j===3) return ""; + if (j===6) return r(t[5],t[4]); + if (j===9) return r(t[8],t[7]); + if (j===12) return r(t[11],t[10]); + if (t[j]!==undefined) return "S/ "+t[j].toLocaleString("es-PE",{maximumFractionDigits:0}); + return ""; + }; + return ( + + {Array.from({length:14}).map((_,j)=>{ + const isPct=[6,9,12].includes(j); + const val=celda(j); + return ( + + ); + })} + + ); + })()} + +
{c}
+ {isPct ? (val==="—"?:) : fmtMoneda(val)} +
+ {isPct?:val} +
+
+ } +
+ ); +} diff --git a/src/pages/Comisiones.jsx b/src/pages/Comisiones.jsx new file mode 100644 index 0000000..e519e99 --- /dev/null +++ b/src/pages/Comisiones.jsx @@ -0,0 +1,1004 @@ +// src/pages/Comisiones.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; +import Modal from "../components/Modal"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; +const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; +const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; +const COLS = ["VENDEDOR","INSCRITOS PAGADOS MES EN CURSO","MONTO PAGADO MES EN CURSO","INSCRITOS PAGADOS MES PASADO","MONTO PAGADO MES PASADO","AVANCE TOTAL","OPCIONES"]; +// Mostaza transparente: resalta filas de alumnos con edición (override en Supabase) +const BG_EDITADO = "rgba(217,160,28,0.14)"; + +// Filtro del popup → tipo de lista que ya maneja ventasDetalle +const OPCIONES_POPUP = [ + { value: "Venta P.C", label: "INSCRITOS PAGADOS MES EN CURSO" }, + { value: "Venta Pendientes", label: "INSCRITOS PAGADOS MES PASADO" }, +]; + +function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; } +// Formatea cualquier monto a "S/ 1,234" (sin decimales) +function fmt(v){ return "S/ " + toNum(v).toLocaleString("es-PE",{maximumFractionDigits:0}); } +// True si la fecha de matrícula (dd/mm/yyyy) es >= 01/01/2025 +function fechaMatDesde2025(fch){ + const s = String(fch || "").trim(); + const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); + if (!m) return false; // sin fecha válida → excluir + const d = new Date(+m[3], +m[2]-1, +m[1]); + return d >= new Date(2025, 0, 1); +} + +export default function Comisiones() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [mes, setMes] = useState(hoy.getMonth() + 1); + const [sede, setSede] = useState("TODOS"); + const [programa, setPrograma] = useState("TODOS"); + const [filas, setFilas] = useState([]); + const [manualesAno, setManualesAno] = useState([]); // todos los del año (cache) + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modal, setModal] = useState(null); // { vendedor, manual? } + const [modalEdit, setModalEdit] = useState(null); // { vendedor } + const [modalEditManual, setModalEditManual] = useState(null); // { nombre } + const [modalAdd, setModalAdd] = useState(false); + const [modalBuscar, setModalBuscar] = useState(false); + const [modalConfig, setModalConfig] = useState(false); + const cols = useColumnasAjustables([200, 160, 150, 160, 150, 130, 110]); + + // Carga TODOS los manuales del año (una sola vez por año) → cambio de mes instantáneo + function cargarManuales() { + api.vendedoresManualesListar(ano, 0) + .then((res) => setManualesAno(res.vendedores || [])) + .catch(() => setManualesAno([])); + } + + // Manuales del mes actual: filtrado en memoria (instantáneo, sin esperar red) + const manuales = useMemo( + () => manualesAno.filter((m) => Number(m.mes) === Number(mes)), + [manualesAno, mes] + ); + + // Tabla de ventas (depende del mes, sede y programa) + function cargarTabla() { + setLoading(true); setError(null); + api.ventas(ano, mes, sede, programa) + .then((res) => { setFilas(res.filas || []); setLoading(false); }) + .catch((e) => { setError(e.message); setLoading(false); }); + } + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.ventas(ano, mes, sede, programa) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, sede, programa]); + + // Cargar manuales del año cuando cambia el año (o al inicio) + useEffect(() => { cargarManuales(); }, [ano]); + + const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]); + const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]); + + // Agrupar vendedores manuales por nombre (cada nombre = 1 fila, suma de montos) + const manualesAgrupados = useMemo(() => { + const map = {}; + manuales.forEach((m) => { + if (!map[m.nombre]) map[m.nombre] = { nombre: m.nombre, totalMonto: 0, filas: [] }; + map[m.nombre].totalMonto += toNum(m.monto); + map[m.nombre].filas.push(m); + }); + return Object.values(map); + }, [manuales]); + + // Total general de AVANCE TOTAL = ventas + montos manuales + // (los manuales solo cuentan cuando NO hay filtro de sede/programa) + const totalAvance = useMemo(() => { + let s = total ? toNum(total[7]) : 0; + if (sede==="TODOS" && programa==="TODOS") manuales.forEach((m) => { s += toNum(m.monto); }); + return s; + }, [total, manuales, sede, programa]); + + const C = { textAlign: "center" }; // todas las columnas centradas + + return ( +
+

🧮 Comisiones

+ + + ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> + +
+ + + +
+
+ + {loading ? : + error ? : +
+ + + {COLS.map((c, i) => )} + + {datos.map((f, i) => ( + + + + + + + + ))} + + {/* Asesores manuales: solo cuando NO hay filtro de sede/programa */} + {sede==="TODOS" && programa==="TODOS" && manualesAgrupados.map((g) => ( + + + + + + + + ))} + + {total && ( + + + + + + + )} + +
{c}
{f[0]}{f[3]}{fmt(f[4])}{f[5]}{fmt(f[6])}{fmt(f[7])} + + +
{g.nombre}{fmt(g.totalMonto)} + + +
TOTAL GENERAL{total[3]}{fmt(total[4])}{total[5]}{fmt(total[6])}{fmt(totalAvance)}
+
} + + {modal && ( + setModal(null)} + onEliminar={() => { setModal(null); cargarManuales(); }} /> + )} + + {modalEdit && ( + setModalEdit(null)} onGuardado={cargarTabla} /> + )} + + {modalEditManual && ( + m.nombre===modalEditManual.nombre)} + onClose={() => setModalEditManual(null)} + onCambio={cargarManuales} /> + )} + + {modalConfig && ( + setModalConfig(false)} onGuardado={cargarTabla} /> + )} + + {modalBuscar && ( + setModalBuscar(false)} onGuardado={cargarTabla} /> + )} + + {modalAdd && ( + setModalAdd(false)} + onGuardado={() => { setModalAdd(false); cargarManuales(); }} /> + )} +
+ ); +} + +// Celda editable con validación según tipo (fecha / dinero / numero / texto) +function CeldaEdit({ tipo, valor, onChange }) { + const baseStyle = { width:"100%", minWidth:90, padding:"5px 6px", border:"1px solid #cbd5e1", + borderRadius:6, fontSize:12, textAlign:"center", boxSizing:"border-box" }; + + // Convierte dd/mm/yyyy → yyyy-mm-dd (para input date) + function aISO(v) { + const s = String(v || "").trim(); + if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0,10); + const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); + if (m) return `${m[3]}-${m[2].padStart(2,"0")}-${m[1].padStart(2,"0")}`; + return ""; + } + // Convierte yyyy-mm-dd → dd/mm/yyyy (para guardar) + function aDMY(iso) { + const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/); + return m ? `${m[3]}/${m[2]}/${m[1]}` : iso; + } + + if (tipo === "fecha") { + return ( + onChange(e.target.value ? aDMY(e.target.value) : "")} + style={baseStyle} /> + ); + } + + if (tipo === "dinero") { + const num = toNum(valor); + const display = (valor === "" || valor === "-" || valor == null) ? "" : "S/ " + num.toLocaleString("es-PE",{maximumFractionDigits:0}); + return ( + { + // Solo dígitos: "5500" → guarda "S/ 5500" + const limpio = e.target.value.replace(/[^\d]/g, ""); + onChange(limpio === "" ? "" : "S/ " + parseInt(limpio,10).toLocaleString("es-PE")); + }} + placeholder="S/ 0" style={baseStyle} /> + ); + } + + if (tipo === "numero") { + return ( + onChange(e.target.value.replace(/[^\d-]/g, ""))} + style={baseStyle} /> + ); + } + + // texto + return onChange(e.target.value)} style={baseStyle} />; +} + +function ModalEditar({ vendedor, ano, mes, sede = "TODOS", programa = "TODOS", onClose, onGuardado }) { + const [tipo, setTipo] = useState(OPCIONES_POPUP[0].value); + const [filas, setFilas] = useState([]); + const [edits, setEdits] = useState({}); // { num_matricula: { idx: valor } } + const [loading, setLoading] = useState(true); + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(null); + const [confirmar, setConfirmar] = useState(null); // matrícula a restaurar + + function cargar() { + setLoading(true); + api.ventasDetalle(vendedor, ano, mes, tipo, sede, programa) + .then((res) => { + const soloAlumnos = (res.filas || []).filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); + setFilas(soloAlumnos); + setEdits({}); + setLoading(false); + }) + .catch(() => { setFilas([]); setLoading(false); }); + } + useEffect(() => { cargar(); }, [vendedor, ano, mes, tipo, sede, programa]); + + // Columnas editables: etiqueta + índice en la fila + clave en tabla override + const CAMPOS = [ + { label: "F. MATRÍCULA", idx: 2, col: "fch_matricula", tipo: "fecha" }, + { label: "INVERSIÓN NETA", idx: 7, col: "inversion_neta", tipo: "dinero" }, + { label: "SALDO MATRÍCULA", idx: 10, col: "saldo_matricula", tipo: "dinero" }, + { label: "SALDO 1° CUOTA", idx: 11, col: "saldo_cuota1", tipo: "dinero" }, + { label: "FECHA INICIO", idx: 9, col: "fecha_inicio", tipo: "fecha" }, + { label: "FECHA CANCELACIÓN 1", idx: 3, col: "fecha_cancelacion1", tipo: "fecha" }, + { label: "PROMEDIO CUOTA", idx: 5, col: "promedio_cuota", tipo: "dinero" }, + { label: "DÍAS ANTICIPACIÓN", idx: 12, col: "dias_anticipacion", tipo: "numero" }, + { label: "VALOR CUOTA ADICIONAL", idx: 13, col: "valor_cuota_adicional", tipo: "dinero" }, + { label: "SEDE", idx: 14, col: "tipo_programa", tipo: "texto" }, + ]; + + function valorActual(f, idx) { + const mat = String(f[15]); + if (edits[mat] && edits[mat][idx] !== undefined) return edits[mat][idx]; + return f[idx] ?? ""; + } + function cambiar(mat, idx, valor) { + setEdits((prev) => ({ ...prev, [mat]: { ...(prev[mat]||{}), [idx]: valor } })); + } + + async function guardar() { + setGuardando(true); setMsg(null); + try { + const registros = Object.keys(edits).map((mat) => { + const r = { num_matricula: mat }; + CAMPOS.forEach((c) => { + const e = edits[mat][c.idx]; + // Campo borrado a propósito → marcador "__VACIO__" (fuerza vacío en el override) + if (e !== undefined) r[c.col] = (String(e).trim() === "") ? "__VACIO__" : String(e); + }); + return r; + }); + if (registros.length === 0) { setMsg({ok:false,txt:"No hay cambios para guardar."}); setGuardando(false); return; } + await api.comisionesOverrideGuardar(registros); + setMsg({ ok:true, txt:"✅ Cambios guardados." }); + cargar(); + if (onGuardado) onGuardado(); // recarga la tabla principal + } catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); } + setGuardando(false); + } + + async function restaurar(mat) { + try { await api.comisionesOverrideRestaurar(mat); cargar(); if (onGuardado) onGuardado(); } + catch (e) { setMsg({ ok:false, txt:"No se pudo restaurar: " + e.message }); } + } + + const th = { background:"#334155", color:"#f1f5f9", padding:8, fontSize:10, fontWeight:600, whiteSpace:"nowrap", textAlign:"center", position:"sticky", top:0, borderRight:"1px solid #475569" }; + + // Columnas redimensionables: ALUMNO + PROGRAMA + CAMPOS + ACCIÓN + const colsEdit = useColumnasAjustables([200, 220, ...CAMPOS.map(()=>130), 80]); + + return ( + +
+ cambiarRow(i,"descripcion",e.target.value)} style={inp} /> + cambiarRow(i,"fch_emision",e.target.value)} style={inp} /> + cambiarRow(i,"monto",e.target.value)} style={inp} /> + + + + + ))} + + +
+ + + + {msg && ( +
{msg.txt}
+ )} +
+ + +
+
+ ); +} + +function ModalConfig({ ano, mes, onClose, onGuardado }) { + const TIPOS = ["TEAC", "TERC", "SEMINARIOS", "OTROS"]; + const [tc, setTc] = useState(""); // IMP_TC único del mes + const [filas, setFilas] = useState([]); // META y COMISIÓN por tipo + const [loading, setLoading] = useState(true); + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(null); + + useEffect(() => { + setLoading(true); + api.comisionesConfig(ano, mes) + .then((res) => { + const guardadas = res.config || []; + // TC: tomar el primero que tenga valor (es único por mes) + const tcGuardado = guardadas.find((x) => +x.imp_tc > 0); + setTc(tcGuardado ? String(tcGuardado.imp_tc) : ""); + const arr = TIPOS.map((t) => { + const g = guardadas.find((x) => x.tipo_programa === t) || {}; + return { tipo_programa: t, meta: g.meta ?? "", comision: g.comision ?? "" }; + }); + setFilas(arr); setLoading(false); + }) + .catch(() => { setTc(""); setFilas(TIPOS.map((t)=>({tipo_programa:t,meta:"",comision:""}))); setLoading(false); }); + }, [ano, mes]); + + function cambiar(i, campo, val) { + setFilas((prev) => prev.map((f, idx) => idx === i ? { ...f, [campo]: val } : f)); + } + + async function guardar() { + setGuardando(true); setMsg(null); + try { + // El mismo TC del mes se guarda en todas las filas (es único por mes) + const payload = filas.map((f) => ({ + tipo_programa: f.tipo_programa, + imp_tc: toNum(tc), meta: toNum(f.meta), comision: toNum(f.comision), + })); + await api.comisionesConfigGuardar({ ano, mes, filas: payload }); + if (onGuardado) onGuardado(); // recarga la tabla principal + onClose(); + } catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); setGuardando(false); } + } + + const inp = { width:"100%", padding:"7px 9px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:13, textAlign:"center", boxSizing:"border-box" }; + const MESES_N = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; + + return ( + +
+ Configuración para {MESES_N[mes-1]} {ano}. +
+ {loading ? : <> + {/* IMP_TC único del mes */} +
+ + setTc(e.target.value)} + style={{...inp,maxWidth:160}} /> + Se usará para convertir dólares este mes. Si lo dejas vacío, usa el TC de la base. +
+ + {/* META y COMISIÓN por tipo de programa */} +
+ + + + + + + + {filas.map((f, i) => ( + + + + + + ))} + +
TIPO PROGRAMAMETA (S/)COMISIÓN (%)
{f.tipo_programa}cambiar(i,"meta",e.target.value)} style={inp} />cambiar(i,"comision",e.target.value)} style={inp} />
+
+ } + {msg && ( +
{msg.txt}
+ )} +
+ + +
+
+ ); +} + +function ModalBuscar({ ano, mes, onClose, onGuardado }) { + const [todos, setTodos] = useState([]); + const [loading, setLoading] = useState(true); + const [q, setQ] = useState(""); // lo que se escribe + const [qAplicado, setQAplicado] = useState(""); // lo que se busca (con debounce) + const [modo, setModo] = useState("VER"); // VER | EDITAR + const [edits, setEdits] = useState({}); // { num_matricula: { idx: valor } } + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(null); + const [confirmar, setConfirmar] = useState(null); // matrícula a restaurar + + function cargar() { + setLoading(true); + api.comisionesDetalleTodos(ano, mes) + .then((res) => { setTodos(res.filas || []); setLoading(false); }) + .catch(() => { setTodos([]); setLoading(false); }); + } + useEffect(() => { cargar(); }, [ano, mes]); + + async function restaurar(mat) { + try { + await api.comisionesOverrideRestaurar(mat); + setEdits((p) => { const c = {...p}; delete c[mat]; return c; }); + cargar(); + if (onGuardado) onGuardado(); // recarga la tabla principal + setMsg({ ok:true, txt:"✅ Alumno restaurado a sus valores originales." }); + } catch (e) { setMsg({ ok:false, txt:"No se pudo restaurar: " + e.message }); } + } + + // Debounce: aplica la búsqueda 250ms después de dejar de escribir (evita lag) + useEffect(() => { + const t = setTimeout(() => setQAplicado(q.trim().toLowerCase()), 250); + return () => clearTimeout(t); + }, [q]); + + // Filtrado en memoria, limitado a 100 resultados para no congelar el render + const resultados = useMemo(() => { + if (!qAplicado) return []; + const out = []; + for (let i = 0; i < todos.length && out.length < 100; i++) { + const f = todos[i]; + if (String(f[1]).toLowerCase().includes(qAplicado) || + String(f[8]).toLowerCase().includes(qAplicado) || + String(f[0]).toLowerCase().includes(qAplicado)) { + out.push(f); + } + } + return out; + }, [qAplicado, todos]); + + const HEADERS = ["VENDEDOR","ALUMNO","PROGRAMA","F. MATRÍCULA","INVERSIÓN NETA","SALDO MATRÍCULA","SALDO 1° CUOTA","FECHA INICIO","FECHA CANCELACIÓN 1","PROMEDIO CUOTA","DÍAS ANTICIPACIÓN","VALOR CUOTA ADICIONAL","SEDE"]; + const ORDEN = [0, 1, 8, 2, 7, 10, 11, 9, 3, 5, 12, 13, 14]; + const COL_MONTO = new Set([4, 5, 6, 9, 11]); + // Campos editables (mismos que el Editar normal): idx de fila → tipo + const EDITABLES = { 2:"fecha", 7:"dinero", 10:"dinero", 11:"dinero", 9:"fecha", 3:"fecha", 5:"dinero", 12:"numero", 13:"dinero", 14:"texto" }; + + // Anchos iniciales por columna (VENDEDOR/ALUMNO/PROGRAMA más anchas); +ACCIÓN en modo EDITAR + const anchosBuscar = [180, 220, 240, ...Array(HEADERS.length-3).fill(130), ...(modo==="EDITAR" ? [80] : [])]; + const colsBuscar = useColumnasAjustables(anchosBuscar); + + function setEdit(mat, idx, val) { + setEdits((p) => ({ ...p, [mat]: { ...(p[mat]||{}), [idx]: val } })); + } + + async function guardar() { + setGuardando(true); setMsg(null); + try { + const registros = Object.entries(edits).map(([mat, campos]) => { + const r = { num_matricula: mat }; + Object.entries(campos).forEach(([idx, val]) => { + const map = {2:"fch_matricula",7:"inversion_neta",10:"saldo_matricula",11:"saldo_cuota1",9:"fecha_inicio",3:"fecha_cancelacion1",5:"promedio_cuota",12:"dias_anticipacion",13:"valor_cuota_adicional",14:"tipo_programa"}; + // Campo borrado a propósito → marcador "__VACIO__" + if (map[idx]) r[map[idx]] = (String(val).trim() === "") ? "__VACIO__" : val; + }); + return r; + }); + await api.comisionesOverrideGuardar(registros); + setEdits({}); + if (onGuardado) onGuardado(); + setMsg({ ok:true, txt:"✅ Cambios guardados." }); + setGuardando(false); + } catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); setGuardando(false); } + } + + return ( + +
+ setQ(e.target.value)} + placeholder="Escribe nombre de alumno, programa o vendedor..." + style={{flex:1,padding:"11px 14px",border:"1px solid #cbd5e1",borderRadius:10,fontSize:14,boxSizing:"border-box"}} /> + {/* Switch VER / EDITAR */} +
+ {["VER","EDITAR"].map((m) => ( + + ))} +
+
+ + {loading ? : + !qAplicado ?
Escribe para buscar.
: + resultados.length === 0 ?
Sin coincidencias.
: +
+
+ {resultados.length}{resultados.length===100?"+":""} resultado(s) {modo==="EDITAR" && "— modo edición"} +
+ + + + {HEADERS.map((h,hi)=>)} + {modo==="EDITAR" && } + + + {resultados.map((f, i) => { + const mat = f[15]; + return ( + + {ORDEN.map((idx, pos) => { + const esTexto = pos===0||pos===1||pos===2; + const editable = modo==="EDITAR" && EDITABLES[idx] && idx!==0 && idx!==1 && idx!==8; + const valActual = (edits[mat] && edits[mat][idx] !== undefined) ? edits[mat][idx] : (f[idx] ?? ""); + return ( + + ); + })} + {modo==="EDITAR" && ( + + )} + + );})} + +
{h}ACCIÓN
+ {editable + ? setEdit(mat, idx, v)} /> + : (COL_MONTO.has(pos) ? fmt(f[idx]) : (f[idx] ?? ""))} + + +
+
} + + {msg && ( +
{msg.txt}
+ )} + {modo==="EDITAR" && ( +
+ +
+ )} + + {confirmar && ( + setConfirmar(null)} + onConfirmar={async () => { await restaurar(confirmar); setConfirmar(null); }} + /> + )} +
+ ); +} + +function ModalAgregar({ ano, mes, onClose, onGuardado }) { + const [nombre, setNombre] = useState(""); + const [descripcion, setDescripcion] = useState(""); + const [fchEmision, setFchEmision] = useState(""); + const [monto, setMonto] = useState(""); + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(null); + + async function guardar() { + if (!nombre.trim()) { setMsg("El nombre es obligatorio."); return; } + setGuardando(true); setMsg(null); + try { + await api.vendedoresManualesCrear({ + nombre: nombre.trim(), descripcion: descripcion.trim(), + fch_emision: fchEmision, monto: toNum(monto), ano, mes, + }); + onGuardado(); + } catch (e) { + setMsg("Error al guardar: " + e.message); + setGuardando(false); + } + } + + const inp = { width:"100%", padding:"9px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, boxSizing:"border-box" }; + + return ( + +
+
+ + setNombre(e.target.value)} style={inp} placeholder="Nombre del vendedor" autoFocus /> +
+
+ + setDescripcion(e.target.value)} style={inp} placeholder="Descripción" /> +
+
+ + setFchEmision(e.target.value)} style={inp} /> +
+
+ + setMonto(e.target.value)} style={inp} placeholder="0" /> +
+ {msg &&
{msg}
} +
+ + +
+
+
+ ); +} + +const lbl = { fontSize:12, fontWeight:600, color:"#475569", display:"block", marginBottom:5 }; + +function ModalDetalle({ vendedor, manual, ano, mes, sede = "TODOS", programa = "TODOS", onClose, onEliminar }) { + const [tipo, setTipo] = useState(OPCIONES_POPUP[0].value); + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + const [eliminando, setEliminando] = useState(false); + + useEffect(() => { + if (manual) { setLoading(false); return; } // manual no consulta detalle + let activo = true; + setLoading(true); + api.ventasDetalle(vendedor, ano, mes, tipo, sede, programa) + .then((res) => { + if (!activo) return; + const soloAlumnos = (res.filas || []).filter( + (f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL" && fechaMatDesde2025(f[2]) + ); + setFilas(soloAlumnos); + setLoading(false); + }) + .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); + return () => { activo = false; }; + }, [vendedor, ano, mes, tipo, manual, sede, programa]); + + async function eliminar() { + if (!confirm(`¿Eliminar al vendedor "${manual.nombre}"?`)) return; + setEliminando(true); + try { await api.vendedoresManualesEliminar(manual.id); onEliminar(); } + catch (e) { alert("No se pudo eliminar: " + e.message); setEliminando(false); } + } + + // ── Vista para vendedor MANUAL ── + if (manual) { + const filasM = manual.filas || [manual]; + const totalM = filasM.reduce((s,r)=>s+toNum(r.monto),0); + return ( + +
+ + + + + + + + + {filasM.map((r) => ( + + + + + + + ))} + + + + + + +
VENDEDORDESCRIPCIÓNFECHA EMISIÓNMONTO
{r.nombre}{r.descripcion || "—"}{r.fch_emision || "—"}{fmt(r.monto)}
TOTAL GENERAL{fmt(totalM)}
+
+
+ +
+
+ ); + } + + // Orden solicitado de columnas + const HEADERS = ["VENDEDOR","ALUMNO","PROGRAMA","F. MATRÍCULA","INVERSIÓN NETA","SALDO MATRÍCULA","SALDO 1° CUOTA","FECHA INICIO","FECHA CANCELACIÓN 1","PROMEDIO CUOTA","DÍAS ANTICIPACIÓN","VALOR CUOTA ADICIONAL","SEDE"]; + const ORDEN = [0, 1, 8, 2, 7, 10, 11, 9, 3, 5, 12, 13, 14]; + const COL_MONTO = new Set([4, 5, 6, 9, 11]); + + // Anchos iniciales por columna (px) — el usuario los puede arrastrar + const ANCHOS_INI = [180, 240, 280, 110, 130, 130, 130, 110, 140, 120, 120, 150, 130]; + const [anchos, setAnchos] = useState(ANCHOS_INI); + + function iniciarResize(e, i) { + e.preventDefault(); + const xInicial = e.clientX; + const anchoInicial = anchos[i]; + function onMove(ev) { + const nuevo = Math.max(60, anchoInicial + (ev.clientX - xInicial)); + setAnchos((prev) => { const c = [...prev]; c[i] = nuevo; return c; }); + } + function onUp() { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + } + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + } + + const totalInv = useMemo(() => filas.reduce((s,f)=>s+toNum(f[7]),0), [filas]); + + return ( + +
+ setEmail(e.target.value)} + placeholder="usuario@escuela.com" required autoFocus + style={inputStyle} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" required + style={inputStyle} + /> +
+ + {error && ( +
⚠️ {error}
+ )} + + + +
+
+ ); +} + +const inputStyle = { + width: "100%", padding: "11px 14px", border: "1px solid #cbd5e1", borderRadius: 10, + fontSize: 14, outline: "none", boxSizing: "border-box", +}; diff --git a/src/pages/Ocupabilidad.jsx b/src/pages/Ocupabilidad.jsx new file mode 100644 index 0000000..ff3d98a --- /dev/null +++ b/src/pages/Ocupabilidad.jsx @@ -0,0 +1,194 @@ +// src/pages/Ocupabilidad.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; +const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; +const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; + +const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD"]; + +function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; } + +export default function Ocupabilidad() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [mes, setMes] = useState(hoy.getMonth() + 1); + const [sede, setSede] = useState("TODOS"); + const [programa, setPrograma] = useState("TODOS"); + const [mostrarRepro, setMostrarRepro] = useState(true); + const [datos, setDatos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140]); + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.ocupabilidad(ano, mes, sede, programa) + .then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, sede, programa]); + + // Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio + const datosVisibles = useMemo(() => { + if (mostrarRepro) return datos; + return datos.filter((d) => String(d.dias_para_inicio).trim().toUpperCase() !== "REPROGRAMADO"); + }, [datos, mostrarRepro]); + + const kpis = useMemo(() => { + if (!datosVisibles.length) return null; + const esRepro = (d) => String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO"; + // Las filas REPROGRAMADO (inicios) no entran en las métricas (igual que escritorio) + const validos = datosVisibles.filter((d) => !esRepro(d)); + const insc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Activos) || 0), 0); + const totalInsc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Totales) || 0), 0); + const retirados = validos.reduce((s, d) => s + (parseInt(d.Retirados ?? d.Inscritos_Retirados) || 0), 0); + const meta = validos.reduce((s, d) => s + (parseInt(d.Meta_Curso) || 0), 0); + return { + cursos: validos.length, // solo programados (sin reprogramados) + reprogramados: datosVisibles.filter(esRepro).length, // inicios rojos (REPROGRAMADO) + inscritos: totalInsc, // columna TOTAL INSCRITOS + meta, + avance: meta > 0 ? (totalInsc / meta * 100) : 0, + desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0, + sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length, + pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0), + }; + }, [datosVisibles]); + + const totales = useMemo(() => { + const t = { mes:0, total:0, pc:0, retirados:0, activos:0, refri:0, cont:0, meta:0 }; + datosVisibles.forEach((d) => { + t.mes += parseInt(d.Inscritos_Mes)||0; + t.total += parseInt(d.Inscritos_Totales)||0; + t.pc += parseInt(d.Inscritos_PC)||0; + t.retirados += parseInt(d.Retirados ?? d.Inscritos_Retirados)||0; + t.activos += parseInt(d.Inscritos_Activos)||0; + t.refri += parseInt(d.Descuento)||0; + t.cont += parseInt(d.Inscritos_Continuidad)||0; + t.meta += parseInt(d.Meta_Curso)||0; + }); + return t; + }, [datosVisibles]); + + const KPI_CARDS = kpis ? [ + ["📚","Cursos Activos", kpis.cursos, "programas este mes"], + ["🔁","Cursos Reprogramados", kpis.reprogramados, "inicios reprogramados"], + ["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"], + ["🎯","Meta Total", kpis.meta, "inscritos objetivo"], + ["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"], + ["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, "retirados / total inscritos"], + ] : []; + + async function exportarExcel() { + if (!datosVisibles.length) return; + // Cargar SheetJS desde CDN solo cuando se necesita + const XLSX = await import("xlsx"); + const filas = datosVisibles.map((d) => ({ + "PROGRAMA": d.programa_frecuencia || d.dsc_programa || "", + "FECHA INICIO": d.fch_inicio || "", + "DÍAS PARA INICIO": d.dias_para_inicio ?? "", + "TOTAL INSCRITOS": parseInt(d.Inscritos_Totales)||0, + "RETIRADOS": parseInt(d.Retirados ?? d.Inscritos_Retirados)||0, + "INSCRITOS EN CURSO": parseInt(d.Inscritos_Activos)||0, + "META INSCRITOS": parseInt(d.Meta_Curso)||0, + "AVANCE INSCRITOS": d.Avance_Inscritos || "", + "INSCRITOS MES": parseInt(d.Inscritos_Mes)||0, + "INSCRITOS P.C": parseInt(d.Inscritos_PC)||0, + "INSCRITOS REFRIPERU": parseInt(d.Descuento)||0, + "INSCRITOS CONTINUIDAD": parseInt(d.Inscritos_Continuidad)||0, + })); + const ws = XLSX.utils.json_to_sheet(filas); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad"); + XLSX.writeFile(wb, `cursos_${ano}_${mes}.xlsx`); + } + + return ( +
+

📊 Ocupabilidad

+ + + ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> + +
+ + +
+ +
+ + {loading ? : + error ? : + <> +
+ {KPI_CARDS.map(([ico,label,val,sub]) => ( +
+
{ico}
+
{label}
+
{val}
+
{sub}
+
+ ))} +
+ +
+ + + {COLS.map((c, i) => )} + + {datosVisibles.map((d, i) => { + const prog = d.programa_frecuencia || d.dsc_programa || ""; + const dias = parseInt(d.dias_para_inicio); + const pct = toNum(d.Avance_Inscritos); + // Inicio reprogramado (existe en Supabase pero no en SQL este mes) → fila roja + const esInicio = String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO"; + const cel = (v) => esInicio ? "-" : v; + return ( + + + + + + + + + + + + + + + ); + })} + {datosVisibles.length > 0 && ( + + + + + + + + + + + )} + +
{c}
{prog}{d.fch_inicio || ""}{d.dias_para_inicio ?? ""}{cel(parseInt(d.Inscritos_Totales)||0)}{cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)}{cel(parseInt(d.Inscritos_Activos)||0)}{cel(parseInt(d.Meta_Curso)||0)}{esInicio ? "-" : }{cel(parseInt(d.Inscritos_Mes)||0)}{cel(parseInt(d.Inscritos_PC)||0)}{cel(parseInt(d.Descuento)||0)}{cel(parseInt(d.Inscritos_Continuidad)||0)}
TOTAL GENERAL{totales.total}{totales.retirados}{totales.activos}{totales.meta}0 ? totales.total/totales.meta*100 : 0} />{totales.mes}{totales.pc}{totales.refri}{totales.cont}
+
+ } +
+ ); +} diff --git a/src/pages/Rentabilidad.jsx b/src/pages/Rentabilidad.jsx new file mode 100644 index 0000000..da70a05 --- /dev/null +++ b/src/pages/Rentabilidad.jsx @@ -0,0 +1,270 @@ +// src/pages/Rentabilidad.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; +import Modal from "../components/Modal"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; +const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"]; +const PROGRAMAS = ["TODOS","SEMINARIOS","OTROS","TEAC","TERC"]; + +const COLS = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."]; +// índices: inicial = 7,8,9 | actual = 10,11,12 +const BG_INI = "#eef4ff", BG_ACT = "#edfff6"; + +function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; } + +export default function Rentabilidad() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [mes, setMes] = useState(hoy.getMonth() + 1); + const [sede, setSede] = useState("TODOS"); + const [programa, setPrograma] = useState("TODOS"); + const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalVer, setModalVer] = useState(null); + // Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado + const IDX_INSCRITOS = [2, 3, 4]; + const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i); + const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80]; + const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i)); + const cols = useColumnasAjustables(anchosVisibles); + const [modalCostos, setModalCostos] = useState(null); + + function cargar() { + setLoading(true); setError(null); + api.rentabilidad(ano, mes, sede, programa) + .then((res) => { setFilas(res.filas || []); setLoading(false); }) + .catch((e) => { setError(e.message); setLoading(false); }); + } + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.rentabilidad(ano, mes, sede, programa) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, sede, programa]); + + const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]); + const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]); + + const kpis = useMemo(() => { + if (!total) return null; + return { + programas: datos.length, + enCurso: total[4], + ventaAct: total[10], + costoAct: total[11], + mbAct: total[12], + }; + }, [datos, total]); + + function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; } + + return ( +
+

📈 Rentabilidad

+ + + ({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} /> + +
+ + +
+
+ + {loading ? : + error ? : + <> + {kpis && ( +
+ {[["📚","Programas",kpis.programas,"este mes"], + ["🎓","En Curso",kpis.enCurso,"alumnos activos"], + ["💵","Venta Actual",kpis.ventaAct,"ingreso total"], + ["💸","Costo Actual",kpis.costoAct,"gasto total"], + ["📊","M.B. Actual",kpis.mbAct,"margen bruto"]].map(([ico,label,val,sub])=>( +
+
{ico}
{label}
+
{val}
{sub}
+
+ ))} +
+ )} + +
+ + + {COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=>)} + + {datos.map((f, ri) => ( + + {f.slice(0,13).map((v,ci)=>{ + if (ocultar(ci)) return null; + const isPct = ci===9||ci===12; + return ( + + ); + })} + + + ))} + {total && ( + + {total.slice(0,13).map((v,ci)=>( + ocultar(ci) ? null : + + ))} + + + )} + +
{c}
{v} + + +
{v}
+
+ } + + {modalVer && setModalVer(null)} />} + {modalCostos && setModalCostos(null)} onGuardado={cargar} />} +
+ ); +} + +function ModalVerDetalle({ programa, ano, mes, onClose }) { + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + useEffect(() => { + let activo = true; setLoading(true); + api.rentabilidadDetalle(programa, ano, mes) + .then((res)=>{ if(activo){setFilas(res.filas||[]);setLoading(false);} }) + .catch(()=>{ if(activo){setFilas([]);setLoading(false);} }); + return ()=>{activo=false;}; + }, [programa, ano, mes]); + + const COLS_DET = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","VALOR CUOTA","PROM. DESC. E.","VALOR VENTA"]; + + return ( + + {loading ? : + filas.length===0 ?
Sin alumnos.
: +
+ + {COLS_DET.map((c)=>)} + + {filas.map((f,i)=>( + + {f.map((v,j)=>( + + ))} + + ))} + +
{c}
=5?"right":(j===1?"left":"center")}}>{v}
+
} +
+ ); +} + +function ModalCostos({ programa, ano, mes, onClose, onGuardado }) { + const [costos, setCostos] = useState(null); + const [loading, setLoading] = useState(true); + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(null); + // [keyGuardar, etiqueta, prefijoBackend] + const CAMPOS = [["epp","COSTO EPP","epp"],["certificado","COSTO CERTIFICADO","cert"],["docente","COSTO DOCENTE","doc"],["marketing","COSTO MARKETING","mkt"],["consumibles","COSTO CONSUMIBLES","cons"]]; + + const [ini, setIni] = useState({}); + const [act, setAct] = useState({}); + + useEffect(() => { + let activo = true; setLoading(true); + api.rentabilidadCostos(programa, ano, mes) + .then((res)=>{ + if(!activo) return; + const c = res.costos || {}; + setCostos(c); + const gi={}, ga={}; + CAMPOS.forEach(([k,,bp])=>{ gi[k]=+(c[`${bp}_inicial`]||0); ga[k]=+(c[`${bp}_actual`]||0); }); + setIni(gi); setAct(ga); setLoading(false); + }) + .catch(()=>{ if(activo){setCostos(null);setLoading(false);} }); + return ()=>{activo=false;}; + }, [programa, ano, mes]); + + const totIni = Object.values(ini).reduce((a,b)=>a+(+b||0),0); + const totAct = Object.values(act).reduce((a,b)=>a+(+b||0),0); + + async function guardar() { + setGuardando(true); setMsg(null); + try { + await api.guardarCostos(costos.num_indice, ini, act); + if (onGuardado) onGuardado(); // recarga la tabla principal + onClose(); // cierra el modal + } catch (e) { + setMsg({ ok:false, txt:"❌ Error al guardar. Verifica Supabase." }); + setGuardando(false); + } + } + + return ( + + {loading ? : + !costos ?
Sin datos de costos.
: + <> +
+ + +
+ {msg &&
{msg.txt}
} +
+ + +
+ } +
+ ); +} + +function Columna({ titulo, color, campos, valores, setVal, total }) { + return ( +
+
{titulo}
+ {campos.map(([k,label])=>( +
+ + setVal({...valores,[k]:+e.target.value})} + style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} /> +
+ ))} +
+ TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})} +
+
+ ); +} diff --git a/src/pages/SaldoPendiente.jsx b/src/pages/SaldoPendiente.jsx new file mode 100644 index 0000000..62ac521 --- /dev/null +++ b/src/pages/SaldoPendiente.jsx @@ -0,0 +1,96 @@ +// src/pages/SaldoPendiente.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; + +const CUOTAS = ["1° Cuota","2° Cuota","3° Cuota","4° Cuota","5° Cuota"]; + +function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; } +function fmt(v){ const n=toNum(v); return "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); } + +export default function SaldoPendiente() { + const [tipoCuota, setTipoCuota] = useState("1° Cuota"); + const [datos, setDatos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [vendedor, setVendedor] = useState("TODOS"); + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.saldoPendiente(tipoCuota) + .then((res)=>{ if(activo){setDatos(res.datos||[]);setLoading(false);} }) + .catch((e)=>{ if(activo){setError(e.message);setLoading(false);} }); + return ()=>{activo=false;}; + }, [tipoCuota]); + + const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`; + const vendedores = useMemo(() => { + const set = new Set(datos.map((d)=>String(d.VENDEDOR||"").trim()).filter(Boolean)); + return ["TODOS", ...Array.from(set).sort()]; + }, [datos]); + + const filtrados = useMemo(() => { + let arr = datos.filter((d)=>toNum(d[colSaldo])>0.01); + if (vendedor!=="TODOS") arr = arr.filter((d)=>String(d.VENDEDOR||"").trim().toUpperCase()===vendedor.toUpperCase()); + return arr; + }, [datos, vendedor, colSaldo]); + + const totalSaldo = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d[colSaldo]),0),[filtrados,colSaldo]); + const totalMat = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d["SALDO MAT."]),0),[filtrados]); + + const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; + const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; + + return ( +
+

⏳ Saldo Pendiente

+ + + + + + {loading ? : + error ? : + <> +
+
👥
Alumnos con Saldo
{filtrados.length}
+
Saldo {tipoCuota}
{fmt(totalSaldo)}
+
📋
Saldo Matrícula
{fmt(totalMat)}
+
+ +
+ + {COLS.map((c)=>)} + + {filtrados.map((d,i)=>( + + {KEYS.map((k,j)=>{ + const isMonto = k.includes("SALDO")||k.includes("INV"); + return ( + + ); + })} + + ))} + {filtrados.length>0 && ( + + + + + + + )} + +
{c}
+ {isMonto ? fmt(d[k]) : (d[k] ?? "")} +
TOTAL GENERAL ({filtrados.length}){fmt(totalMat)}{fmt(totalSaldo)}
+
+ } +
+ ); +} diff --git a/src/pages/Usuarios.jsx b/src/pages/Usuarios.jsx new file mode 100644 index 0000000..e4da63d --- /dev/null +++ b/src/pages/Usuarios.jsx @@ -0,0 +1,130 @@ +// src/pages/Usuarios.jsx +import { useState, useEffect, useCallback } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; + +const ROLES = ["ADMINISTRADOR", "VENTAS", "COBRANZA"]; + +export default function Usuarios() { + const [usuarios, setUsuarios] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // form nuevo usuario + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [nombre, setNombre] = useState(""); + const [rol, setRol] = useState("COBRANZA"); + const [creando, setCreando] = useState(false); + const [msg, setMsg] = useState(null); + + const cargar = useCallback(() => { + setLoading(true); setError(null); + api.usuariosListar() + .then((res) => { setUsuarios(res.usuarios || []); setLoading(false); }) + .catch((e) => { setError(e.message); setLoading(false); }); + }, []); + + useEffect(() => { cargar(); }, [cargar]); + + async function crear() { + setMsg(null); + if (!email.trim() || !password.trim()) { setMsg({ ok:false, txt:"Correo y contraseña son obligatorios." }); return; } + setCreando(true); + try { + await api.usuariosCrear(email.trim(), password, nombre.trim(), rol); + setMsg({ ok:true, txt:`Usuario ${email} creado como ${rol}.` }); + setEmail(""); setPassword(""); setNombre(""); setRol("COBRANZA"); + cargar(); + } catch (e) { + setMsg({ ok:false, txt:"Error: " + e.message }); + } + setCreando(false); + } + + async function cambiarRol(id, nuevoRol) { + try { await api.usuariosActualizarRol(id, nuevoRol); cargar(); } + catch (e) { alert("No se pudo cambiar el rol: " + e.message); } + } + + async function eliminar(id, correo) { + if (!confirm(`¿Eliminar al usuario ${correo}? Esta acción no se puede deshacer.`)) return; + try { await api.usuariosEliminar(id); cargar(); } + catch (e) { alert("No se pudo eliminar: " + e.message); } + } + + return ( +
+

🔐 Usuarios

+ + {/* Crear nuevo usuario */} +
+

➕ Crear nuevo usuario

+
+ + setEmail(e.target.value)} placeholder="usuario@escuela.com" style={inp} /> + + + setPassword(e.target.value)} placeholder="mín. 6 caracteres" style={inp} /> + + + setNombre(e.target.value)} placeholder="Nombre completo" style={inp} /> + + + cambiarRol(u.id, e.target.value)} + style={{ padding:"5px 8px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12 }}> + {ROLES.map((r)=>)} + + + + + + + ))} + {usuarios.length === 0 && ( + Sin usuarios. + )} + + +
} +
+ ); +} + +function Campo({ label, children }) { + return ( +
+ + {children} +
+ ); +} + +const inp = { padding:"8px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, minWidth:180 }; diff --git a/src/pages/Ventas.jsx b/src/pages/Ventas.jsx new file mode 100644 index 0000000..4f7696a --- /dev/null +++ b/src/pages/Ventas.jsx @@ -0,0 +1,266 @@ +// src/pages/Ventas.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; +import Modal from "../components/Modal"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; +const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; +const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; +const COLS = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."]; +const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"]; +const HEADERS_DETALLE = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"]; + +export default function Ventas() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [mes, setMes] = useState(hoy.getMonth() + 1); + const [sede, setSede] = useState("TODOS"); + const [programa, setPrograma] = useState("TODOS"); + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modal, setModal] = useState(null); // { vendedor } + const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]); + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); + api.ventas(ano, mes, sede, programa) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, sede, programa]); + + const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]); + const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]); + + const KPIS = total ? [ + ["👤","Vendedores", datos.length, "activos este mes"], + ["🎓","Total Inscritos", total[1], "matriculados"], + ["💵","Venta Total", total[2], "inversión neta"], + ["✅","Venta P.C del Mes", total[4], "pagos completos"], + ["🚀","Avance P.C Total", total[7], "P.C + Pendientes"], + ] : []; + + // Exportar saldos por cuota — Excel con formato (anchos, encabezado de color, + // fechas reales, montos numéricos en soles y fila TOTAL GENERAL). + async function exportarCuota(tipoCuota) { + const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }; + // Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original + const toDate = (v) => { + if (!v) return ""; + const s = String(v).trim().replace(/\//g,"-"); + const p = s.split("-"); + if (p.length === 3) { + let [d,m,y] = p; + if (y.length === 4) return new Date(+y, +m-1, +d); + } + return String(v); + }; + + let res; + try { + res = await api.saldoPendiente(tipoCuota); + } catch (e) { + alert("No se pudo obtener los datos de saldos: " + e.message); + return; + } + const datosS = res.datos || []; + const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`; + const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01); + if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; } + + const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; + const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; + const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"]; + const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"]; + + const XLSX = await import("xlsx-js-style"); + + // Construir matriz de celdas (AOA) con tipos correctos + const headerStyle = { + font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 }, + fill: { fgColor: { rgb: "1E40AF" } }, + alignment: { horizontal: "center", vertical: "center" }, + }; + const moneyFmt = '"S/" #,##0'; + const dateFmt = "dd/mm/yyyy"; + + const ws = {}; + const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } }; + + // Encabezado + COLS.forEach((h, c) => { + const ref = XLSX.utils.encode_cell({ r: 0, c }); + ws[ref] = { v: h, t: "s", s: headerStyle }; + }); + + // Filas de datos + filtrados.forEach((d, ri) => { + KEYS.forEach((k, c) => { + const ref = XLSX.utils.encode_cell({ r: ri + 1, c }); + const col = COLS[c]; + if (COL_MONTO.includes(col)) { + ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt }; + } else if (COL_FECHA.includes(col)) { + const dt = toDate(d[k]); + if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt }; + else ws[ref] = { v: dt, t: "s" }; + } else { + ws[ref] = { v: d[k] ?? "", t: "s" }; + } + }); + }); + + // Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA + const rTot = filtrados.length + 1; + const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0); + const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0; + const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } }; + COLS.forEach((col, c) => { + const ref = XLSX.utils.encode_cell({ r: rTot, c }); + if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle }; + else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle }; + else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle }; + else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle }; + else ws[ref] = { v: "", t: "s", s: totalStyle }; + }); + + ws["!ref"] = XLSX.utils.encode_range(range); + ws["!cols"] = [ + { wch: 12 }, // MATRÍCULA + { wch: 22 }, // VENDEDOR + { wch: 28 }, // ALUMNO + { wch: 32 }, // PROGRAMA + { wch: 12 }, // F. INICIO + { wch: 12 }, // F. MATR. + { wch: 13 }, // SALDO MAT. + { wch: 15 }, // SALDO CUOTA + { wch: 13 }, // VENCIMIENTO + { wch: 13 }, // INV. NETA + ]; + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Saldos"); + const nombre = tipoCuota.replace("°","").replace(/\s/g,"_"); + XLSX.writeFile(wb, `saldos_${nombre}.xlsx`); + } + + return ( +
+

🛒 Ventas

+ + + ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> + +
+ + +
+
+ + {loading ? : + error ? : + <> +
+ {KPIS.map(([ico,label,val,sub]) => ( +
+
{ico}
+
{label}
+
{val}
+
{sub}
+
+ ))} +
+ +
+ + + {COLS.map((c, i) => )} + + {datos.map((f, i) => ( + + + + + + + + + ))} + {total && ( + + + + + + + + )} + +
{c}
{f[0]}{f[1]}{f[2]}{f[3]}{f[4]}{f[5]}{f[6]}{f[7]} + +
TOTAL GENERAL{total[1]}{total[2]}{total[3]}{total[4]}{total[5]}{total[6]}{total[7]}
+
+ } + + {modal && ( + setModal(null)} /> + )} +
+ ); +} + +function ModalDetalle({ vendedor, ano, mes, onClose }) { + const [tipo, setTipo] = useState(TIPOS[0]); + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let activo = true; + setLoading(true); + api.ventasDetalle(vendedor, ano, mes, tipo) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); + return () => { activo = false; }; + }, [vendedor, ano, mes, tipo]); + + // Columnas redimensionables (igual que las tablas principales) + const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]); + + return ( + +
+