From a03bed842f624e02f35624a8f488d0c71748191a Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Thu, 5 Feb 2026 23:18:22 -0600 Subject: [PATCH] [REMEDIATION] feat: Frontend remediation - auth, finance, contracts, session management Add auth components, finance pages/hooks/services, contract components. Enhance LoginPage, AdminLayout, hooks. Remove legacy apiClient. Add mock data services for development. Addresses frontend gaps. Co-Authored-By: Claude Opus 4.6 --- web/.env.example | 15 +- web/package-lock.json | 1762 ++++++++++++++++- web/postcss.config.js | 6 + web/src/components/auth/ProtectedRoute.tsx | 118 ++ web/src/components/auth/index.ts | 5 + web/src/components/contracts/AddendaModal.tsx | 188 ++ web/src/components/contracts/ContractForm.tsx | 321 +++ web/src/components/contracts/PartidaModal.tsx | 163 ++ web/src/components/contracts/index.ts | 7 + web/src/hooks/index.ts | 3 + web/src/hooks/useBidding.ts | 44 +- web/src/hooks/useConstruccion.ts | 154 +- web/src/hooks/useFinance.ts | 649 ++++++ web/src/hooks/usePresupuestos.ts | 89 +- web/src/hooks/useReports.ts | 87 +- web/src/hooks/useSession.ts | 160 ++ web/src/layouts/AdminLayout.tsx | 165 +- .../admin/finanzas/CuentasContablesPage.tsx | 497 +++++ .../admin/finanzas/CuentasPorCobrarPage.tsx | 472 +++++ .../admin/finanzas/CuentasPorPagarPage.tsx | 454 +++++ web/src/pages/admin/finanzas/FacturasPage.tsx | 422 ++++ .../admin/finanzas/FlujoEfectivoPage.tsx | 279 +++ web/src/pages/admin/finanzas/PolizasPage.tsx | 369 ++++ web/src/pages/admin/finanzas/index.ts | 10 + web/src/pages/auth/LoginPage.tsx | 26 +- web/src/services/apiClient.ts | 223 --- web/src/services/auth/auth.api.ts | 83 +- web/src/services/finance/accounting.api.ts | 133 ++ web/src/services/finance/ap.api.ts | 66 + web/src/services/finance/ar.api.ts | 50 + web/src/services/finance/cash-flow.api.ts | 41 + web/src/services/finance/index.ts | 9 + web/src/services/finance/invoices.api.ts | 95 + web/src/services/mockData.construccion.ts | 556 ++++++ web/src/services/mockData.modules.ts | 678 +++++++ web/src/services/mockData.ts | 240 +++ web/src/stores/authStore.ts | 125 +- web/src/types/finance.types.ts | 556 ++++++ web/src/types/index.ts | 49 + web/src/utils/authCleanup.ts | 98 + web/src/utils/index.ts | 8 + web/src/vite-env.d.ts | 13 + web/vite.config.ts | 6 +- 43 files changed, 9183 insertions(+), 311 deletions(-) create mode 100644 web/postcss.config.js create mode 100644 web/src/components/auth/ProtectedRoute.tsx create mode 100644 web/src/components/auth/index.ts create mode 100644 web/src/components/contracts/AddendaModal.tsx create mode 100644 web/src/components/contracts/ContractForm.tsx create mode 100644 web/src/components/contracts/PartidaModal.tsx create mode 100644 web/src/components/contracts/index.ts create mode 100644 web/src/hooks/useFinance.ts create mode 100644 web/src/hooks/useSession.ts create mode 100644 web/src/pages/admin/finanzas/CuentasContablesPage.tsx create mode 100644 web/src/pages/admin/finanzas/CuentasPorCobrarPage.tsx create mode 100644 web/src/pages/admin/finanzas/CuentasPorPagarPage.tsx create mode 100644 web/src/pages/admin/finanzas/FacturasPage.tsx create mode 100644 web/src/pages/admin/finanzas/FlujoEfectivoPage.tsx create mode 100644 web/src/pages/admin/finanzas/PolizasPage.tsx create mode 100644 web/src/pages/admin/finanzas/index.ts delete mode 100644 web/src/services/apiClient.ts create mode 100644 web/src/services/finance/accounting.api.ts create mode 100644 web/src/services/finance/ap.api.ts create mode 100644 web/src/services/finance/ar.api.ts create mode 100644 web/src/services/finance/cash-flow.api.ts create mode 100644 web/src/services/finance/index.ts create mode 100644 web/src/services/finance/invoices.api.ts create mode 100644 web/src/services/mockData.construccion.ts create mode 100644 web/src/services/mockData.modules.ts create mode 100644 web/src/services/mockData.ts create mode 100644 web/src/types/finance.types.ts create mode 100644 web/src/utils/authCleanup.ts diff --git a/web/.env.example b/web/.env.example index f40d251..61b8de3 100644 --- a/web/.env.example +++ b/web/.env.example @@ -2,12 +2,25 @@ # ERP CONSTRUCCION - CONFIGURACION FRONTEND # =========================================================== # Copiar este archivo a .env y ajustar valores +# Puertos oficiales: Frontend 3020, Backend 3021 # API Backend URL VITE_API_URL=http://localhost:3021/api/v1 -# Tenant ID para desarrollo +# Tenant ID para desarrollo (UUID del tenant en BD) +# En produccion: resolver dinamicamente por subdominio VITE_TENANT_ID=default-tenant # Ambiente VITE_APP_ENV=development + +# =========================================================== +# DEMO LOGIN (SOLO DESARROLLO) +# =========================================================== +# VITE_SHOW_DEMO_LOGIN: Muestra boton de credenciales demo +# IMPORTANTE: NUNCA habilitar en produccion +VITE_SHOW_DEMO_LOGIN=false + +# Credenciales demo (solo si VITE_SHOW_DEMO_LOGIN=true) +# VITE_DEMO_EMAIL=admin@demo.com +# VITE_DEMO_PASSWORD=demo123 diff --git a/web/package-lock.json b/web/package-lock.json index 1fb1591..2645cd2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,6 +23,9 @@ "zustand": "^4.4.7" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@typescript-eslint/eslint-plugin": "^6.14.0", @@ -32,16 +35,32 @@ "eslint": "^8.55.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^28.0.0", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", "typescript": "^5.2.2", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vitest": "^4.0.18" }, "engines": { "node": ">=18.0.0", "npm": ">=9.0.0" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -55,6 +74,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.7.tgz", + "integrity": "sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -309,6 +383,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -357,6 +441,138 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", + "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -646,6 +862,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -663,6 +896,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -680,6 +930,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -835,6 +1102,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", + "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@hookform/resolvers": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", @@ -1318,6 +1603,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.90.20", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", @@ -1344,6 +1636,104 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1389,6 +1779,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1664,6 +2072,90 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1687,6 +2179,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1765,6 +2267,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -1775,6 +2287,16 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1847,6 +2369,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1971,6 +2503,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2106,6 +2648,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2119,12 +2682,52 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -2153,6 +2756,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2169,6 +2779,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2209,6 +2829,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2230,6 +2858,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2248,6 +2889,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2525,6 +3173,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2535,6 +3193,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2962,6 +3630,47 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2999,6 +3708,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3090,6 +3809,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3126,6 +3852,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", + "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^5.3.7", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.20.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3271,6 +4037,27 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3280,6 +4067,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3325,6 +4119,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -3433,6 +4237,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3506,6 +4321,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3553,6 +4381,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3766,6 +4601,36 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3861,6 +4726,14 @@ "react-dom": ">=16" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3926,6 +4799,30 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4051,6 +4948,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4096,6 +5006,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4116,6 +5033,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4129,6 +5060,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4191,6 +5135,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", @@ -4259,6 +5210,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4307,6 +5275,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.22", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", + "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.22" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.22", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.22.tgz", + "integrity": "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4320,6 +5318,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -4380,6 +5404,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz", + "integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", @@ -4497,6 +5531,698 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/vitest/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4513,6 +6239,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4530,6 +6273,23 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..39700dc --- /dev/null +++ b/web/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,118 @@ +/** + * ProtectedRoute Component + * Guards routes that require authentication with role-based access control + * Based on gamilit implementation + */ + +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from '../../stores/authStore'; +import { LoadingSpinner } from '../common/LoadingSpinner'; + +interface ProtectedRouteProps { + children: React.ReactNode; + /** Roles allowed to access this route */ + allowedRoles?: string[]; + /** Custom redirect path for unauthenticated users */ + redirectTo?: string; +} + +export function ProtectedRoute({ + children, + allowedRoles, + redirectTo = '/auth/login', +}: ProtectedRouteProps) { + const user = useAuthStore((state) => state.user); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isLoading = useAuthStore((state) => state.isLoading); + const isInitialized = useAuthStore((state) => state.isInitialized); + const checkSession = useAuthStore((state) => state.checkSession); + const location = useLocation(); + + // Show loading while checking auth state + if (isLoading || !isInitialized) { + return ( +
+
+ +

+ Verificando sesión... +

+
+
+ ); + } + + // Check if session is still valid + const sessionValid = checkSession(); + + // Not authenticated - redirect to login + if (!isAuthenticated || !sessionValid) { + return ( + + ); + } + + // Check role-based access if roles are specified + if (allowedRoles && allowedRoles.length > 0) { + const userRole = user?.role || ''; + const hasRequiredRole = allowedRoles.includes(userRole); + + if (!hasRequiredRole) { + // User doesn't have required role - redirect to unauthorized page + return ( + + ); + } + } + + // User is authenticated and has required role + return <>{children}; +} + +/** + * PublicRoute Component + * Redirects authenticated users away from public pages (login, register) + */ +interface PublicRouteProps { + children: React.ReactNode; + /** Where to redirect authenticated users */ + redirectTo?: string; +} + +export function PublicRoute({ + children, + redirectTo = '/admin/dashboard', +}: PublicRouteProps) { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isLoading = useAuthStore((state) => state.isLoading); + const isInitialized = useAuthStore((state) => state.isInitialized); + const location = useLocation(); + + // Show loading while checking auth state + if (isLoading || !isInitialized) { + return ( +
+ +
+ ); + } + + // If user is already authenticated, redirect to dashboard + if (isAuthenticated) { + // Check if there's a "from" location to redirect back to + const from = (location.state as { from?: Location })?.from?.pathname || redirectTo; + return ; + } + + return <>{children}; +} + +export default ProtectedRoute; diff --git a/web/src/components/auth/index.ts b/web/src/components/auth/index.ts new file mode 100644 index 0000000..b745ebe --- /dev/null +++ b/web/src/components/auth/index.ts @@ -0,0 +1,5 @@ +/** + * Auth Components Index + */ + +export { ProtectedRoute, PublicRoute } from './ProtectedRoute'; diff --git a/web/src/components/contracts/AddendaModal.tsx b/web/src/components/contracts/AddendaModal.tsx new file mode 100644 index 0000000..6e8aece --- /dev/null +++ b/web/src/components/contracts/AddendaModal.tsx @@ -0,0 +1,188 @@ +/** + * AddendaModal - Modal para crear/editar addendas de contrato + */ + +import { useState } from 'react'; +import { + useCreateContractAddendum, + useUpdateContractAddendum, +} from '../../hooks/useContracts'; +import type { + ContractAddendum, + AddendumType, + CreateAddendumDto, +} from '../../types/contracts.types'; +import { ADDENDUM_TYPE_OPTIONS } from '../../types/contracts.types'; +import { + Modal, + ModalFooter, + TextInput, + TextareaField, + SelectField, + FormGroup, +} from '../common'; + +interface AddendaModalProps { + contractId: string; + addendum: ContractAddendum | null; + onClose: () => void; +} + +export function AddendaModal({ contractId, addendum, onClose }: AddendaModalProps) { + const createMutation = useCreateContractAddendum(); + const updateMutation = useUpdateContractAddendum(); + + const [formData, setFormData] = useState({ + addendumNumber: addendum?.addendumNumber || '', + addendumType: addendum?.addendumType || 'scope_change', + title: addendum?.title || '', + description: addendum?.description || '', + effectiveDate: addendum?.effectiveDate ? addendum.effectiveDate.split('T')[0] : '', + newEndDate: addendum?.newEndDate ? addendum.newEndDate.split('T')[0] : undefined, + amountChange: addendum?.amountChange ?? 0, + scopeChanges: addendum?.scopeChanges || '', + notes: addendum?.notes || '', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const payload: CreateAddendumDto = { + ...formData, + newEndDate: formData.newEndDate || undefined, + amountChange: formData.amountChange || undefined, + scopeChanges: formData.scopeChanges || undefined, + notes: formData.notes || undefined, + }; + + if (addendum) { + await updateMutation.mutateAsync({ + contractId, + addendumId: addendum.id, + data: payload, + }); + } else { + await createMutation.mutateAsync({ contractId, data: payload }); + } + onClose(); + }; + + const update = (field: K, value: CreateAddendumDto[K]) => { + setFormData({ ...formData, [field]: value }); + }; + + const isLoading = createMutation.isPending || updateMutation.isPending; + const showDateExtension = ['extension', 'scope_change', 'other'].includes(formData.addendumType); + const showAmountChange = ['amount_increase', 'amount_decrease', 'scope_change', 'other'].includes(formData.addendumType); + + return ( + + + + + } + > +
+ + update('addendumNumber', e.target.value)} + placeholder="ADD-001" + /> + ({ value: o.value, label: o.label }))} + value={formData.addendumType} + onChange={(e) => update('addendumType', e.target.value as AddendumType)} + /> + + + update('title', e.target.value)} + placeholder="Titulo de la addenda" + /> + + update('description', e.target.value)} + placeholder="Descripcion detallada de la addenda..." + rows={3} + /> + + + update('effectiveDate', e.target.value)} + /> + {showDateExtension && ( + update('newEndDate', e.target.value || undefined)} + /> + )} + + + {showAmountChange && ( + update('amountChange', parseFloat(e.target.value) || 0)} + placeholder="Positivo para incremento, negativo para decremento" + step="0.01" + /> + )} + + {formData.addendumType === 'scope_change' && ( + update('scopeChanges', e.target.value)} + placeholder="Describir los cambios en el alcance del contrato..." + rows={3} + /> + )} + + update('notes', e.target.value)} + placeholder="Notas adicionales..." + rows={2} + /> + +
+ ); +} diff --git a/web/src/components/contracts/ContractForm.tsx b/web/src/components/contracts/ContractForm.tsx new file mode 100644 index 0000000..5ed34b7 --- /dev/null +++ b/web/src/components/contracts/ContractForm.tsx @@ -0,0 +1,321 @@ +/** + * ContractForm - Modal para crear/editar contratos + */ + +import { useState } from 'react'; +import { + useCreateContract, + useUpdateContract, + useSubcontractors, +} from '../../hooks/useContracts'; +import type { + Contract, + ContractType, + ClientContractType, + CreateContractDto, +} from '../../types/contracts.types'; +import { + CONTRACT_TYPE_OPTIONS, + CLIENT_CONTRACT_TYPE_OPTIONS, + SUBCONTRACTOR_SPECIALTY_OPTIONS, +} from '../../types/contracts.types'; +import { + Modal, + ModalFooter, + TextInput, + TextareaField, + SelectField, + FormGroup, +} from '../common'; + +interface ContractFormProps { + contract: Contract | null; + onClose: () => void; +} + +export function ContractForm({ contract, onClose }: ContractFormProps) { + const createMutation = useCreateContract(); + const updateMutation = useUpdateContract(); + const { data: subcontractorsData } = useSubcontractors({ status: 'active' }); + + const [formData, setFormData] = useState({ + contractNumber: contract?.contractNumber || '', + name: contract?.name || '', + description: contract?.description || '', + contractType: contract?.contractType || 'client', + clientContractType: contract?.clientContractType || undefined, + clientName: contract?.clientName || '', + clientRfc: contract?.clientRfc || '', + clientAddress: contract?.clientAddress || '', + subcontractorId: contract?.subcontractorId || '', + specialty: contract?.specialty || '', + startDate: contract?.startDate ? contract.startDate.split('T')[0] : '', + endDate: contract?.endDate ? contract.endDate.split('T')[0] : '', + contractAmount: contract?.contractAmount || 0, + currency: contract?.currency || 'MXN', + paymentTerms: contract?.paymentTerms || '', + retentionPercentage: contract?.retentionPercentage ?? 5, + advancePercentage: contract?.advancePercentage ?? 0, + notes: contract?.notes || '', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const payload = { ...formData }; + + // Clean up based on contract type + if (formData.contractType === 'client') { + payload.subcontractorId = undefined; + payload.specialty = undefined; + } else { + payload.clientName = undefined; + payload.clientRfc = undefined; + payload.clientAddress = undefined; + payload.clientContractType = undefined; + } + + if (contract) { + await updateMutation.mutateAsync({ id: contract.id, data: payload }); + } else { + await createMutation.mutateAsync(payload); + } + onClose(); + }; + + const update = (field: K, value: CreateContractDto[K]) => { + setFormData({ ...formData, [field]: value }); + }; + + const isLoading = createMutation.isPending || updateMutation.isPending; + const isClient = formData.contractType === 'client'; + + return ( + + + + + } + > +
+ {/* Basic Info */} +
+

+ Datos Generales +

+ + update('contractNumber', e.target.value)} + placeholder="CONT-2026-001" + /> + ({ value: o.value, label: o.label }))} + value={formData.contractType} + onChange={(e) => update('contractType', e.target.value as ContractType)} + /> + + update('name', e.target.value)} + placeholder="Contrato de construccion de obra X" + className="mt-4" + /> + update('description', e.target.value)} + placeholder="Descripcion detallada del contrato..." + rows={2} + className="mt-4" + /> +
+ + {/* Client/Subcontractor Info */} + {isClient ? ( +
+

+ Datos del Cliente +

+ + update('clientName', e.target.value)} + placeholder="Cliente S.A. de C.V." + /> + ({ value: o.value, label: o.label })), + ]} + value={formData.clientContractType || ''} + onChange={(e) => update('clientContractType', e.target.value as ClientContractType || undefined)} + /> + + + update('clientRfc', e.target.value.toUpperCase())} + placeholder="XAXX010101000" + maxLength={13} + /> + update('clientAddress', e.target.value)} + placeholder="Direccion completa" + /> + +
+ ) : ( +
+

+ Datos del Subcontratista +

+ + ({ + value: s.id, + label: `${s.code} - ${s.businessName}`, + })), + ]} + value={formData.subcontractorId || ''} + onChange={(e) => update('subcontractorId', e.target.value)} + /> + ({ value: o.value, label: o.label })), + ]} + value={formData.specialty || ''} + onChange={(e) => update('specialty', e.target.value)} + /> + +
+ )} + + {/* Dates */} +
+

+ Vigencia +

+ + update('startDate', e.target.value)} + /> + update('endDate', e.target.value)} + /> + +
+ + {/* Financial */} +
+

+ Condiciones Financieras +

+ + update('contractAmount', parseFloat(e.target.value) || 0)} + placeholder="0.00" + min="0" + step="0.01" + /> + update('currency', e.target.value)} + /> + + + update('retentionPercentage', parseFloat(e.target.value) || 0)} + min="0" + max="100" + step="0.1" + /> + update('advancePercentage', parseFloat(e.target.value) || 0)} + min="0" + max="100" + step="0.1" + /> + + update('paymentTerms', e.target.value)} + placeholder="Describir las condiciones de pago del contrato..." + rows={2} + className="mt-4" + /> +
+ + {/* Notes */} + update('notes', e.target.value)} + placeholder="Notas adicionales sobre el contrato..." + rows={3} + /> + +
+ ); +} diff --git a/web/src/components/contracts/PartidaModal.tsx b/web/src/components/contracts/PartidaModal.tsx new file mode 100644 index 0000000..9929eb2 --- /dev/null +++ b/web/src/components/contracts/PartidaModal.tsx @@ -0,0 +1,163 @@ +/** + * PartidaModal - Modal para crear/editar partidas de contrato + */ + +import { useState } from 'react'; +import { + useCreateContractPartida, + useUpdateContractPartida, +} from '../../hooks/useContracts'; +import type { + ContractPartida, + CreateContractPartidaDto, +} from '../../types/contracts.types'; +import { + Modal, + ModalFooter, + TextInput, + FormGroup, +} from '../common'; + +interface PartidaModalProps { + contractId: string; + partida: ContractPartida | null; + onClose: () => void; +} + +export function PartidaModal({ contractId, partida, onClose }: PartidaModalProps) { + const createMutation = useCreateContractPartida(); + const updateMutation = useUpdateContractPartida(); + + const [formData, setFormData] = useState({ + conceptoId: partida?.conceptoId || '', + conceptoCode: partida?.conceptoCode || '', + conceptoDescription: partida?.conceptoDescription || '', + quantity: partida?.quantity || 0, + unitPrice: partida?.unitPrice || 0, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const payload: CreateContractPartidaDto = { + conceptoId: formData.conceptoId, + quantity: formData.quantity, + unitPrice: formData.unitPrice, + }; + + if (partida) { + await updateMutation.mutateAsync({ + contractId, + partidaId: partida.id, + data: { + quantity: formData.quantity, + unitPrice: formData.unitPrice, + }, + }); + } else { + await createMutation.mutateAsync({ contractId, data: payload }); + } + onClose(); + }; + + const update = (field: K, value: typeof formData[K]) => { + setFormData({ ...formData, [field]: value }); + }; + + const isLoading = createMutation.isPending || updateMutation.isPending; + const total = formData.quantity * formData.unitPrice; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + return ( + + + + + } + > +
+ {/* Concepto Selection - In a real app this would be a searchable select */} + update('conceptoId', e.target.value)} + placeholder="UUID del concepto" + disabled={!!partida} + /> + + {partida && ( +
+

Concepto

+

+ {partida.conceptoCode || 'N/A'} +

+

+ {partida.conceptoDescription || '-'} +

+
+ )} + + + update('quantity', parseFloat(e.target.value) || 0)} + placeholder="0.00" + min="0" + step="0.01" + /> + update('unitPrice', parseFloat(e.target.value) || 0)} + placeholder="0.00" + min="0" + step="0.01" + /> + + + {/* Total Calculated */} +
+
+ + Total: + + + {formatCurrency(total)} + +
+
+ +
+ ); +} diff --git a/web/src/components/contracts/index.ts b/web/src/components/contracts/index.ts new file mode 100644 index 0000000..2df0ea6 --- /dev/null +++ b/web/src/components/contracts/index.ts @@ -0,0 +1,7 @@ +/** + * Contracts Components Index + */ + +export { ContractForm } from './ContractForm'; +export { AddendaModal } from './AddendaModal'; +export { PartidaModal } from './PartidaModal'; diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index eec288d..273004e 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -11,3 +11,6 @@ export * from './useToast'; // Utility hooks export { useDebounce, useDebouncedCallback, useDebounceWithImmediate } from './useDebounce'; export { useLocalStorage, useSessionStorage } from './useLocalStorage'; + +// Session management +export { useSession } from './useSession'; diff --git a/web/src/hooks/useBidding.ts b/web/src/hooks/useBidding.ts index 53d6e3d..5cf8ef0 100644 --- a/web/src/hooks/useBidding.ts +++ b/web/src/hooks/useBidding.ts @@ -22,6 +22,11 @@ import { CreateVendorDto, UpdateVendorDto, } from '../services/bidding'; +import { + mockOportunidades, + mockConcursos, + mockProveedores, +} from '../services/mockData.modules'; // ============================================================================ // QUERY KEYS @@ -67,7 +72,18 @@ const handleError = (error: AxiosError) => { export function useOpportunities(filters?: OpportunityFilters) { return useQuery({ queryKey: biddingKeys.opportunities.list(filters), - queryFn: () => opportunitiesApi.list(filters), + queryFn: async () => { + try { + return await opportunitiesApi.list(filters); + } catch { + console.warn('[useOpportunities] API failed, using mock data'); + let items = [...mockOportunidades]; + if (filters?.status) { + items = items.filter(o => o.status === filters.status); + } + return { items, total: items.length }; + } + }, }); } @@ -138,7 +154,18 @@ export function useUpdateOpportunityStatus() { export function useTenders(filters?: TenderFilters) { return useQuery({ queryKey: biddingKeys.tenders.list(filters), - queryFn: () => tendersApi.list(filters), + queryFn: async () => { + try { + return await tendersApi.list(filters); + } catch { + console.warn('[useTenders] API failed, using mock data'); + let items = [...mockConcursos]; + if (filters?.status) { + items = items.filter(t => t.status === filters.status); + } + return { items, total: items.length }; + } + }, }); } @@ -279,7 +306,18 @@ export function useSubmitProposal() { export function useVendors(filters?: VendorFilters) { return useQuery({ queryKey: biddingKeys.vendors.list(filters), - queryFn: () => vendorsApi.list(filters), + queryFn: async () => { + try { + return await vendorsApi.list(filters); + } catch { + console.warn('[useVendors] API failed, using mock data'); + let items = [...mockProveedores]; + if (filters?.isActive !== undefined) { + items = items.filter(v => v.isActive === filters.isActive); + } + return { items, total: items.length }; + } + }, }); } diff --git a/web/src/hooks/useConstruccion.ts b/web/src/hooks/useConstruccion.ts index def6760..05e3a88 100644 --- a/web/src/hooks/useConstruccion.ts +++ b/web/src/hooks/useConstruccion.ts @@ -33,35 +33,47 @@ import { CreatePrototipoDto, UpdatePrototipoDto, } from '../services/construccion/prototipos.api'; +import { + mockFraccionamientos, + mockEtapas, + mockManzanas, + mockLotes, + mockPrototipos, + filterFraccionamientos, + filterEtapas, + filterManzanas, + filterLotes, + filterPrototipos, +} from '../services/mockData.construccion'; // Query Keys export const construccionKeys = { fraccionamientos: { all: ['construccion', 'fraccionamientos'] as const, - list: (filters?: FraccionamientoFilters) => + list: (filters?: FraccionamientoFilters & { search?: string }) => [...construccionKeys.fraccionamientos.all, 'list', filters] as const, detail: (id: string) => [...construccionKeys.fraccionamientos.all, 'detail', id] as const, }, etapas: { all: ['construccion', 'etapas'] as const, - list: (filters?: EtapaFilters) => [...construccionKeys.etapas.all, 'list', filters] as const, + list: (filters?: EtapaFilters & { search?: string }) => [...construccionKeys.etapas.all, 'list', filters] as const, detail: (id: string) => [...construccionKeys.etapas.all, 'detail', id] as const, }, manzanas: { all: ['construccion', 'manzanas'] as const, - list: (filters?: ManzanaFilters) => + list: (filters?: ManzanaFilters & { search?: string }) => [...construccionKeys.manzanas.all, 'list', filters] as const, detail: (id: string) => [...construccionKeys.manzanas.all, 'detail', id] as const, }, lotes: { all: ['construccion', 'lotes'] as const, - list: (filters?: LoteFilters) => [...construccionKeys.lotes.all, 'list', filters] as const, + list: (filters?: LoteFilters & { search?: string }) => [...construccionKeys.lotes.all, 'list', filters] as const, detail: (id: string) => [...construccionKeys.lotes.all, 'detail', id] as const, stats: (manzanaId?: string) => [...construccionKeys.lotes.all, 'stats', manzanaId] as const, }, prototipos: { all: ['construccion', 'prototipos'] as const, - list: (filters?: PrototipoFilters) => + list: (filters?: PrototipoFilters & { search?: string }) => [...construccionKeys.prototipos.all, 'list', filters] as const, detail: (id: string) => [...construccionKeys.prototipos.all, 'detail', id] as const, }, @@ -75,17 +87,34 @@ const handleError = (error: AxiosError) => { // ==================== FRACCIONAMIENTOS ==================== -export function useFraccionamientos(filters?: FraccionamientoFilters) { +export function useFraccionamientos(filters?: FraccionamientoFilters & { search?: string }) { return useQuery({ queryKey: construccionKeys.fraccionamientos.list(filters), - queryFn: () => fraccionamientosApi.list(filters), + queryFn: async () => { + try { + return await fraccionamientosApi.list(filters); + } catch { + console.warn('[useFraccionamientos] API failed, using mock data'); + const items = filterFraccionamientos(mockFraccionamientos, filters); + return { items, total: items.length }; + } + }, }); } export function useFraccionamiento(id: string) { return useQuery({ queryKey: construccionKeys.fraccionamientos.detail(id), - queryFn: () => fraccionamientosApi.get(id), + queryFn: async () => { + try { + return await fraccionamientosApi.get(id); + } catch { + console.warn('[useFraccionamiento] API failed, using mock data'); + const item = mockFraccionamientos.find((f) => f.id === id); + if (!item) throw new Error('Not found'); + return item; + } + }, enabled: !!id, }); } @@ -130,17 +159,34 @@ export function useDeleteFraccionamiento() { // ==================== ETAPAS ==================== -export function useEtapas(filters?: EtapaFilters) { +export function useEtapas(filters?: EtapaFilters & { search?: string }) { return useQuery({ queryKey: construccionKeys.etapas.list(filters), - queryFn: () => etapasApi.list(filters), + queryFn: async () => { + try { + return await etapasApi.list(filters); + } catch { + console.warn('[useEtapas] API failed, using mock data'); + const items = filterEtapas(mockEtapas, filters); + return { items, total: items.length }; + } + }, }); } export function useEtapa(id: string) { return useQuery({ queryKey: construccionKeys.etapas.detail(id), - queryFn: () => etapasApi.get(id), + queryFn: async () => { + try { + return await etapasApi.get(id); + } catch { + console.warn('[useEtapa] API failed, using mock data'); + const item = mockEtapas.find((e) => e.id === id); + if (!item) throw new Error('Not found'); + return item; + } + }, enabled: !!id, }); } @@ -184,17 +230,34 @@ export function useDeleteEtapa() { // ==================== MANZANAS ==================== -export function useManzanas(filters?: ManzanaFilters) { +export function useManzanas(filters?: ManzanaFilters & { search?: string }) { return useQuery({ queryKey: construccionKeys.manzanas.list(filters), - queryFn: () => manzanasApi.list(filters), + queryFn: async () => { + try { + return await manzanasApi.list(filters); + } catch { + console.warn('[useManzanas] API failed, using mock data'); + const items = filterManzanas(mockManzanas, filters); + return { items, total: items.length }; + } + }, }); } export function useManzana(id: string) { return useQuery({ queryKey: construccionKeys.manzanas.detail(id), - queryFn: () => manzanasApi.get(id), + queryFn: async () => { + try { + return await manzanasApi.get(id); + } catch { + console.warn('[useManzana] API failed, using mock data'); + const item = mockManzanas.find((m) => m.id === id); + if (!item) throw new Error('Not found'); + return item; + } + }, enabled: !!id, }); } @@ -239,17 +302,34 @@ export function useDeleteManzana() { // ==================== LOTES ==================== -export function useLotes(filters?: LoteFilters) { +export function useLotes(filters?: LoteFilters & { search?: string }) { return useQuery({ queryKey: construccionKeys.lotes.list(filters), - queryFn: () => lotesApi.list(filters), + queryFn: async () => { + try { + return await lotesApi.list(filters); + } catch { + console.warn('[useLotes] API failed, using mock data'); + const items = filterLotes(mockLotes, filters); + return { items, total: items.length }; + } + }, }); } export function useLote(id: string) { return useQuery({ queryKey: construccionKeys.lotes.detail(id), - queryFn: () => lotesApi.get(id), + queryFn: async () => { + try { + return await lotesApi.get(id); + } catch { + console.warn('[useLote] API failed, using mock data'); + const item = mockLotes.find((l) => l.id === id); + if (!item) throw new Error('Not found'); + return item; + } + }, enabled: !!id, }); } @@ -257,7 +337,22 @@ export function useLote(id: string) { export function useLoteStats(manzanaId?: string) { return useQuery({ queryKey: construccionKeys.lotes.stats(manzanaId), - queryFn: () => lotesApi.getStats(manzanaId), + queryFn: async () => { + try { + return await lotesApi.getStats(manzanaId); + } catch { + console.warn('[useLoteStats] API failed, using mock data'); + const items = manzanaId ? mockLotes.filter((l) => l.manzanaId === manzanaId) : mockLotes; + return { + total: items.length, + available: items.filter((l) => l.status === 'available').length, + reserved: items.filter((l) => l.status === 'reserved').length, + sold: items.filter((l) => l.status === 'sold').length, + inConstruction: items.filter((l) => l.status === 'in_construction').length, + blocked: items.filter((l) => l.status === 'blocked').length, + }; + } + }, }); } @@ -329,17 +424,34 @@ export function useAssignPrototipo() { // ==================== PROTOTIPOS ==================== -export function usePrototipos(filters?: PrototipoFilters) { +export function usePrototipos(filters?: PrototipoFilters & { search?: string }) { return useQuery({ queryKey: construccionKeys.prototipos.list(filters), - queryFn: () => prototiposApi.list(filters), + queryFn: async () => { + try { + return await prototiposApi.list(filters); + } catch { + console.warn('[usePrototipos] API failed, using mock data'); + const items = filterPrototipos(mockPrototipos, filters); + return { items, total: items.length }; + } + }, }); } export function usePrototipo(id: string) { return useQuery({ queryKey: construccionKeys.prototipos.detail(id), - queryFn: () => prototiposApi.get(id), + queryFn: async () => { + try { + return await prototiposApi.get(id); + } catch { + console.warn('[usePrototipo] API failed, using mock data'); + const item = mockPrototipos.find((p) => p.id === id); + if (!item) throw new Error('Not found'); + return item; + } + }, enabled: !!id, }); } diff --git a/web/src/hooks/useFinance.ts b/web/src/hooks/useFinance.ts new file mode 100644 index 0000000..6db5b1e --- /dev/null +++ b/web/src/hooks/useFinance.ts @@ -0,0 +1,649 @@ +/** + * useFinance Hook - Contabilidad, CxC, CxP, Flujo de Efectivo, Facturación + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import toast from 'react-hot-toast'; +import type { ApiError } from '../services/api'; +import { + accountsApi, + entriesApi, + accountingReportsApi, + arApi, + apApi, + cashFlowApi, + invoicesApi, +} from '../services/finance'; +import type { + AccountFilters, + CreateAccountDto, + UpdateAccountDto, + EntryFilters, + CreateEntryDto, + UpdateEntryDto, + ARFilters, + RegisterARPaymentDto, + APFilters, + ScheduleAPPaymentDto, + RegisterAPPaymentDto, + InvoiceFilters, + CreateInvoiceDto, + UpdateInvoiceDto, +} from '../types/finance.types'; + +// ============================================================================ +// QUERY KEYS +// ============================================================================ + +export const financeKeys = { + // Accounts + accounts: { + all: ['finance', 'accounts'] as const, + list: (filters?: AccountFilters) => [...financeKeys.accounts.all, 'list', filters] as const, + tree: () => [...financeKeys.accounts.all, 'tree'] as const, + detail: (id: string) => [...financeKeys.accounts.all, 'detail', id] as const, + }, + // Entries + entries: { + all: ['finance', 'entries'] as const, + list: (filters?: EntryFilters) => [...financeKeys.entries.all, 'list', filters] as const, + detail: (id: string) => [...financeKeys.entries.all, 'detail', id] as const, + }, + // Reports + reports: { + all: ['finance', 'reports'] as const, + trialBalance: (period: string) => [...financeKeys.reports.all, 'trial-balance', period] as const, + ledger: (accountId: string, period: string) => [...financeKeys.reports.all, 'ledger', accountId, period] as const, + }, + // AR + ar: { + all: ['finance', 'ar'] as const, + list: (filters?: ARFilters) => [...financeKeys.ar.all, 'list', filters] as const, + detail: (id: string) => [...financeKeys.ar.all, 'detail', id] as const, + stats: () => [...financeKeys.ar.all, 'stats'] as const, + aging: () => [...financeKeys.ar.all, 'aging'] as const, + }, + // AP + ap: { + all: ['finance', 'ap'] as const, + list: (filters?: APFilters) => [...financeKeys.ap.all, 'list', filters] as const, + detail: (id: string) => [...financeKeys.ap.all, 'detail', id] as const, + stats: () => [...financeKeys.ap.all, 'stats'] as const, + aging: () => [...financeKeys.ap.all, 'aging'] as const, + scheduled: () => [...financeKeys.ap.all, 'scheduled'] as const, + calendar: (month: string) => [...financeKeys.ap.all, 'calendar', month] as const, + }, + // Cash Flow + cashFlow: { + all: ['finance', 'cash-flow'] as const, + actual: (period: string) => [...financeKeys.cashFlow.all, 'actual', period] as const, + projected: () => [...financeKeys.cashFlow.all, 'projected'] as const, + byCategory: (period: string) => [...financeKeys.cashFlow.all, 'by-category', period] as const, + monthly: (year: number) => [...financeKeys.cashFlow.all, 'monthly', year] as const, + }, + // Invoices + invoices: { + all: ['finance', 'invoices'] as const, + list: (filters?: InvoiceFilters) => [...financeKeys.invoices.all, 'list', filters] as const, + detail: (id: string) => [...financeKeys.invoices.all, 'detail', id] as const, + stats: () => [...financeKeys.invoices.all, 'stats'] as const, + dashboard: () => [...financeKeys.invoices.all, 'dashboard'] as const, + }, +}; + +// ============================================================================ +// ERROR HANDLER +// ============================================================================ + +const handleError = (error: AxiosError) => { + const message = error.response?.data?.message || 'Ha ocurrido un error'; + toast.error(message); +}; + +// ============================================================================ +// ACCOUNTS HOOKS +// ============================================================================ + +export function useAccounts(filters?: AccountFilters) { + return useQuery({ + queryKey: financeKeys.accounts.list(filters), + queryFn: () => accountsApi.list(filters), + }); +} + +export function useAccountsTree() { + return useQuery({ + queryKey: financeKeys.accounts.tree(), + queryFn: () => accountsApi.tree(), + }); +} + +export function useAccount(id: string) { + return useQuery({ + queryKey: financeKeys.accounts.detail(id), + queryFn: () => accountsApi.get(id), + enabled: !!id, + }); +} + +export function useCreateAccount() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateAccountDto) => accountsApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: financeKeys.accounts.all }); + toast.success('Cuenta creada exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateAccount() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateAccountDto }) => + accountsApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: financeKeys.accounts.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.accounts.detail(id) }); + toast.success('Cuenta actualizada'); + }, + onError: handleError, + }); +} + +export function useDeleteAccount() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => accountsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: financeKeys.accounts.all }); + toast.success('Cuenta eliminada'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// ENTRIES HOOKS +// ============================================================================ + +export function useEntries(filters?: EntryFilters) { + return useQuery({ + queryKey: financeKeys.entries.list(filters), + queryFn: () => entriesApi.list(filters), + }); +} + +export function useEntry(id: string) { + return useQuery({ + queryKey: financeKeys.entries.detail(id), + queryFn: () => entriesApi.get(id), + enabled: !!id, + }); +} + +export function useCreateEntry() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateEntryDto) => entriesApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: financeKeys.entries.all }); + toast.success('Póliza creada exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateEntry() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateEntryDto }) => + entriesApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: financeKeys.entries.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.entries.detail(id) }); + toast.success('Póliza actualizada'); + }, + onError: handleError, + }); +} + +export function useDeleteEntry() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => entriesApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: financeKeys.entries.all }); + toast.success('Póliza eliminada'); + }, + onError: handleError, + }); +} + +export function useSubmitEntry() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => entriesApi.submit(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: financeKeys.entries.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.entries.detail(id) }); + toast.success('Póliza enviada a revisión'); + }, + onError: handleError, + }); +} + +export function useApproveEntry() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => entriesApi.approve(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: financeKeys.entries.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.entries.detail(id) }); + toast.success('Póliza aprobada'); + }, + onError: handleError, + }); +} + +export function usePostEntry() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => entriesApi.post(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: financeKeys.entries.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.entries.detail(id) }); + queryClient.invalidateQueries({ queryKey: financeKeys.reports.all }); + toast.success('Póliza contabilizada'); + }, + onError: handleError, + }); +} + +export function useCancelEntry() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => entriesApi.cancel(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: financeKeys.entries.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.entries.detail(id) }); + toast.success('Póliza cancelada'); + }, + onError: handleError, + }); +} + +export function useReverseEntry() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => entriesApi.reverse(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: financeKeys.entries.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.reports.all }); + toast.success('Póliza reversada'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// REPORTS HOOKS +// ============================================================================ + +export function useTrialBalance(period: string) { + return useQuery({ + queryKey: financeKeys.reports.trialBalance(period), + queryFn: () => accountingReportsApi.trialBalance(period), + enabled: !!period, + }); +} + +export function useAccountLedger(accountId: string, period: string) { + return useQuery({ + queryKey: financeKeys.reports.ledger(accountId, period), + queryFn: () => accountingReportsApi.accountLedger(accountId, period), + enabled: !!accountId && !!period, + }); +} + +// ============================================================================ +// AR HOOKS +// ============================================================================ + +export function useAR(filters?: ARFilters) { + return useQuery({ + queryKey: financeKeys.ar.list(filters), + queryFn: () => arApi.list(filters), + }); +} + +export function useARDetail(id: string) { + return useQuery({ + queryKey: financeKeys.ar.detail(id), + queryFn: () => arApi.get(id), + enabled: !!id, + }); +} + +export function useARStats() { + return useQuery({ + queryKey: financeKeys.ar.stats(), + queryFn: () => arApi.stats(), + }); +} + +export function useARAging() { + return useQuery({ + queryKey: financeKeys.ar.aging(), + queryFn: () => arApi.aging(), + }); +} + +export function useRegisterARPayment() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: RegisterARPaymentDto }) => + arApi.registerPayment(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: financeKeys.ar.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.ar.detail(id) }); + toast.success('Pago registrado exitosamente'); + }, + onError: handleError, + }); +} + +export function useSendARReminder() { + return useMutation({ + mutationFn: (id: string) => arApi.sendReminder(id), + onSuccess: () => { + toast.success('Recordatorio enviado'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// AP HOOKS +// ============================================================================ + +export function useAP(filters?: APFilters) { + return useQuery({ + queryKey: financeKeys.ap.list(filters), + queryFn: () => apApi.list(filters), + }); +} + +export function useAPDetail(id: string) { + return useQuery({ + queryKey: financeKeys.ap.detail(id), + queryFn: () => apApi.get(id), + enabled: !!id, + }); +} + +export function useAPStats() { + return useQuery({ + queryKey: financeKeys.ap.stats(), + queryFn: () => apApi.stats(), + }); +} + +export function useAPAging() { + return useQuery({ + queryKey: financeKeys.ap.aging(), + queryFn: () => apApi.aging(), + }); +} + +export function useAPScheduled() { + return useQuery({ + queryKey: financeKeys.ap.scheduled(), + queryFn: () => apApi.getScheduled(), + }); +} + +export function useAPCalendar(month: string) { + return useQuery({ + queryKey: financeKeys.ap.calendar(month), + queryFn: () => apApi.getCalendar(month), + enabled: !!month, + }); +} + +export function useScheduleAPPayment() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: ScheduleAPPaymentDto }) => + apApi.schedule(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: financeKeys.ap.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.ap.detail(id) }); + toast.success('Pago programado'); + }, + onError: handleError, + }); +} + +export function useRegisterAPPayment() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: RegisterAPPaymentDto }) => + apApi.registerPayment(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: financeKeys.ap.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.ap.detail(id) }); + queryClient.invalidateQueries({ queryKey: financeKeys.cashFlow.all }); + toast.success('Pago registrado exitosamente'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// CASH FLOW HOOKS +// ============================================================================ + +export function useCashFlowActual(period: string) { + return useQuery({ + queryKey: financeKeys.cashFlow.actual(period), + queryFn: () => cashFlowApi.actual(period), + enabled: !!period, + }); +} + +export function useCashFlowProjected() { + return useQuery({ + queryKey: financeKeys.cashFlow.projected(), + queryFn: () => cashFlowApi.projected(), + }); +} + +export function useCashFlowByCategory(period: string) { + return useQuery({ + queryKey: financeKeys.cashFlow.byCategory(period), + queryFn: () => cashFlowApi.byCategory(period), + enabled: !!period, + }); +} + +export function useCashFlowMonthly(year: number) { + return useQuery({ + queryKey: financeKeys.cashFlow.monthly(year), + queryFn: () => cashFlowApi.monthly(year), + enabled: !!year, + }); +} + +// ============================================================================ +// INVOICES HOOKS +// ============================================================================ + +export function useInvoices(filters?: InvoiceFilters) { + return useQuery({ + queryKey: financeKeys.invoices.list(filters), + queryFn: () => invoicesApi.list(filters), + }); +} + +export function useInvoice(id: string) { + return useQuery({ + queryKey: financeKeys.invoices.detail(id), + queryFn: () => invoicesApi.get(id), + enabled: !!id, + }); +} + +export function useInvoiceStats() { + return useQuery({ + queryKey: financeKeys.invoices.stats(), + queryFn: () => invoicesApi.stats(), + }); +} + +export function useInvoiceDashboard() { + return useQuery({ + queryKey: financeKeys.invoices.dashboard(), + queryFn: () => invoicesApi.dashboard(), + }); +} + +export function useCreateInvoice() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateInvoiceDto) => invoicesApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all }); + toast.success('Factura creada exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateInvoice() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateInvoiceDto }) => + invoicesApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.detail(id) }); + toast.success('Factura actualizada'); + }, + onError: handleError, + }); +} + +export function useDeleteInvoice() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => invoicesApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all }); + toast.success('Factura eliminada'); + }, + onError: handleError, + }); +} + +export function useSendInvoice() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => invoicesApi.send(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.detail(id) }); + toast.success('Factura enviada'); + }, + onError: handleError, + }); +} + +export function useMarkInvoicePaid() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data?: { date: string; amount: number; method: string; reference?: string } }) => + invoicesApi.markPaid(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.detail(id) }); + queryClient.invalidateQueries({ queryKey: financeKeys.ar.all }); + toast.success('Factura marcada como pagada'); + }, + onError: handleError, + }); +} + +export function useCancelInvoice() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, reason }: { id: string; reason?: string }) => + invoicesApi.cancel(id, reason), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.detail(id) }); + toast.success('Factura cancelada'); + }, + onError: handleError, + }); +} + +export function useStampInvoice() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => invoicesApi.stamp(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.invoices.detail(id) }); + toast.success('Factura timbrada exitosamente'); + }, + onError: handleError, + }); +} + +export function useDownloadInvoicePdf() { + return useMutation({ + mutationFn: (id: string) => invoicesApi.getPdf(id), + onError: handleError, + }); +} + +// ============================================================================ +// ALIASES FOR BACKWARD COMPATIBILITY +// ============================================================================ + +// Entries aliases +export const useAccountingEntries = useEntries; +export const useDeleteAccountingEntry = useDeleteEntry; +export const usePostAccountingEntry = usePostEntry; +export function useReverseAccountingEntry() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id }: { id: string; reason?: string }) => entriesApi.reverse(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: financeKeys.entries.all }); + queryClient.invalidateQueries({ queryKey: financeKeys.reports.all }); + toast.success('Póliza reversada'); + }, + onError: handleError, + }); +} + +// Cash Flow aliases +export function useCashFlowSummary(period: string) { + return useQuery({ + queryKey: [...financeKeys.cashFlow.all, 'summary', period] as const, + queryFn: () => cashFlowApi.actual(period), + enabled: !!period, + }); +} + +export function useCashFlowForecast(days: number) { + return useQuery({ + queryKey: [...financeKeys.cashFlow.all, 'forecast', days] as const, + queryFn: () => cashFlowApi.projected(Math.ceil(days / 30)), + enabled: !!days, + }); +} diff --git a/web/src/hooks/usePresupuestos.ts b/web/src/hooks/usePresupuestos.ts index 67112ca..67baa94 100644 --- a/web/src/hooks/usePresupuestos.ts +++ b/web/src/hooks/usePresupuestos.ts @@ -23,6 +23,7 @@ import { CreateGeneradorDto, UpdateGeneradorDto, } from '../services/presupuestos'; +import { mockConceptos, mockPresupuestos, mockEstimaciones } from '../services/mockData.modules'; // ==================== QUERY KEYS ==================== @@ -54,17 +55,44 @@ const handleError = (error: AxiosError) => { // ==================== CONCEPTOS ==================== -export function useConceptos(filters?: ConceptoFilters) { +export function useConceptos(filters?: ConceptoFilters & { search?: string }) { return useQuery({ queryKey: presupuestosKeys.conceptos.list(filters), - queryFn: () => conceptosApi.list(filters), + queryFn: async () => { + try { + return await conceptosApi.list(filters); + } catch { + console.warn('[useConceptos] API failed, using mock data'); + let items = [...mockConceptos]; + if (filters?.tipo) { + items = items.filter(c => c.tipo === filters.tipo); + } + if (filters?.search) { + const searchLower = filters.search.toLowerCase(); + items = items.filter(c => + c.descripcion.toLowerCase().includes(searchLower) || + c.codigo.toLowerCase().includes(searchLower) + ); + } + return { items, total: items.length }; + } + }, }); } export function useConcepto(id: string) { return useQuery({ queryKey: presupuestosKeys.conceptos.detail(id), - queryFn: () => conceptosApi.get(id), + queryFn: async () => { + try { + return await conceptosApi.get(id); + } catch { + console.warn('[useConcepto] API failed, using mock data'); + const item = mockConceptos.find(c => c.id === id); + if (!item) throw new Error('Not found'); + return item; + } + }, enabled: !!id, }); } @@ -72,7 +100,20 @@ export function useConcepto(id: string) { export function useConceptosTree(rootId?: string) { return useQuery({ queryKey: presupuestosKeys.conceptos.tree(rootId), - queryFn: () => conceptosApi.getTree(rootId), + queryFn: async () => { + try { + return await conceptosApi.getTree(rootId); + } catch { + console.warn('[useConceptosTree] API failed, using mock data'); + // Return a simple tree structure from mock data + return mockConceptos + .filter(c => !c.parentId) + .map(c => ({ + ...c, + children: mockConceptos.filter(child => child.parentId === c.id), + })); + } + }, }); } @@ -116,10 +157,28 @@ export function useDeleteConcepto() { // ==================== PRESUPUESTOS ==================== -export function usePresupuestos(filters?: PresupuestoFilters) { +export function usePresupuestos(filters?: PresupuestoFilters & { search?: string }) { return useQuery({ queryKey: presupuestosKeys.presupuestos.list(filters), - queryFn: () => presupuestosApi.list(filters), + queryFn: async () => { + try { + return await presupuestosApi.list(filters); + } catch { + console.warn('[usePresupuestos] API failed, using mock data'); + let items = [...mockPresupuestos]; + if (filters?.estado) { + items = items.filter(p => p.estado === filters.estado); + } + if (filters?.search) { + const searchLower = filters.search.toLowerCase(); + items = items.filter(p => + p.nombre.toLowerCase().includes(searchLower) || + p.codigo.toLowerCase().includes(searchLower) + ); + } + return { items, total: items.length }; + } + }, }); } @@ -322,10 +381,24 @@ export function useExportPresupuestoExcel() { // ==================== ESTIMACIONES ==================== -export function useEstimaciones(filters?: EstimacionFilters) { +export function useEstimaciones(filters?: EstimacionFilters & { search?: string }) { return useQuery({ queryKey: presupuestosKeys.estimaciones.list(filters), - queryFn: () => estimacionesApi.list(filters), + queryFn: async () => { + try { + return await estimacionesApi.list(filters); + } catch { + console.warn('[useEstimaciones] API failed, using mock data'); + let items = [...mockEstimaciones]; + if (filters?.presupuestoId) { + items = items.filter(e => e.presupuestoId === filters.presupuestoId); + } + if (filters?.estado) { + items = items.filter(e => e.estado === filters.estado); + } + return { items, total: items.length }; + } + }, }); } diff --git a/web/src/hooks/useReports.ts b/web/src/hooks/useReports.ts index 1865f70..39e6d2d 100644 --- a/web/src/hooks/useReports.ts +++ b/web/src/hooks/useReports.ts @@ -8,6 +8,14 @@ import { ProjectSummaryFilters, AlertFilters, } from '../services/reports'; +import { + mockDashboardStats, + mockProjectsSummary, + mockAlerts, + getMockProjectKPIs, + getMockEarnedValue, + getMockSCurveData, +} from '../services/mockData'; // ============================================================================= // Query Keys Factory @@ -43,11 +51,19 @@ const handleError = (error: AxiosError) => { /** * Get Earned Value metrics for a specific project * Returns SPI, CPI, EV, PV, AC, and other EVM indicators + * Falls back to mock data on error */ export function useEarnedValue(projectId: string, params?: DateRangeParams) { return useQuery({ queryKey: reportsKeys.earnedValue(projectId, params), - queryFn: () => reportsApi.getEarnedValue(projectId, params), + queryFn: async () => { + try { + return await reportsApi.getEarnedValue(projectId, params); + } catch { + console.warn('[useEarnedValue] API failed, using mock data'); + return getMockEarnedValue(projectId); + } + }, enabled: !!projectId, }); } @@ -55,11 +71,19 @@ export function useEarnedValue(projectId: string, params?: DateRangeParams) { /** * Get S-Curve data for a specific project * Returns time series data for planned vs actual progress + * Falls back to mock data on error */ export function useSCurveData(projectId: string, params?: DateRangeParams) { return useQuery({ queryKey: reportsKeys.sCurve(projectId, params), - queryFn: () => reportsApi.getSCurveData(projectId, params), + queryFn: async () => { + try { + return await reportsApi.getSCurveData(projectId, params); + } catch { + console.warn('[useSCurveData] API failed, using mock data'); + return getMockSCurveData(projectId); + } + }, enabled: !!projectId, }); } @@ -71,32 +95,61 @@ export function useSCurveData(projectId: string, params?: DateRangeParams) { /** * Get summary of all projects with their KPIs * Supports filtering by status and search + * Falls back to mock data on error */ export function useProjectsSummary(filters?: ProjectSummaryFilters) { return useQuery({ queryKey: reportsKeys.projectsSummary(filters), - queryFn: () => reportsApi.getProjectsSummary(filters), + queryFn: async () => { + try { + return await reportsApi.getProjectsSummary(filters); + } catch { + // Return mock data on error + console.warn('[useProjectsSummary] API failed, using mock data'); + let items = mockProjectsSummary; + if (filters?.status) { + items = items.filter(p => p.status === filters.status); + } + return { items, total: items.length }; + } + }, }); } /** * Get general dashboard statistics * Returns aggregate metrics for all projects + * Falls back to mock data on error */ export function useDashboardStats() { return useQuery({ queryKey: reportsKeys.dashboardStats(), - queryFn: () => reportsApi.getDashboardStats(), + queryFn: async () => { + try { + return await reportsApi.getDashboardStats(); + } catch { + console.warn('[useDashboardStats] API failed, using mock data'); + return mockDashboardStats; + } + }, }); } /** * Get KPIs for a specific project + * Falls back to mock data on error */ export function useProjectKPIs(projectId: string, params?: DateRangeParams) { return useQuery({ queryKey: reportsKeys.projectKPIs(projectId, params), - queryFn: () => reportsApi.getProjectKPIs(projectId, params), + queryFn: async () => { + try { + return await reportsApi.getProjectKPIs(projectId, params); + } catch { + console.warn('[useProjectKPIs] API failed, using mock data'); + return getMockProjectKPIs(projectId); + } + }, enabled: !!projectId, }); } @@ -108,11 +161,33 @@ export function useProjectKPIs(projectId: string, params?: DateRangeParams) { /** * Get active alerts * Supports filtering by severity, project, and type + * Falls back to mock data on error */ export function useAlerts(filters?: AlertFilters) { return useQuery({ queryKey: reportsKeys.alerts(filters), - queryFn: () => reportsApi.getAlerts(filters), + queryFn: async () => { + try { + return await reportsApi.getAlerts(filters); + } catch { + console.warn('[useAlerts] API failed, using mock data'); + let items = [...mockAlerts]; + if (filters?.acknowledged !== undefined) { + // acknowledged=false means filter for items without acknowledgedAt + // acknowledged=true means filter for items with acknowledgedAt + items = items.filter(a => + filters.acknowledged ? !!a.acknowledgedAt : !a.acknowledgedAt + ); + } + if (filters?.severity) { + items = items.filter(a => a.severity === filters.severity); + } + if (filters?.projectId) { + items = items.filter(a => a.projectId === filters.projectId); + } + return { items, total: items.length }; + } + }, }); } diff --git a/web/src/hooks/useSession.ts b/web/src/hooks/useSession.ts new file mode 100644 index 0000000..7551795 --- /dev/null +++ b/web/src/hooks/useSession.ts @@ -0,0 +1,160 @@ +/** + * useSession Hook + * Manages session lifecycle, automatic refresh, and expiration warnings + * Based on gamilit implementation + */ + +import { useEffect, useCallback, useRef } from 'react'; +import { useAuthStore } from '../stores/authStore'; +import { refreshToken as refreshTokenApi } from '../services/auth'; +import toast from 'react-hot-toast'; + +// Check session every minute +const SESSION_CHECK_INTERVAL = 60 * 1000; + +// Warn user 5 minutes before expiration +const SESSION_WARNING_THRESHOLD = 5 * 60 * 1000; + +// Refresh token if less than 1 hour remaining +const TOKEN_REFRESH_THRESHOLD = 60 * 60 * 1000; + +interface UseSessionOptions { + /** Enable automatic session refresh */ + autoRefresh?: boolean; + /** Show warning before session expires */ + showExpirationWarning?: boolean; + /** Callback when session expires */ + onSessionExpired?: () => void; +} + +export function useSession(options: UseSessionOptions = {}) { + const { + autoRefresh = true, + showExpirationWarning = true, + onSessionExpired, + } = options; + + const { + isAuthenticated, + sessionExpiresAt, + refreshToken, + checkSession, + setTokens, + extendSession, + logout, + } = useAuthStore(); + + const warningShownRef = useRef(false); + const isRefreshingRef = useRef(false); + + // Refresh the session token + const refreshSession = useCallback(async () => { + if (isRefreshingRef.current || !refreshToken) { + return false; + } + + isRefreshingRef.current = true; + + try { + const response = await refreshTokenApi(refreshToken); + setTokens(response.accessToken, response.refreshToken); + warningShownRef.current = false; + return true; + } catch (error) { + console.error('[useSession] Token refresh failed:', error); + return false; + } finally { + isRefreshingRef.current = false; + } + }, [refreshToken, setTokens]); + + // Handle session expiration + const handleSessionExpired = useCallback(() => { + toast.error('Tu sesión ha expirado. Por favor, inicia sesión nuevamente.'); + logout(); + onSessionExpired?.(); + }, [logout, onSessionExpired]); + + // Check session and refresh if needed + useEffect(() => { + if (!isAuthenticated) { + return; + } + + const checkAndRefresh = async () => { + // Validate session + const isValid = checkSession(); + + if (!isValid) { + handleSessionExpired(); + return; + } + + // Calculate time until expiration + if (sessionExpiresAt) { + const timeUntilExpiry = sessionExpiresAt - Date.now(); + + // Show warning if about to expire + if ( + showExpirationWarning && + timeUntilExpiry <= SESSION_WARNING_THRESHOLD && + timeUntilExpiry > 0 && + !warningShownRef.current + ) { + warningShownRef.current = true; + const minutesLeft = Math.ceil(timeUntilExpiry / 60000); + toast( + `Tu sesión expirará en ${minutesLeft} minutos. Guarda tu trabajo.`, + { + icon: '⚠️', + duration: 10000, + } + ); + } + + // Auto-refresh if threshold reached + if (autoRefresh && timeUntilExpiry <= TOKEN_REFRESH_THRESHOLD && timeUntilExpiry > 0) { + const success = await refreshSession(); + if (!success) { + // Refresh failed - session will expire + console.warn('[useSession] Auto-refresh failed'); + } + } + } + }; + + // Initial check + checkAndRefresh(); + + // Set up interval for periodic checks + const interval = setInterval(checkAndRefresh, SESSION_CHECK_INTERVAL); + + return () => { + clearInterval(interval); + }; + }, [ + isAuthenticated, + sessionExpiresAt, + checkSession, + refreshSession, + handleSessionExpired, + autoRefresh, + showExpirationWarning, + ]); + + // Reset warning flag when session is extended + useEffect(() => { + warningShownRef.current = false; + }, [sessionExpiresAt]); + + return { + isAuthenticated, + sessionExpiresAt, + checkSession, + refreshSession, + extendSession, + timeUntilExpiry: sessionExpiresAt ? sessionExpiresAt - Date.now() : null, + }; +} + +export default useSession; diff --git a/web/src/layouts/AdminLayout.tsx b/web/src/layouts/AdminLayout.tsx index 649af68..0224952 100644 --- a/web/src/layouts/AdminLayout.tsx +++ b/web/src/layouts/AdminLayout.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { Link, Outlet, useLocation } from 'react-router-dom'; import { Building2, @@ -9,7 +9,6 @@ import { Menu, X, LogOut, - User, ChevronDown, ChevronRight, LayoutDashboard, @@ -31,10 +30,15 @@ import { Wallet, PiggyBank, ArrowLeftRight, + Settings, + UserCircle, } from 'lucide-react'; import clsx from 'clsx'; import { useAuthStore } from '../stores/authStore'; +import { useLogout } from '../hooks/useAuth'; +import { useSession } from '../hooks/useSession'; import { ThemeToggle } from '../components/theme'; +import { LoadingSpinner } from '../components/common/LoadingSpinner'; interface NavItem { label: string; @@ -130,11 +134,66 @@ export function AdminLayout() { return initial; }); const location = useLocation(); - const { user, logout } = useAuthStore(); + const user = useAuthStore((state) => state.user); + const userMenuRef = useRef(null); + + // Use logout hook for proper cleanup + const logoutMutation = useLogout(); + + // Initialize session management + useSession({ + autoRefresh: true, + showExpirationWarning: true, + }); const handleLogout = () => { - logout(); - window.location.href = '/auth/login'; + setUserMenuOpen(false); + logoutMutation.mutate(); + }; + + // Close user menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) { + setUserMenuOpen(false); + } + }; + + if (userMenuOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [userMenuOpen]); + + // Close user menu on escape key + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setUserMenuOpen(false); + } + }; + + if (userMenuOpen) { + document.addEventListener('keydown', handleEscape); + } + + return () => { + document.removeEventListener('keydown', handleEscape); + }; + }, [userMenuOpen]); + + // Get user initials for avatar + const getUserInitials = () => { + if (user?.firstName && user?.lastName) { + return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase(); + } + if (user?.email) { + return user.email.substring(0, 2).toUpperCase(); + } + return 'U'; }; const toggleSection = (title: string) => { @@ -149,7 +208,7 @@ export function AdminLayout() { }; return ( -
+
{/* Mobile sidebar backdrop */} {sidebarOpen && (
@@ -233,7 +292,7 @@ export function AdminLayout() { {/* Main content */} -
+
{/* Top bar */}
@@ -251,31 +310,89 @@ export function AdminLayout() { {/* User menu */} -
+
+ {/* Dropdown menu */} {userMenuOpen && ( -
- +
+ {/* User info header */} +
+

+ {user?.firstName} {user?.lastName} +

+

+ {user?.email} +

+
+ + {/* Menu items */} +
+ setUserMenuOpen(false)} + className="flex items-center w-full px-4 py-2.5 text-sm text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis transition-colors" + > + + Mi Perfil + + setUserMenuOpen(false)} + className="flex items-center w-full px-4 py-2.5 text-sm text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis transition-colors" + > + + Configuración + +
+ + {/* Logout */} +
+ +
)}
diff --git a/web/src/pages/admin/finanzas/CuentasContablesPage.tsx b/web/src/pages/admin/finanzas/CuentasContablesPage.tsx new file mode 100644 index 0000000..1f4a22d --- /dev/null +++ b/web/src/pages/admin/finanzas/CuentasContablesPage.tsx @@ -0,0 +1,497 @@ +/** + * CuentasContablesPage - Catálogo de Cuentas Contables + */ + +import { useState } from 'react'; +import { Plus, Pencil, Trash2, ChevronRight, ChevronDown, FolderTree } from 'lucide-react'; +import { + useAccountsTree, + useCreateAccount, + useUpdateAccount, + useDeleteAccount, +} from '../../../hooks/useFinance'; +import type { Account, AccountType, CreateAccountDto } from '../../../types/finance.types'; +import { ACCOUNT_TYPE_OPTIONS } from '../../../types/finance.types'; +import { + PageHeader, + PageHeaderAction, + SearchInput, + SelectField, + StatusBadgeFromOptions, + ConfirmDialog, + Modal, + ModalFooter, + TextInput, + FormGroup, + LoadingOverlay, + EmptyState, +} from '../../../components/common'; + +export function CuentasContablesPage() { + const [search, setSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + const [showModal, setShowModal] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [parentAccount, setParentAccount] = useState(null); + const [deleteId, setDeleteId] = useState(null); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const { data: accounts, isLoading, error } = useAccountsTree(); + const createMutation = useCreateAccount(); + const updateMutation = useUpdateAccount(); + const deleteMutation = useDeleteAccount(); + + const toggleExpand = (id: string) => { + const newExpanded = new Set(expandedIds); + if (newExpanded.has(id)) { + newExpanded.delete(id); + } else { + newExpanded.add(id); + } + setExpandedIds(newExpanded); + }; + + const expandAll = () => { + const allIds = new Set(); + const collectIds = (items: Account[]) => { + items.forEach(item => { + if (item.children?.length) { + allIds.add(item.id); + collectIds(item.children); + } + }); + }; + if (accounts) collectIds(accounts); + setExpandedIds(allIds); + }; + + const collapseAll = () => { + setExpandedIds(new Set()); + }; + + const handleDelete = async () => { + if (deleteId) { + await deleteMutation.mutateAsync(deleteId); + setDeleteId(null); + } + }; + + const handleSubmit = async (formData: CreateAccountDto) => { + if (editingItem) { + await updateMutation.mutateAsync({ id: editingItem.id, data: formData }); + } else { + await createMutation.mutateAsync(formData); + } + setShowModal(false); + setEditingItem(null); + setParentAccount(null); + }; + + const openCreate = (parent?: Account) => { + setEditingItem(null); + setParentAccount(parent || null); + setShowModal(true); + }; + + const openEdit = (item: Account) => { + setEditingItem(item); + setParentAccount(null); + setShowModal(true); + }; + + // Filter accounts + const filterAccounts = (items: Account[]): Account[] => { + return items.filter(item => { + const matchesSearch = !search || + item.code.toLowerCase().includes(search.toLowerCase()) || + item.name.toLowerCase().includes(search.toLowerCase()); + const matchesType = !typeFilter || item.type === typeFilter; + + if (matchesSearch && matchesType) return true; + + // Check children + if (item.children?.length) { + const filteredChildren = filterAccounts(item.children); + if (filteredChildren.length > 0) return true; + } + + return false; + }).map(item => ({ + ...item, + children: item.children ? filterAccounts(item.children) : undefined, + })); + }; + + const filteredAccounts = accounts ? filterAccounts(accounts) : []; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + + return ( +
+ openCreate()}> + + Nueva Cuenta + + } + /> + +
+
+
+ + ({ value: o.value, label: o.label }))]} + value={typeFilter} + onChange={(e) => setTypeFilter(e.target.value as AccountType | '')} + className="sm:w-48" + /> +
+
+ + +
+
+
+ +
+ {filteredAccounts.length === 0 ? ( + } + title="No hay cuentas" + description="Crea la primera cuenta para comenzar a estructurar tu catálogo." + /> + ) : ( +
+
+
Cuenta
+
Tipo
+
Saldo
+
Estado
+
Acciones
+
+ {filteredAccounts.map(account => ( + + ))} +
+ )} +
+ + {showModal && ( + { + setShowModal(false); + setEditingItem(null); + setParentAccount(null); + }} + onSubmit={handleSubmit} + isLoading={createMutation.isPending || updateMutation.isPending} + /> + )} + + setDeleteId(null)} + onConfirm={handleDelete} + title="Confirmar eliminación" + message="¿Está seguro de eliminar esta cuenta? Si tiene subcuentas o movimientos, no podrá ser eliminada." + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteMutation.isPending} + /> +
+ ); +} + +// ============================================================================ +// ACCOUNT ROW COMPONENT +// ============================================================================ + +interface AccountRowProps { + account: Account; + level: number; + expandedIds: Set; + onToggle: (id: string) => void; + onEdit: (account: Account) => void; + onDelete: (id: string) => void; + onAddChild: (parent: Account) => void; +} + +function AccountRow({ + account, + level, + expandedIds, + onToggle, + onEdit, + onDelete, + onAddChild, +}: AccountRowProps) { + const hasChildren = account.children && account.children.length > 0; + const isExpanded = expandedIds.has(account.id); + const paddingLeft = level * 24 + 16; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + return ( + <> +
+
+ {hasChildren ? ( + + ) : ( + + )} +
+ {account.code} + {account.name} +
+
+
+ +
+
+ {formatCurrency(account.balance)} +
+
+ + {account.isActive ? 'Activa' : 'Inactiva'} + +
+
+ + + +
+
+ {hasChildren && isExpanded && account.children!.map(child => ( + + ))} + + ); +} + +// ============================================================================ +// ACCOUNT MODAL +// ============================================================================ + +interface AccountModalProps { + item: Account | null; + parentAccount: Account | null; + accounts: Account[]; + onClose: () => void; + onSubmit: (data: CreateAccountDto) => Promise; + isLoading: boolean; +} + +function AccountModal({ item, parentAccount, accounts, onClose, onSubmit, isLoading }: AccountModalProps) { + const [formData, setFormData] = useState({ + code: item?.code || '', + name: item?.name || '', + type: item?.type || parentAccount?.type || 'asset', + parentId: item?.parentId || parentAccount?.id || undefined, + allowTransactions: item?.allowTransactions ?? true, + isActive: item?.isActive ?? true, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit(formData); + }; + + const update = (field: K, value: CreateAccountDto[K]) => { + setFormData({ ...formData, [field]: value }); + }; + + // Flatten accounts for parent select + const flattenAccounts = (items: Account[], prefix = ''): { value: string; label: string }[] => { + const result: { value: string; label: string }[] = []; + items.forEach(item => { + result.push({ value: item.id, label: `${prefix}${item.code} - ${item.name}` }); + if (item.children?.length) { + result.push(...flattenAccounts(item.children, prefix + ' ')); + } + }); + return result; + }; + + const parentOptions = [ + { value: '', label: 'Sin cuenta padre (nivel raíz)' }, + ...flattenAccounts(accounts), + ]; + + return ( + + + + + } + > +
+ + update('code', e.target.value)} + placeholder="1101" + /> + ({ value: o.value, label: o.label }))} + value={formData.type} + onChange={(e) => update('type', e.target.value as AccountType)} + /> + + + update('name', e.target.value)} + placeholder="Caja y Bancos" + /> + + update('parentId', e.target.value || undefined)} + /> + + +
+ update('allowTransactions', e.target.checked)} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + +
+ +
+ update('isActive', e.target.checked)} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + +
+
+ +
+ ); +} diff --git a/web/src/pages/admin/finanzas/CuentasPorCobrarPage.tsx b/web/src/pages/admin/finanzas/CuentasPorCobrarPage.tsx new file mode 100644 index 0000000..8f087bb --- /dev/null +++ b/web/src/pages/admin/finanzas/CuentasPorCobrarPage.tsx @@ -0,0 +1,472 @@ +/** + * CuentasPorCobrarPage - Cuentas por Cobrar (CxC) + */ + +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Eye, DollarSign, Send, AlertTriangle, TrendingUp, Clock } from 'lucide-react'; +import { + useAR, + useARStats, + useRegisterARPayment, + useSendARReminder, +} from '../../../hooks/useFinance'; +import type { AccountsReceivable, ARStatus, RegisterARPaymentDto } from '../../../types/finance.types'; +import { AR_STATUS_OPTIONS } from '../../../types/finance.types'; +import { + PageHeader, + DataTable, + SearchInput, + SelectField, + StatusBadgeFromOptions, + Modal, + ModalFooter, + TextInput, + FormGroup, + LoadingOverlay, +} from '../../../components/common'; +import type { DataTableColumn } from '../../../components/common'; + +export function CuentasPorCobrarPage() { + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [overdueOnly, setOverdueOnly] = useState(false); + const [paymentModal, setPaymentModal] = useState(null); + + const { data, isLoading, error } = useAR({ + status: statusFilter || undefined, + overdue: overdueOnly || undefined, + }); + + const { data: stats } = useARStats(); + const paymentMutation = useRegisterARPayment(); + const reminderMutation = useSendARReminder(); + + const handlePayment = async (formData: RegisterARPaymentDto) => { + if (paymentModal) { + await paymentMutation.mutateAsync({ id: paymentModal.id, data: formData }); + setPaymentModal(null); + } + }; + + const handleSendReminder = async (id: string) => { + await reminderMutation.mutateAsync(id); + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('es-MX', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + }; + + // Filter by search + const items = (data?.items || []).filter( + (item) => !search || + item.partnerName.toLowerCase().includes(search.toLowerCase()) || + item.documentNumber.toLowerCase().includes(search.toLowerCase()) + ); + + const columns: DataTableColumn[] = [ + { + key: 'document', + header: 'Documento', + render: (item) => ( +
+ {item.documentNumber} +

{item.documentType}

+
+ ), + }, + { + key: 'partner', + header: 'Cliente', + render: (item) => ( + {item.partnerName} + ), + }, + { + key: 'dates', + header: 'Fechas', + render: (item) => ( +
+

Emisión: {formatDate(item.documentDate)}

+

0 ? 'text-red-600 font-medium' : ''}> + Vence: {formatDate(item.dueDate)} +

+
+ ), + }, + { + key: 'amounts', + header: 'Importes', + align: 'right', + render: (item) => ( +
+

Original: {formatCurrency(item.originalAmount)}

+

Saldo: {formatCurrency(item.balanceAmount)}

+
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (item) => ( +
+ + {item.daysOverdue > 0 && ( +

{item.daysOverdue} días vencido

+ )} +
+ ), + }, + { + key: 'actions', + header: 'Acciones', + align: 'right', + render: (item) => ( +
+ + + + {item.status !== 'paid' && item.status !== 'cancelled' && ( + <> + + {item.daysOverdue > 0 && ( + + )} + + )} +
+ ), + }, + ]; + + if (isLoading) { + return ; + } + + return ( +
+ + + {/* KPI Cards */} + {stats && ( +
+ } + color="blue" + /> + } + color="green" + /> + } + color="red" + /> + } + color="gray" + /> +
+ )} + + {/* Aging Summary */} + {stats && ( +
+

Antigüedad de Saldos

+
+ + + + +
+
+ )} + + {/* Filters */} +
+
+ + ({ value: o.value, label: o.label }))]} + value={statusFilter} + onChange={(e) => setStatusFilter(e.target.value as ARStatus | '')} + className="sm:w-48" + /> + +
+
+ + + + {paymentModal && ( + setPaymentModal(null)} + onSubmit={handlePayment} + isLoading={paymentMutation.isPending} + /> + )} +
+ ); +} + +// ============================================================================ +// KPI CARD COMPONENT +// ============================================================================ + +interface KPICardProps { + title: string; + value: string; + icon: React.ReactNode; + color: 'blue' | 'green' | 'red' | 'gray' | 'yellow' | 'orange'; +} + +function KPICard({ title, value, icon, color }: KPICardProps) { + const colorClasses = { + blue: 'bg-blue-50 text-blue-600', + green: 'bg-green-50 text-green-600', + red: 'bg-red-50 text-red-600', + gray: 'bg-gray-50 text-gray-600', + yellow: 'bg-yellow-50 text-yellow-600', + orange: 'bg-orange-50 text-orange-600', + }; + + return ( +
+
+
+

{title}

+

{value}

+
+
+ {icon} +
+
+
+ ); +} + +// ============================================================================ +// AGING ITEM COMPONENT +// ============================================================================ + +interface AgingItemProps { + label: string; + value: number; + total: number; + color: 'green' | 'yellow' | 'orange' | 'red'; +} + +function AgingItem({ label, value, total, color }: AgingItemProps) { + const percentage = total > 0 ? (value / total) * 100 : 0; + const colorClasses = { + green: 'bg-green-500', + yellow: 'bg-yellow-500', + orange: 'bg-orange-500', + red: 'bg-red-500', + }; + + const formatCurrency = (val: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(val); + }; + + return ( +
+
+ {label} + {formatCurrency(value)} +
+
+
+
+

{percentage.toFixed(1)}%

+
+ ); +} + +// ============================================================================ +// PAYMENT MODAL +// ============================================================================ + +interface PaymentModalProps { + item: AccountsReceivable; + onClose: () => void; + onSubmit: (data: RegisterARPaymentDto) => Promise; + isLoading: boolean; +} + +function PaymentModal({ item, onClose, onSubmit, isLoading }: PaymentModalProps) { + const [formData, setFormData] = useState({ + date: new Date().toISOString().split('T')[0], + amount: item.balanceAmount, + method: 'transfer', + reference: '', + notes: '', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit(formData); + }; + + const update = (field: K, value: RegisterARPaymentDto[K]) => { + setFormData({ ...formData, [field]: value }); + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + return ( + + + + + } + > +
+

Documento: {item.documentNumber}

+

Cliente: {item.partnerName}

+

Saldo pendiente: {formatCurrency(item.balanceAmount)}

+
+ +
+ + update('date', e.target.value)} + /> + update('amount', parseFloat(e.target.value))} + /> + + + + update('method', e.target.value)} + /> + update('reference', e.target.value)} + placeholder="No. de transferencia, cheque, etc." + /> + + + update('notes', e.target.value)} + placeholder="Notas adicionales..." + /> + +
+ ); +} diff --git a/web/src/pages/admin/finanzas/CuentasPorPagarPage.tsx b/web/src/pages/admin/finanzas/CuentasPorPagarPage.tsx new file mode 100644 index 0000000..c1a1e5e --- /dev/null +++ b/web/src/pages/admin/finanzas/CuentasPorPagarPage.tsx @@ -0,0 +1,454 @@ +/** + * CuentasPorPagarPage - Cuentas por Pagar (CxP) + */ + +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Eye, DollarSign, AlertTriangle, TrendingDown, Clock, FileText } from 'lucide-react'; +import { + useAP, + useAPStats, + useRegisterAPPayment, +} from '../../../hooks/useFinance'; +import type { AccountsPayable, APStatus, RegisterAPPaymentDto } from '../../../types/finance.types'; +import { AP_STATUS_OPTIONS } from '../../../types/finance.types'; +import { + PageHeader, + DataTable, + SearchInput, + SelectField, + StatusBadgeFromOptions, + Modal, + ModalFooter, + TextInput, + FormGroup, + LoadingOverlay, +} from '../../../components/common'; +import type { DataTableColumn } from '../../../components/common'; + +export function CuentasPorPagarPage() { + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [overdueOnly, setOverdueOnly] = useState(false); + const [paymentModal, setPaymentModal] = useState(null); + + const { data, isLoading, error } = useAP({ + status: statusFilter || undefined, + overdue: overdueOnly || undefined, + }); + + const { data: stats } = useAPStats(); + const paymentMutation = useRegisterAPPayment(); + + const handlePayment = async (formData: RegisterAPPaymentDto) => { + if (paymentModal) { + await paymentMutation.mutateAsync({ id: paymentModal.id, data: formData }); + setPaymentModal(null); + } + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('es-MX', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + }; + + // Filter by search + const items = (data?.items || []).filter( + (item) => !search || + item.partnerName.toLowerCase().includes(search.toLowerCase()) || + item.documentNumber.toLowerCase().includes(search.toLowerCase()) + ); + + const columns: DataTableColumn[] = [ + { + key: 'document', + header: 'Documento', + render: (item) => ( +
+ {item.documentNumber} +

{item.documentType}

+
+ ), + }, + { + key: 'partner', + header: 'Proveedor', + render: (item) => ( + {item.partnerName} + ), + }, + { + key: 'dates', + header: 'Fechas', + render: (item) => ( +
+

Recepción: {formatDate(item.documentDate)}

+

0 ? 'text-red-600 font-medium' : ''}> + Vence: {formatDate(item.dueDate)} +

+
+ ), + }, + { + key: 'amounts', + header: 'Importes', + align: 'right', + render: (item) => ( +
+

Original: {formatCurrency(item.originalAmount)}

+

Saldo: {formatCurrency(item.balanceAmount)}

+
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (item) => ( +
+ + {item.daysOverdue > 0 && ( +

{item.daysOverdue} días vencido

+ )} +
+ ), + }, + { + key: 'actions', + header: 'Acciones', + align: 'right', + render: (item) => ( +
+ + + + {item.status !== 'paid' && item.status !== 'cancelled' && ( + + )} +
+ ), + }, + ]; + + if (isLoading) { + return ; + } + + return ( +
+ + + {/* KPI Cards */} + {stats && ( +
+ } + color="blue" + /> + } + color="green" + /> + } + color="red" + /> + } + color="gray" + /> +
+ )} + + {/* Aging Summary */} + {stats && ( +
+

Antigüedad de Saldos

+
+ + + + +
+
+ )} + + {/* Filters */} +
+
+ + ({ value: o.value, label: o.label }))]} + value={statusFilter} + onChange={(e) => setStatusFilter(e.target.value as APStatus | '')} + className="sm:w-48" + /> + +
+
+ + + + {paymentModal && ( + setPaymentModal(null)} + onSubmit={handlePayment} + isLoading={paymentMutation.isPending} + /> + )} +
+ ); +} + +// ============================================================================ +// KPI CARD COMPONENT +// ============================================================================ + +interface KPICardProps { + title: string; + value: string; + icon: React.ReactNode; + color: 'blue' | 'green' | 'red' | 'gray' | 'yellow' | 'orange'; +} + +function KPICard({ title, value, icon, color }: KPICardProps) { + const colorClasses = { + blue: 'bg-blue-50 text-blue-600', + green: 'bg-green-50 text-green-600', + red: 'bg-red-50 text-red-600', + gray: 'bg-gray-50 text-gray-600', + yellow: 'bg-yellow-50 text-yellow-600', + orange: 'bg-orange-50 text-orange-600', + }; + + return ( +
+
+
+

{title}

+

{value}

+
+
+ {icon} +
+
+
+ ); +} + +// ============================================================================ +// AGING ITEM COMPONENT +// ============================================================================ + +interface AgingItemProps { + label: string; + value: number; + total: number; + color: 'green' | 'yellow' | 'orange' | 'red'; +} + +function AgingItem({ label, value, total, color }: AgingItemProps) { + const percentage = total > 0 ? (value / total) * 100 : 0; + const colorClasses = { + green: 'bg-green-500', + yellow: 'bg-yellow-500', + orange: 'bg-orange-500', + red: 'bg-red-500', + }; + + const formatCurrency = (val: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(val); + }; + + return ( +
+
+ {label} + {formatCurrency(value)} +
+
+
+
+

{percentage.toFixed(1)}%

+
+ ); +} + +// ============================================================================ +// PAYMENT MODAL +// ============================================================================ + +interface PaymentModalProps { + item: AccountsPayable; + onClose: () => void; + onSubmit: (data: RegisterAPPaymentDto) => Promise; + isLoading: boolean; +} + +function PaymentModal({ item, onClose, onSubmit, isLoading }: PaymentModalProps) { + const [formData, setFormData] = useState({ + date: new Date().toISOString().split('T')[0], + amount: item.balanceAmount, + method: 'transfer', + reference: '', + notes: '', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit(formData); + }; + + const update = (field: K, value: RegisterAPPaymentDto[K]) => { + setFormData({ ...formData, [field]: value }); + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + return ( + + + + + } + > +
+

Documento: {item.documentNumber}

+

Proveedor: {item.partnerName}

+

Saldo pendiente: {formatCurrency(item.balanceAmount)}

+
+ +
+ + update('date', e.target.value)} + /> + update('amount', parseFloat(e.target.value))} + /> + + + + update('method', e.target.value)} + /> + update('reference', e.target.value)} + placeholder="No. de transferencia, cheque, etc." + /> + + + update('notes', e.target.value)} + placeholder="Notas adicionales..." + /> + +
+ ); +} diff --git a/web/src/pages/admin/finanzas/FacturasPage.tsx b/web/src/pages/admin/finanzas/FacturasPage.tsx new file mode 100644 index 0000000..c109907 --- /dev/null +++ b/web/src/pages/admin/finanzas/FacturasPage.tsx @@ -0,0 +1,422 @@ +/** + * FacturasPage - Listado de Facturas + */ + +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + Plus, + Eye, + Pencil, + Trash2, + Send, + FileText, + Download, + Stamp, + XCircle, +} from 'lucide-react'; +import { + useInvoices, + useInvoiceStats, + useDeleteInvoice, + useSendInvoice, + useCancelInvoice, + useStampInvoice, + useDownloadInvoicePdf, +} from '../../../hooks/useFinance'; +import type { Invoice, InvoiceStatus } from '../../../types/finance.types'; +import { INVOICE_STATUS_OPTIONS } from '../../../types/finance.types'; +import { + PageHeader, + DataTable, + SearchInput, + SelectField, + StatusBadgeFromOptions, + ConfirmDialog, + LoadingOverlay, +} from '../../../components/common'; +import type { DataTableColumn } from '../../../components/common'; + +export function FacturasPage() { + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [deleteId, setDeleteId] = useState(null); + const [sendId, setSendId] = useState(null); + const [cancelId, setCancelId] = useState(null); + const [stampId, setStampId] = useState(null); + + const { data, isLoading, error } = useInvoices({ + status: statusFilter || undefined, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + }); + + const { data: stats } = useInvoiceStats(); + const deleteMutation = useDeleteInvoice(); + const sendMutation = useSendInvoice(); + const cancelMutation = useCancelInvoice(); + const stampMutation = useStampInvoice(); + const pdfMutation = useDownloadInvoicePdf(); + + const handleDelete = async () => { + if (deleteId) { + await deleteMutation.mutateAsync(deleteId); + setDeleteId(null); + } + }; + + const handleSend = async () => { + if (sendId) { + await sendMutation.mutateAsync(sendId); + setSendId(null); + } + }; + + const handleCancel = async () => { + if (cancelId) { + await cancelMutation.mutateAsync({ id: cancelId, reason: 'Cancelación solicitada por usuario' }); + setCancelId(null); + } + }; + + const handleStamp = async () => { + if (stampId) { + await stampMutation.mutateAsync(stampId); + setStampId(null); + } + }; + + const handleDownloadPdf = async (id: string) => { + const blob = await pdfMutation.mutateAsync(id); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `factura-${id}.pdf`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('es-MX', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + }; + + // Filter by search + const items = (data?.items || []).filter( + (item) => !search || + (item.number || item.invoiceNumber || '').toLowerCase().includes(search.toLowerCase()) || + (item.partnerName || item.customerName || '').toLowerCase().includes(search.toLowerCase()) + ); + + const columns: DataTableColumn[] = [ + { + key: 'number', + header: 'Número', + render: (item) => ( +
+ {item.number || item.invoiceNumber} + {(item.cfdiUuid || item.uuid) && ( +

+ {(item.cfdiUuid || item.uuid || '').substring(0, 8)}... +

+ )} +
+ ), + }, + { + key: 'customer', + header: 'Cliente', + render: (item) => ( +
+ {item.partnerName || item.customerName} + {(item.partnerRfc || item.customerRfc) && ( +

{item.partnerRfc || item.customerRfc}

+ )} +
+ ), + }, + { + key: 'date', + header: 'Fecha', + render: (item) => ( +
+

Emisión: {formatDate(item.date)}

+ {item.dueDate && ( +

Vence: {formatDate(item.dueDate)}

+ )} +
+ ), + }, + { + key: 'amounts', + header: 'Importes', + align: 'right', + render: (item) => ( +
+

Subtotal: {formatCurrency(item.subtotal)}

+

Total: {formatCurrency(item.total)}

+
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (item) => ( +
+ + {item.cfdiStatus && item.cfdiStatus !== 'none' && ( +

+ CFDI: {item.cfdiStatus === 'stamped' ? 'Timbrada' : item.cfdiStatus === 'cancelled' ? 'Cancelada' : item.cfdiStatus} +

+ )} +
+ ), + }, + { + key: 'actions', + header: 'Acciones', + align: 'right', + render: (item) => ( +
+ + + + + {/* Download PDF */} + + + {/* Draft actions */} + {item.status === 'draft' && ( + <> + + + + + + + )} + + {/* Sent actions - can stamp for CFDI */} + {item.status === 'sent' && (!item.cfdiStatus || item.cfdiStatus === 'none') && ( + + )} + + {/* Cancel action for sent/partial */} + {(item.status === 'sent' || item.status === 'partial') && ( + + )} +
+ ), + }, + ]; + + if (isLoading) { + return ; + } + + return ( +
+ + + Nueva Factura + + } + /> + + {/* Stats Cards */} + {stats && ( +
+ + + + + +
+ )} + + {/* Filters */} +
+
+ + ({ value: o.value, label: o.label }))]} + value={statusFilter} + onChange={(e) => setStatusFilter(e.target.value as InvoiceStatus | '')} + /> +
+
+
+ + setDateFrom(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + setDateTo(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+
+ + , + title: 'No hay facturas', + description: 'Crea tu primera factura para comenzar.' + }} + /> + + {/* Delete Confirmation */} + setDeleteId(null)} + onConfirm={handleDelete} + title="Eliminar Factura" + message="¿Está seguro de eliminar esta factura? Esta acción no se puede deshacer." + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteMutation.isPending} + /> + + {/* Send Confirmation */} + setSendId(null)} + onConfirm={handleSend} + title="Enviar Factura" + message="¿Está seguro de enviar esta factura al cliente? Se notificará por correo electrónico." + confirmLabel="Enviar" + variant="info" + isLoading={sendMutation.isPending} + /> + + {/* Cancel Confirmation */} + setCancelId(null)} + onConfirm={handleCancel} + title="Cancelar Factura" + message="¿Está seguro de cancelar esta factura? Si tiene CFDI timbrado, también se cancelará ante el SAT." + confirmLabel="Cancelar Factura" + variant="danger" + isLoading={cancelMutation.isPending} + /> + + {/* Stamp CFDI Confirmation */} + setStampId(null)} + onConfirm={handleStamp} + title="Timbrar CFDI" + message="¿Está seguro de timbrar esta factura? Una vez timbrada, el CFDI será válido fiscalmente." + confirmLabel="Timbrar" + variant="info" + isLoading={stampMutation.isPending} + /> +
+ ); +} + +// ============================================================================ +// STATS CARD COMPONENT +// ============================================================================ + +interface StatsCardProps { + label: string; + value: string; + color: 'blue' | 'green' | 'yellow' | 'red' | 'gray'; +} + +function StatsCard({ label, value, color }: StatsCardProps) { + const colorClasses = { + blue: 'border-blue-200 bg-blue-50 text-blue-700', + green: 'border-green-200 bg-green-50 text-green-700', + yellow: 'border-yellow-200 bg-yellow-50 text-yellow-700', + red: 'border-red-200 bg-red-50 text-red-700', + gray: 'border-gray-200 bg-gray-50 text-gray-700', + }; + + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/web/src/pages/admin/finanzas/FlujoEfectivoPage.tsx b/web/src/pages/admin/finanzas/FlujoEfectivoPage.tsx new file mode 100644 index 0000000..8453960 --- /dev/null +++ b/web/src/pages/admin/finanzas/FlujoEfectivoPage.tsx @@ -0,0 +1,279 @@ +/** + * FlujoEfectivoPage - Dashboard de Flujo de Efectivo + */ + +import { useState } from 'react'; +import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Calendar, DollarSign, AlertTriangle } from 'lucide-react'; +import { + useCashFlowSummary, + useCashFlowForecast, +} from '../../../hooks/useFinance'; +import type { CashFlowPeriodType, CashFlowCategory, DailyProjection } from '../../../types/finance.types'; +import { + PageHeader, + SelectField, + LoadingOverlay, +} from '../../../components/common'; + +export function FlujoEfectivoPage() { + const [period, setPeriod] = useState('month'); + const [forecastDays, setForecastDays] = useState(30); + + const { data: summary, isLoading: summaryLoading } = useCashFlowSummary(period); + const { data: forecast, isLoading: forecastLoading } = useCashFlowForecast(forecastDays); + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('es-MX', { + day: '2-digit', + month: 'short', + }); + }; + + const periodOptions = [ + { value: 'day', label: 'Hoy' }, + { value: 'week', label: 'Esta Semana' }, + { value: 'month', label: 'Este Mes' }, + { value: 'quarter', label: 'Este Trimestre' }, + { value: 'year', label: 'Este Año' }, + ]; + + const forecastOptions = [ + { value: '7', label: '7 días' }, + { value: '15', label: '15 días' }, + { value: '30', label: '30 días' }, + { value: '60', label: '60 días' }, + { value: '90', label: '90 días' }, + ]; + + if (summaryLoading) { + return ; + } + + // Extract summary data with safe defaults + const summaryData = summary ? { + openingBalance: summary.openingBalance ?? 0, + closingBalance: summary.closingBalance ?? 0, + totalInflows: summary.totalInflows ?? 0, + totalOutflows: summary.totalOutflows ?? 0, + netChange: summary.netFlow ?? 0, + inflowCount: (summary.items?.filter((i: { type: string }) => i.type === 'inflow').length) ?? 0, + outflowCount: (summary.items?.filter((i: { type: string }) => i.type === 'outflow').length) ?? 0, + inflowsByCategory: [] as CashFlowCategory[], + outflowsByCategory: [] as CashFlowCategory[], + } : null; + + // Extract forecast data with safe defaults + const forecastData = forecast ? { + expectedInflows: forecast.summary?.projectedInflows ?? 0, + expectedOutflows: forecast.summary?.projectedOutflows ?? 0, + projectedBalance: (forecast.periods?.[forecast.periods.length - 1]?.closingBalance) ?? 0, + dailyProjection: (forecast.periods?.map((p: { period: string; totalInflows: number; totalOutflows: number; netFlow: number; closingBalance: number }) => ({ + date: p.period, + inflows: p.totalInflows, + outflows: p.totalOutflows, + net: p.netFlow, + balance: p.closingBalance, + }))) ?? [] as DailyProjection[], + } : null; + + return ( +
+ + + {/* Period Selector */} +
+
+
+ + Período: + setPeriod(e.target.value as CashFlowPeriodType)} + className="w-40" + /> +
+
+ + Proyección: + setForecastDays(parseInt(e.target.value))} + className="w-32" + /> +
+
+
+ + {/* KPI Cards */} + {summaryData && ( +
+ } + color="gray" + /> + } + color="green" + /> + } + color="red" + /> + = 0 ? 'Positivo' : 'Negativo'} + icon={} + color={summaryData.netChange >= 0 ? 'blue' : 'orange'} + /> +
+ )} + + {/* Cash Flow Forecast */} + {!forecastLoading && forecastData && ( +
+

+ + Proyección de Flujo ({forecastDays} días) +

+ + {/* Warning if projected balance is negative */} + {forecastData.projectedBalance < 0 && ( +
+ +

+ Alerta: Se proyecta un saldo negativo de {formatCurrency(forecastData.projectedBalance)} al final del período +

+
+ )} + +
+
+

Cobros Esperados

+

{formatCurrency(forecastData.expectedInflows)}

+
+
+

Pagos Programados

+

{formatCurrency(forecastData.expectedOutflows)}

+
+
+

Saldo Proyectado

+

= 0 ? 'text-blue-600' : 'text-red-600'}`}> + {formatCurrency(forecastData.projectedBalance)} +

+
+
+ + {/* Daily Projection Table */} + {forecastData.dailyProjection.length > 0 && ( +
+ + + + + + + + + + + + {forecastData.dailyProjection.slice(0, 10).map((day: DailyProjection, index: number) => ( + + + + + + + + ))} + +
PeríodoEntradasSalidasNetoSaldo
{formatDate(day.date)} + {day.inflows > 0 ? formatCurrency(day.inflows) : '-'} + + {day.outflows > 0 ? formatCurrency(day.outflows) : '-'} + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatCurrency(day.net)} + = 0 ? 'text-gray-900' : 'text-red-600'}`}> + {formatCurrency(day.balance)} +
+ {forecastData.dailyProjection.length > 10 && ( +

+ Mostrando 10 de {forecastData.dailyProjection.length} períodos +

+ )} +
+ )} +
+ )} + + {/* Empty State */} + {!summaryData && !summaryLoading && ( +
+ +

Sin datos de flujo

+

No hay movimientos de efectivo para el período seleccionado.

+
+ )} +
+ ); +} + +// ============================================================================ +// KPI CARD COMPONENT +// ============================================================================ + +interface KPICardProps { + title: string; + value: string; + subtitle?: string; + icon: React.ReactNode; + color: 'blue' | 'green' | 'red' | 'gray' | 'orange'; +} + +function KPICard({ title, value, subtitle, icon, color }: KPICardProps) { + const colorClasses = { + blue: 'bg-blue-50 text-blue-600', + green: 'bg-green-50 text-green-600', + red: 'bg-red-50 text-red-600', + gray: 'bg-gray-50 text-gray-600', + orange: 'bg-orange-50 text-orange-600', + }; + + return ( +
+
+
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

} +
+
+ {icon} +
+
+
+ ); +} diff --git a/web/src/pages/admin/finanzas/PolizasPage.tsx b/web/src/pages/admin/finanzas/PolizasPage.tsx new file mode 100644 index 0000000..bed4e69 --- /dev/null +++ b/web/src/pages/admin/finanzas/PolizasPage.tsx @@ -0,0 +1,369 @@ +/** + * PolizasPage - Pólizas Contables (Journal Entries) + */ + +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Plus, Eye, Pencil, Trash2, CheckCircle, XCircle, FileText } from 'lucide-react'; +import { + useAccountingEntries, + useDeleteAccountingEntry, + usePostAccountingEntry, + useReverseAccountingEntry, +} from '../../../hooks/useFinance'; +import type { AccountingEntry, EntryStatus, EntryType } from '../../../types/finance.types'; +import { ENTRY_STATUS_OPTIONS, ENTRY_TYPE_OPTIONS } from '../../../types/finance.types'; +import { + PageHeader, + DataTable, + SearchInput, + SelectField, + StatusBadgeFromOptions, + ConfirmDialog, + LoadingOverlay, +} from '../../../components/common'; +import type { DataTableColumn } from '../../../components/common'; + +export function PolizasPage() { + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [deleteId, setDeleteId] = useState(null); + const [postId, setPostId] = useState(null); + const [reverseId, setReverseId] = useState(null); + + const { data, isLoading, error } = useAccountingEntries({ + status: statusFilter || undefined, + type: typeFilter || undefined, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + }); + + const deleteMutation = useDeleteAccountingEntry(); + const postMutation = usePostAccountingEntry(); + const reverseMutation = useReverseAccountingEntry(); + + const handleDelete = async () => { + if (deleteId) { + await deleteMutation.mutateAsync(deleteId); + setDeleteId(null); + } + }; + + const handlePost = async () => { + if (postId) { + await postMutation.mutateAsync(postId); + setPostId(null); + } + }; + + const handleReverse = async () => { + if (reverseId) { + await reverseMutation.mutateAsync({ id: reverseId, reason: 'Reversión solicitada por usuario' }); + setReverseId(null); + } + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('es-MX', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + }; + + // Filter by search + const items = (data?.items || []).filter( + (item) => !search || + item.entryNumber.toLowerCase().includes(search.toLowerCase()) || + item.description.toLowerCase().includes(search.toLowerCase()) || + item.reference?.toLowerCase().includes(search.toLowerCase()) + ); + + const columns: DataTableColumn[] = [ + { + key: 'number', + header: 'Número', + render: (item) => ( +
+ {item.entryNumber} + {item.reference && ( +

{item.reference}

+ )} +
+ ), + }, + { + key: 'date', + header: 'Fecha', + render: (item) => ( + {formatDate(item.date)} + ), + }, + { + key: 'type', + header: 'Tipo', + render: (item) => ( + + ), + }, + { + key: 'description', + header: 'Descripción', + render: (item) => ( +
+

{item.description}

+

{item.lines?.length || 0} líneas

+
+ ), + }, + { + key: 'amounts', + header: 'Importe', + align: 'right', + render: (item) => ( +
+

{formatCurrency(item.totalDebit)}

+ {item.totalDebit !== item.totalCredit && ( +

Descuadre: {formatCurrency(Math.abs(item.totalDebit - item.totalCredit))}

+ )} +
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (item) => ( + + ), + }, + { + key: 'actions', + header: 'Acciones', + align: 'right', + render: (item) => ( +
+ + + + {item.status === 'draft' && ( + <> + + + + + + + )} + {item.status === 'posted' && ( + + )} +
+ ), + }, + ]; + + if (isLoading) { + return ; + } + + return ( +
+ + + Nueva Póliza + + } + /> + + {/* Stats Cards */} +
+ i.status === 'draft').length || 0} + color="yellow" + /> + i.status === 'posted').length || 0} + color="green" + /> + i.status === 'reversed').length || 0} + color="orange" + /> + +
+ + {/* Filters */} +
+
+ + ({ value: o.value, label: o.label }))]} + value={statusFilter} + onChange={(e) => setStatusFilter(e.target.value as EntryStatus | '')} + /> + ({ value: o.value, label: o.label }))]} + value={typeFilter} + onChange={(e) => setTypeFilter(e.target.value as EntryType | '')} + /> +
+
+
+ + setDateFrom(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + setDateTo(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+
+ + , + title: 'No hay pólizas', + description: 'Crea tu primera póliza contable para comenzar.' + }} + /> + + {/* Delete Confirmation */} + setDeleteId(null)} + onConfirm={handleDelete} + title="Eliminar Póliza" + message="¿Está seguro de eliminar esta póliza? Esta acción no se puede deshacer." + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteMutation.isPending} + /> + + {/* Post Confirmation */} + setPostId(null)} + onConfirm={handlePost} + title="Contabilizar Póliza" + message="¿Está seguro de contabilizar esta póliza? Una vez contabilizada, no podrá ser editada." + confirmLabel="Contabilizar" + variant="info" + isLoading={postMutation.isPending} + /> + + {/* Reverse Confirmation */} + setReverseId(null)} + onConfirm={handleReverse} + title="Reversar Póliza" + message="¿Está seguro de reversar esta póliza? Se creará una póliza de reversión automáticamente." + confirmLabel="Reversar" + variant="warning" + isLoading={reverseMutation.isPending} + /> +
+ ); +} + +// ============================================================================ +// STATS CARD COMPONENT +// ============================================================================ + +interface StatsCardProps { + label: string; + count: number; + color: 'blue' | 'green' | 'yellow' | 'orange'; +} + +function StatsCard({ label, count, color }: StatsCardProps) { + const colorClasses = { + blue: 'border-blue-200 bg-blue-50', + green: 'border-green-200 bg-green-50', + yellow: 'border-yellow-200 bg-yellow-50', + orange: 'border-orange-200 bg-orange-50', + }; + + const textClasses = { + blue: 'text-blue-700', + green: 'text-green-700', + yellow: 'text-yellow-700', + orange: 'text-orange-700', + }; + + return ( +
+

{label}

+

{count}

+
+ ); +} diff --git a/web/src/pages/admin/finanzas/index.ts b/web/src/pages/admin/finanzas/index.ts new file mode 100644 index 0000000..31a17fa --- /dev/null +++ b/web/src/pages/admin/finanzas/index.ts @@ -0,0 +1,10 @@ +/** + * Finance Pages Index + */ + +export { CuentasContablesPage } from './CuentasContablesPage'; +export { CuentasPorCobrarPage } from './CuentasPorCobrarPage'; +export { CuentasPorPagarPage } from './CuentasPorPagarPage'; +export { PolizasPage } from './PolizasPage'; +export { FlujoEfectivoPage } from './FlujoEfectivoPage'; +export { FacturasPage } from './FacturasPage'; diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx index 3624cae..c9014bc 100644 --- a/web/src/pages/auth/LoginPage.tsx +++ b/web/src/pages/auth/LoginPage.tsx @@ -136,16 +136,22 @@ export function LoginPage() {
- {/* Demo Access */} -
-

¿Quieres ver una demo?

- - Acceder sin cuenta (modo demo) - -
+ {/* Demo Access - Solo visible en desarrollo con VITE_SHOW_DEMO_LOGIN=true */} + {import.meta.env.VITE_SHOW_DEMO_LOGIN === 'true' && ( +
+

¿Quieres ver una demo?

+ +
+ )}
{/* Footer */} diff --git a/web/src/services/apiClient.ts b/web/src/services/apiClient.ts deleted file mode 100644 index 3b91e75..0000000 --- a/web/src/services/apiClient.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * API Client - Centralized HTTP client with interceptors - * G-004: Request/response interceptors, auth handling, error transformation - */ - -import axios, { - AxiosInstance, - AxiosError, - AxiosResponse, - InternalAxiosRequestConfig, -} from 'axios'; - -// API Configuration -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3021/api'; -const API_TIMEOUT = 30000; // 30 seconds - -// Token storage keys -const ACCESS_TOKEN_KEY = 'access_token'; -const REFRESH_TOKEN_KEY = 'refresh_token'; - -// API Error interface -export interface ApiError { - message: string; - statusCode: number; - error?: string; - details?: Record; -} - -// Pagination response interface -export interface PaginatedResponse { - data: T[]; - meta: { - total: number; - page: number; - limit: number; - totalPages: number; - hasNextPage: boolean; - hasPreviousPage: boolean; - }; -} - -/** - * Create axios instance with base configuration - */ -const apiClient: AxiosInstance = axios.create({ - baseURL: API_BASE_URL, - timeout: API_TIMEOUT, - headers: { - 'Content-Type': 'application/json', - }, -}); - -/** - * Request interceptor - Add auth token to requests - */ -apiClient.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { - const token = localStorage.getItem(ACCESS_TOKEN_KEY); - - if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}`; - } - - // Log requests in development - if (import.meta.env.DEV) { - console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, config.data || ''); - } - - return config; - }, - (error: AxiosError) => { - return Promise.reject(error); - } -); - -/** - * Response interceptor - Handle responses and errors - */ -apiClient.interceptors.response.use( - (response: AxiosResponse) => { - // Log responses in development - if (import.meta.env.DEV) { - console.log(`[API] Response ${response.status}:`, response.data); - } - - return response; - }, - async (error: AxiosError) => { - const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; - - // Handle 401 Unauthorized - Attempt token refresh - if (error.response?.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - - try { - const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); - - if (refreshToken) { - const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { - refreshToken, - }); - - const { accessToken, refreshToken: newRefreshToken } = response.data; - - localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); - if (newRefreshToken) { - localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken); - } - - // Retry original request with new token - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${accessToken}`; - } - - return apiClient(originalRequest); - } - } catch (refreshError) { - // Refresh failed - clear tokens and redirect to login - clearAuthTokens(); - window.location.href = '/login'; - return Promise.reject(refreshError); - } - } - - // Transform error for consistent handling - const apiError = transformError(error); - return Promise.reject(apiError); - } -); - -/** - * Transform axios error to ApiError - */ -function transformError(error: AxiosError): ApiError { - if (error.response) { - // Server responded with error - return { - message: error.response.data?.message || 'Error del servidor', - statusCode: error.response.status, - error: error.response.data?.error, - details: error.response.data?.details, - }; - } - - if (error.request) { - // Request made but no response - return { - message: 'No se pudo conectar con el servidor', - statusCode: 0, - error: 'NETWORK_ERROR', - }; - } - - // Request setup error - return { - message: error.message || 'Error desconocido', - statusCode: 0, - error: 'REQUEST_ERROR', - }; -} - -/** - * Token management utilities - */ -export function setAuthTokens(accessToken: string, refreshToken?: string): void { - localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); - if (refreshToken) { - localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); - } -} - -export function getAccessToken(): string | null { - return localStorage.getItem(ACCESS_TOKEN_KEY); -} - -export function clearAuthTokens(): void { - localStorage.removeItem(ACCESS_TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); -} - -export function isAuthenticated(): boolean { - return !!getAccessToken(); -} - -/** - * API helper methods - */ -export const api = { - get: (url: string, config?: object) => - apiClient.get(url, config).then((res) => res.data), - - post: (url: string, data?: unknown, config?: object) => - apiClient.post(url, data, config).then((res) => res.data), - - put: (url: string, data?: unknown, config?: object) => - apiClient.put(url, data, config).then((res) => res.data), - - patch: (url: string, data?: unknown, config?: object) => - apiClient.patch(url, data, config).then((res) => res.data), - - delete: (url: string, config?: object) => - apiClient.delete(url, config).then((res) => res.data), -}; - -/** - * Paginated request helper - */ -export async function getPaginated( - url: string, - params?: { - page?: number; - limit?: number; - sort?: string; - order?: 'asc' | 'desc'; - search?: string; - [key: string]: unknown; - } -): Promise> { - const response = await apiClient.get>(url, { params }); - return response.data; -} - -export default apiClient; diff --git a/web/src/services/auth/auth.api.ts b/web/src/services/auth/auth.api.ts index 06a60b9..81d5b83 100644 --- a/web/src/services/auth/auth.api.ts +++ b/web/src/services/auth/auth.api.ts @@ -55,30 +55,79 @@ export interface RefreshTokenResponse { // API CALLS // ============================================================ +// Backend response wrapper type +interface ApiResponseWrapper { + success: boolean; + data: T; + message?: string; +} + +// Raw user response from backend (different from our User type) +interface BackendUserResponse { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + roles?: string[]; +} + +// Raw auth response from backend +interface BackendAuthResponse { + accessToken: string; + refreshToken: string; + expiresIn?: number; + user: BackendUserResponse; + tenant?: Tenant; +} + +/** + * Transform backend user to frontend User type + */ +function transformUser(backendUser: BackendUserResponse, tenantId: string): User { + return { + ...backendUser, + tenantId, + status: 'active', + role: backendUser.roles?.[0] || 'user', + roles: backendUser.roles, + }; +} + /** * Login con email y password */ export async function login(credentials: LoginCredentials): Promise { - const response = await api.post('/auth/login', credentials); - return response.data; + const response = await api.post>('/auth/login', credentials); + const data = response.data.data; + + // Transform backend response to match frontend expectations + return { + ...data, + user: transformUser(data.user, data.tenant?.id || ''), + }; } /** * Registro de nuevo usuario */ export async function register(data: RegisterData): Promise { - const response = await api.post('/auth/register', data); - return response.data; + const response = await api.post>('/auth/register', data); + const responseData = response.data.data; + + return { + ...responseData, + user: transformUser(responseData.user, responseData.tenant?.id || ''), + }; } /** * Refresh token */ export async function refreshToken(token: string): Promise { - const response = await api.post('/auth/refresh', { + const response = await api.post>('/auth/refresh', { refreshToken: token, }); - return response.data; + return response.data.data; } /** @@ -88,12 +137,30 @@ export async function logout(): Promise { await api.post('/auth/logout'); } +// Backend /auth/me response +interface BackendMeResponse { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + roles?: string[]; + tenantId?: string; +} + /** * Obtener usuario actual */ export async function getCurrentUser(): Promise { - const response = await api.get('/auth/me'); - return response.data; + const response = await api.get>('/auth/me'); + const data = response.data.data; + + return { + ...data, + tenantId: data.tenantId || '', + status: 'active', + role: data.roles?.[0] || 'user', + roles: data.roles, + }; } /** diff --git a/web/src/services/finance/accounting.api.ts b/web/src/services/finance/accounting.api.ts new file mode 100644 index 0000000..4af03a2 --- /dev/null +++ b/web/src/services/finance/accounting.api.ts @@ -0,0 +1,133 @@ +/** + * Accounting API - Contabilidad (Cuentas y Pólizas) + */ + +import api from '../api'; +import type { PaginatedResponse } from '../../types/api.types'; +import type { + Account, + AccountFilters, + CreateAccountDto, + UpdateAccountDto, + AccountingEntry, + EntryFilters, + CreateEntryDto, + UpdateEntryDto, + TrialBalance, + AccountLedger, +} from '../../types/finance.types'; + +// ============================================================================ +// ACCOUNTS API +// ============================================================================ + +export const accountsApi = { + list: async (filters?: AccountFilters): Promise> => { + const response = await api.get>('/accounting/accounts', { + params: filters, + }); + return response.data; + }, + + tree: async (): Promise => { + const response = await api.get('/accounting/accounts/tree'); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/accounting/accounts/${id}`); + return response.data; + }, + + create: async (data: CreateAccountDto): Promise => { + const response = await api.post('/accounting/accounts', data); + return response.data; + }, + + update: async (id: string, data: UpdateAccountDto): Promise => { + const response = await api.put(`/accounting/accounts/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/accounting/accounts/${id}`); + }, +}; + +// ============================================================================ +// ENTRIES API (Pólizas) +// ============================================================================ + +export const entriesApi = { + list: async (filters?: EntryFilters): Promise> => { + const response = await api.get>('/accounting/entries', { + params: filters, + }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/accounting/entries/${id}`); + return response.data; + }, + + create: async (data: CreateEntryDto): Promise => { + const response = await api.post('/accounting/entries', data); + return response.data; + }, + + update: async (id: string, data: UpdateEntryDto): Promise => { + const response = await api.put(`/accounting/entries/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/accounting/entries/${id}`); + }, + + // Workflow actions + submit: async (id: string): Promise => { + const response = await api.post(`/accounting/entries/${id}/submit`); + return response.data; + }, + + approve: async (id: string): Promise => { + const response = await api.post(`/accounting/entries/${id}/approve`); + return response.data; + }, + + post: async (id: string): Promise => { + const response = await api.post(`/accounting/entries/${id}/post`); + return response.data; + }, + + cancel: async (id: string): Promise => { + const response = await api.post(`/accounting/entries/${id}/cancel`); + return response.data; + }, + + reverse: async (id: string): Promise => { + const response = await api.post(`/accounting/entries/${id}/reverse`); + return response.data; + }, +}; + +// ============================================================================ +// REPORTS API +// ============================================================================ + +export const accountingReportsApi = { + trialBalance: async (period: string): Promise => { + const response = await api.get('/accounting/reports/trial-balance', { + params: { period }, + }); + return response.data; + }, + + accountLedger: async (accountId: string, period: string): Promise => { + const response = await api.get(`/accounting/account-ledger/${accountId}`, { + params: { period }, + }); + return response.data; + }, +}; diff --git a/web/src/services/finance/ap.api.ts b/web/src/services/finance/ap.api.ts new file mode 100644 index 0000000..35a087d --- /dev/null +++ b/web/src/services/finance/ap.api.ts @@ -0,0 +1,66 @@ +/** + * Accounts Payable API - Cuentas por Pagar + */ + +import api from '../api'; +import type { PaginatedResponse } from '../../types/api.types'; +import type { + AccountsPayable, + APFilters, + APStats, + ScheduleAPPaymentDto, + RegisterAPPaymentDto, +} from '../../types/finance.types'; + +export const apApi = { + list: async (filters?: APFilters): Promise> => { + const response = await api.get>('/ap', { + params: filters, + }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/ap/${id}`); + return response.data; + }, + + stats: async (): Promise => { + const response = await api.get('/ap/stats'); + return response.data; + }, + + aging: async (): Promise<{ + current: AccountsPayable[]; + overdue30: AccountsPayable[]; + overdue60: AccountsPayable[]; + overdue90Plus: AccountsPayable[]; + }> => { + const response = await api.get('/ap/aging'); + return response.data; + }, + + schedule: async (id: string, data: ScheduleAPPaymentDto): Promise => { + const response = await api.post(`/ap/${id}/schedule`, data); + return response.data; + }, + + registerPayment: async (id: string, data: RegisterAPPaymentDto): Promise => { + const response = await api.post(`/ap/${id}/pay`, data); + return response.data; + }, + + getScheduled: async (): Promise => { + const response = await api.get('/ap/scheduled'); + return response.data; + }, + + getCalendar: async (month: string): Promise<{ + date: string; + items: AccountsPayable[]; + total: number; + }[]> => { + const response = await api.get('/ap/calendar', { params: { month } }); + return response.data; + }, +}; diff --git a/web/src/services/finance/ar.api.ts b/web/src/services/finance/ar.api.ts new file mode 100644 index 0000000..18217b7 --- /dev/null +++ b/web/src/services/finance/ar.api.ts @@ -0,0 +1,50 @@ +/** + * Accounts Receivable API - Cuentas por Cobrar + */ + +import api from '../api'; +import type { PaginatedResponse } from '../../types/api.types'; +import type { + AccountsReceivable, + ARFilters, + ARStats, + RegisterARPaymentDto, +} from '../../types/finance.types'; + +export const arApi = { + list: async (filters?: ARFilters): Promise> => { + const response = await api.get>('/ar', { + params: filters, + }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/ar/${id}`); + return response.data; + }, + + stats: async (): Promise => { + const response = await api.get('/ar/stats'); + return response.data; + }, + + aging: async (): Promise<{ + current: AccountsReceivable[]; + overdue30: AccountsReceivable[]; + overdue60: AccountsReceivable[]; + overdue90Plus: AccountsReceivable[]; + }> => { + const response = await api.get('/ar/aging'); + return response.data; + }, + + registerPayment: async (id: string, data: RegisterARPaymentDto): Promise => { + const response = await api.post(`/ar/${id}/payment`, data); + return response.data; + }, + + sendReminder: async (id: string): Promise => { + await api.post(`/ar/${id}/reminder`); + }, +}; diff --git a/web/src/services/finance/cash-flow.api.ts b/web/src/services/finance/cash-flow.api.ts new file mode 100644 index 0000000..c818a75 --- /dev/null +++ b/web/src/services/finance/cash-flow.api.ts @@ -0,0 +1,41 @@ +/** + * Cash Flow API - Flujo de Efectivo + */ + +import api from '../api'; +import type { CashFlowPeriod, CashFlowProjection } from '../../types/finance.types'; + +export const cashFlowApi = { + actual: async (period: string): Promise => { + const response = await api.get('/cash-flow/actual', { + params: { period }, + }); + return response.data; + }, + + projected: async (months?: number): Promise => { + const response = await api.get('/cash-flow/projected', { + params: { months: months || 6 }, + }); + return response.data; + }, + + byCategory: async (period: string): Promise<{ + category: string; + inflows: number; + outflows: number; + net: number; + }[]> => { + const response = await api.get('/cash-flow/by-category', { + params: { period }, + }); + return response.data; + }, + + monthly: async (year: number): Promise => { + const response = await api.get('/cash-flow/monthly', { + params: { year }, + }); + return response.data; + }, +}; diff --git a/web/src/services/finance/index.ts b/web/src/services/finance/index.ts new file mode 100644 index 0000000..b1e3bcf --- /dev/null +++ b/web/src/services/finance/index.ts @@ -0,0 +1,9 @@ +/** + * Finance Services Index + */ + +export * from './accounting.api'; +export * from './ar.api'; +export * from './ap.api'; +export * from './cash-flow.api'; +export * from './invoices.api'; diff --git a/web/src/services/finance/invoices.api.ts b/web/src/services/finance/invoices.api.ts new file mode 100644 index 0000000..9035db0 --- /dev/null +++ b/web/src/services/finance/invoices.api.ts @@ -0,0 +1,95 @@ +/** + * Invoices API - Facturación + */ + +import api from '../api'; +import type { PaginatedResponse } from '../../types/api.types'; +import type { + Invoice, + InvoiceFilters, + InvoiceStats, + CreateInvoiceDto, + UpdateInvoiceDto, +} from '../../types/finance.types'; + +export const invoicesApi = { + list: async (filters?: InvoiceFilters): Promise> => { + const response = await api.get>('/invoices', { + params: filters, + }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/invoices/${id}`); + return response.data; + }, + + stats: async (): Promise => { + const response = await api.get('/invoices/summary'); + return response.data; + }, + + dashboard: async (): Promise<{ + stats: InvoiceStats; + recentInvoices: Invoice[]; + overdueInvoices: Invoice[]; + }> => { + const response = await api.get('/invoices/dashboard'); + return response.data; + }, + + create: async (data: CreateInvoiceDto): Promise => { + const response = await api.post('/invoices', data); + return response.data; + }, + + update: async (id: string, data: UpdateInvoiceDto): Promise => { + const response = await api.patch(`/invoices/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/invoices/${id}`); + }, + + // Workflow actions + send: async (id: string): Promise => { + const response = await api.post(`/invoices/${id}/send`); + return response.data; + }, + + markPaid: async (id: string, paymentData?: { + date: string; + amount: number; + method: string; + reference?: string; + }): Promise => { + const response = await api.post(`/invoices/${id}/pay`, paymentData); + return response.data; + }, + + cancel: async (id: string, reason?: string): Promise => { + const response = await api.post(`/invoices/${id}/cancel`, { reason }); + return response.data; + }, + + // PDF + getPdf: async (id: string): Promise => { + const response = await api.get(`/invoices/${id}/pdf`, { + responseType: 'blob', + }); + return response.data; + }, + + // CFDI (Mexico e-invoicing) + stamp: async (id: string): Promise => { + const response = await api.post(`/invoices/${id}/stamp`); + return response.data; + }, + + cancelCfdi: async (id: string, reason: string): Promise => { + const response = await api.post(`/invoices/${id}/cancel-cfdi`, { reason }); + return response.data; + }, +}; diff --git a/web/src/services/mockData.construccion.ts b/web/src/services/mockData.construccion.ts new file mode 100644 index 0000000..0d71497 --- /dev/null +++ b/web/src/services/mockData.construccion.ts @@ -0,0 +1,556 @@ +/** + * Mock Data for Construction Module + * Used as fallback when API calls fail + */ + +import { Fraccionamiento } from './construccion/fraccionamientos.api'; +import { Etapa } from './construccion/etapas.api'; +import { Manzana } from './construccion/manzanas.api'; +import { Lote } from './construccion/lotes.api'; +import { Prototipo } from './construccion/prototipos.api'; + +const TENANT_ID = '00000000-0000-0000-0003-000000000001'; +const PROJECT_ID = 'proj-001'; + +// ============================================================================= +// Fraccionamientos Mock +// ============================================================================= + +export const mockFraccionamientos: Fraccionamiento[] = [ + { + id: 'frac-001', + tenantId: TENANT_ID, + proyectoId: PROJECT_ID, + codigo: 'FA-001', + nombre: 'Fraccionamiento Los Álamos', + descripcion: 'Desarrollo habitacional de 250 viviendas en zona norte', + direccion: 'Av. Principal #100, Col. Norte', + fechaInicio: '2025-06-01', + fechaFinEstimada: '2027-12-31', + estado: 'activo', + createdAt: '2025-05-15T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + }, + { + id: 'frac-002', + tenantId: TENANT_ID, + proyectoId: PROJECT_ID, + codigo: 'FA-002', + nombre: 'Residencial Las Palmas', + descripcion: 'Fraccionamiento residencial de lujo con 80 lotes', + direccion: 'Blvd. Las Palmas #500, Col. Centro', + fechaInicio: '2025-03-01', + fechaFinEstimada: '2026-09-30', + estado: 'activo', + createdAt: '2025-02-01T10:00:00Z', + updatedAt: '2026-01-10T10:00:00Z', + }, + { + id: 'frac-003', + tenantId: TENANT_ID, + proyectoId: PROJECT_ID, + codigo: 'FA-003', + nombre: 'Plaza Comercial Norte', + descripcion: 'Desarrollo comercial con 45 locales', + direccion: 'Carretera Norte Km 5', + fechaInicio: '2025-09-01', + fechaFinEstimada: '2026-06-30', + estado: 'pausado', + createdAt: '2025-08-01T10:00:00Z', + updatedAt: '2025-12-15T10:00:00Z', + }, + { + id: 'frac-004', + tenantId: TENANT_ID, + proyectoId: PROJECT_ID, + codigo: 'FA-004', + nombre: 'Condominios Vista Hermosa', + descripcion: 'Torre de 12 pisos con 48 departamentos', + direccion: 'Av. Vista Hermosa #200', + fechaInicio: '2024-01-15', + fechaFinEstimada: '2025-06-30', + estado: 'completado', + createdAt: '2023-12-01T10:00:00Z', + updatedAt: '2025-06-30T10:00:00Z', + }, +]; + +// ============================================================================= +// Etapas Mock +// ============================================================================= + +export const mockEtapas: Etapa[] = [ + { + id: 'etapa-001', + tenantId: TENANT_ID, + fraccionamientoId: 'frac-001', + code: 'E1', + name: 'Etapa 1 - Norte', + description: 'Primera etapa del fraccionamiento, zona norte', + sequence: 1, + totalLots: 80, + status: 'completed', + startDate: '2025-06-01', + expectedEndDate: '2026-03-31', + actualEndDate: '2026-02-28', + createdAt: '2025-05-15T10:00:00Z', + updatedAt: '2026-02-28T10:00:00Z', + fraccionamiento: { + id: 'frac-001', + nombre: 'Fraccionamiento Los Álamos', + codigo: 'FA-001', + }, + }, + { + id: 'etapa-002', + tenantId: TENANT_ID, + fraccionamientoId: 'frac-001', + code: 'E2', + name: 'Etapa 2 - Centro', + description: 'Segunda etapa, zona central', + sequence: 2, + totalLots: 100, + status: 'in_progress', + startDate: '2026-01-01', + expectedEndDate: '2026-12-31', + createdAt: '2025-12-01T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + fraccionamiento: { + id: 'frac-001', + nombre: 'Fraccionamiento Los Álamos', + codigo: 'FA-001', + }, + }, + { + id: 'etapa-003', + tenantId: TENANT_ID, + fraccionamientoId: 'frac-001', + code: 'E3', + name: 'Etapa 3 - Sur', + description: 'Tercera etapa, zona sur', + sequence: 3, + totalLots: 70, + status: 'planned', + startDate: '2027-01-01', + expectedEndDate: '2027-12-31', + createdAt: '2025-12-15T10:00:00Z', + updatedAt: '2025-12-15T10:00:00Z', + fraccionamiento: { + id: 'frac-001', + nombre: 'Fraccionamiento Los Álamos', + codigo: 'FA-001', + }, + }, + { + id: 'etapa-004', + tenantId: TENANT_ID, + fraccionamientoId: 'frac-002', + code: 'E1', + name: 'Etapa Única', + description: 'Etapa única del fraccionamiento residencial', + sequence: 1, + totalLots: 80, + status: 'in_progress', + startDate: '2025-03-01', + expectedEndDate: '2026-09-30', + createdAt: '2025-02-01T10:00:00Z', + updatedAt: '2026-01-10T10:00:00Z', + fraccionamiento: { + id: 'frac-002', + nombre: 'Residencial Las Palmas', + codigo: 'FA-002', + }, + }, +]; + +// ============================================================================= +// Manzanas Mock +// ============================================================================= + +export const mockManzanas: Manzana[] = [ + { + id: 'manzana-001', + tenantId: TENANT_ID, + etapaId: 'etapa-001', + code: 'M1', + name: 'Manzana 1', + description: 'Manzana esquinera con vista al parque', + totalLots: 20, + sequence: 1, + createdAt: '2025-06-01T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + etapa: { + id: 'etapa-001', + code: 'E1', + name: 'Etapa 1 - Norte', + fraccionamientoId: 'frac-001', + }, + }, + { + id: 'manzana-002', + tenantId: TENANT_ID, + etapaId: 'etapa-001', + code: 'M2', + name: 'Manzana 2', + description: 'Manzana central', + totalLots: 25, + sequence: 2, + createdAt: '2025-06-01T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + etapa: { + id: 'etapa-001', + code: 'E1', + name: 'Etapa 1 - Norte', + fraccionamientoId: 'frac-001', + }, + }, + { + id: 'manzana-003', + tenantId: TENANT_ID, + etapaId: 'etapa-001', + code: 'M3', + name: 'Manzana 3', + description: 'Manzana junto a área verde', + totalLots: 18, + sequence: 3, + createdAt: '2025-06-01T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + etapa: { + id: 'etapa-001', + code: 'E1', + name: 'Etapa 1 - Norte', + fraccionamientoId: 'frac-001', + }, + }, + { + id: 'manzana-004', + tenantId: TENANT_ID, + etapaId: 'etapa-002', + code: 'M4', + name: 'Manzana 4', + description: 'Primera manzana de etapa 2', + totalLots: 22, + sequence: 1, + createdAt: '2026-01-01T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + etapa: { + id: 'etapa-002', + code: 'E2', + name: 'Etapa 2 - Centro', + fraccionamientoId: 'frac-001', + }, + }, +]; + +// ============================================================================= +// Lotes Mock +// ============================================================================= + +export const mockLotes: Lote[] = [ + { + id: 'lote-001', + tenantId: TENANT_ID, + manzanaId: 'manzana-001', + prototipoId: 'proto-001', + code: 'L01', + officialNumber: '001', + areaM2: 120, + frontM: 8, + depthM: 15, + status: 'sold', + basePrice: 450000, + finalPrice: 475000, + createdAt: '2025-06-01T10:00:00Z', + updatedAt: '2026-01-10T10:00:00Z', + manzana: { id: 'manzana-001', code: 'M1', name: 'Manzana 1', etapaId: 'etapa-001' }, + prototipo: { id: 'proto-001', code: 'CASA-A', name: 'Casa Álamo', type: 'house' }, + }, + { + id: 'lote-002', + tenantId: TENANT_ID, + manzanaId: 'manzana-001', + prototipoId: 'proto-002', + code: 'L02', + officialNumber: '002', + areaM2: 150, + frontM: 10, + depthM: 15, + status: 'in_construction', + basePrice: 520000, + finalPrice: 550000, + createdAt: '2025-06-01T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + manzana: { id: 'manzana-001', code: 'M1', name: 'Manzana 1', etapaId: 'etapa-001' }, + prototipo: { id: 'proto-002', code: 'CASA-B', name: 'Casa Roble', type: 'house' }, + }, + { + id: 'lote-003', + tenantId: TENANT_ID, + manzanaId: 'manzana-001', + code: 'L03', + officialNumber: '003', + areaM2: 130, + frontM: 8.5, + depthM: 15.3, + status: 'reserved', + basePrice: 480000, + createdAt: '2025-06-01T10:00:00Z', + updatedAt: '2026-01-12T10:00:00Z', + manzana: { id: 'manzana-001', code: 'M1', name: 'Manzana 1', etapaId: 'etapa-001' }, + }, + { + id: 'lote-004', + tenantId: TENANT_ID, + manzanaId: 'manzana-001', + code: 'L04', + officialNumber: '004', + areaM2: 125, + frontM: 8.3, + depthM: 15.1, + status: 'available', + basePrice: 465000, + createdAt: '2025-06-01T10:00:00Z', + updatedAt: '2025-06-01T10:00:00Z', + manzana: { id: 'manzana-001', code: 'M1', name: 'Manzana 1', etapaId: 'etapa-001' }, + }, + { + id: 'lote-005', + tenantId: TENANT_ID, + manzanaId: 'manzana-002', + prototipoId: 'proto-001', + code: 'L05', + officialNumber: '005', + areaM2: 140, + frontM: 9, + depthM: 15.5, + status: 'sold', + basePrice: 500000, + finalPrice: 520000, + createdAt: '2025-06-15T10:00:00Z', + updatedAt: '2026-01-05T10:00:00Z', + manzana: { id: 'manzana-002', code: 'M2', name: 'Manzana 2', etapaId: 'etapa-001' }, + prototipo: { id: 'proto-001', code: 'CASA-A', name: 'Casa Álamo', type: 'house' }, + }, +]; + +// ============================================================================= +// Prototipos Mock +// ============================================================================= + +export const mockPrototipos: Prototipo[] = [ + { + id: 'proto-001', + tenantId: TENANT_ID, + code: 'CASA-A', + name: 'Casa Álamo', + description: 'Casa de un piso, diseño moderno con jardín frontal', + type: 'house', + constructionAreaM2: 85, + landAreaM2: 120, + bedrooms: 2, + bathrooms: 1, + parkingSpaces: 1, + floors: 1, + basePrice: 950000, + features: ['Jardín frontal', 'Cocina integral', 'Closets'], + isActive: true, + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-12-01T10:00:00Z', + }, + { + id: 'proto-002', + tenantId: TENANT_ID, + code: 'CASA-B', + name: 'Casa Roble', + description: 'Casa de dos pisos con amplio jardín trasero', + type: 'house', + constructionAreaM2: 120, + landAreaM2: 150, + bedrooms: 3, + bathrooms: 2, + parkingSpaces: 2, + floors: 2, + basePrice: 1450000, + features: ['Jardín trasero', 'Cocina integral', 'Cuarto de servicio', 'Roof garden'], + isActive: true, + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-12-01T10:00:00Z', + }, + { + id: 'proto-003', + tenantId: TENANT_ID, + code: 'CASA-C', + name: 'Casa Cedro Premium', + description: 'Casa residencial de lujo con acabados premium', + type: 'house', + constructionAreaM2: 180, + landAreaM2: 200, + bedrooms: 4, + bathrooms: 3, + parkingSpaces: 2, + floors: 2, + basePrice: 2200000, + features: ['Piscina', 'Jardín grande', 'Cuarto de TV', 'Vestidor master', 'Doble altura'], + isActive: true, + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-12-01T10:00:00Z', + }, + { + id: 'proto-004', + tenantId: TENANT_ID, + code: 'DEPTO-A', + name: 'Departamento Vista', + description: 'Departamento con vista panorámica', + type: 'apartment', + constructionAreaM2: 75, + landAreaM2: 0, + bedrooms: 2, + bathrooms: 1, + parkingSpaces: 1, + floors: 1, + basePrice: 850000, + features: ['Balcón', 'Vista panorámica', 'Área de lavado'], + isActive: true, + createdAt: '2025-03-01T10:00:00Z', + updatedAt: '2025-12-01T10:00:00Z', + }, +]; + +// ============================================================================= +// Filter Helpers +// ============================================================================= + +export function filterFraccionamientos( + items: Fraccionamiento[], + filters?: { search?: string; estado?: string; proyectoId?: string } +): Fraccionamiento[] { + let result = [...items]; + + if (filters?.search) { + const searchLower = filters.search.toLowerCase(); + result = result.filter( + (f) => + f.nombre.toLowerCase().includes(searchLower) || + f.codigo.toLowerCase().includes(searchLower) || + f.descripcion?.toLowerCase().includes(searchLower) + ); + } + + if (filters?.estado) { + result = result.filter((f) => f.estado === filters.estado); + } + + if (filters?.proyectoId) { + result = result.filter((f) => f.proyectoId === filters.proyectoId); + } + + return result; +} + +export function filterEtapas( + items: Etapa[], + filters?: { search?: string; status?: string; fraccionamientoId?: string } +): Etapa[] { + let result = [...items]; + + if (filters?.search) { + const searchLower = filters.search.toLowerCase(); + result = result.filter( + (e) => + e.name.toLowerCase().includes(searchLower) || + e.code.toLowerCase().includes(searchLower) || + e.description?.toLowerCase().includes(searchLower) + ); + } + + if (filters?.status) { + result = result.filter((e) => e.status === filters.status); + } + + if (filters?.fraccionamientoId) { + result = result.filter((e) => e.fraccionamientoId === filters.fraccionamientoId); + } + + return result; +} + +export function filterManzanas( + items: Manzana[], + filters?: { search?: string; etapaId?: string } +): Manzana[] { + let result = [...items]; + + if (filters?.search) { + const searchLower = filters.search.toLowerCase(); + result = result.filter( + (m) => + m.name.toLowerCase().includes(searchLower) || + m.code.toLowerCase().includes(searchLower) || + m.description?.toLowerCase().includes(searchLower) + ); + } + + if (filters?.etapaId) { + result = result.filter((m) => m.etapaId === filters.etapaId); + } + + return result; +} + +export function filterLotes( + items: Lote[], + filters?: { search?: string; status?: string; manzanaId?: string; prototipoId?: string } +): Lote[] { + let result = [...items]; + + if (filters?.search) { + const searchLower = filters.search.toLowerCase(); + result = result.filter( + (l) => + l.code.toLowerCase().includes(searchLower) || + l.officialNumber?.toLowerCase().includes(searchLower) || + l.manzana?.name.toLowerCase().includes(searchLower) + ); + } + + if (filters?.status) { + result = result.filter((l) => l.status === filters.status); + } + + if (filters?.manzanaId) { + result = result.filter((l) => l.manzanaId === filters.manzanaId); + } + + if (filters?.prototipoId) { + result = result.filter((l) => l.prototipoId === filters.prototipoId); + } + + return result; +} + +export function filterPrototipos( + items: Prototipo[], + filters?: { search?: string; type?: string; isActive?: boolean } +): Prototipo[] { + let result = [...items]; + + if (filters?.search) { + const searchLower = filters.search.toLowerCase(); + result = result.filter( + (p) => + p.name.toLowerCase().includes(searchLower) || + p.code.toLowerCase().includes(searchLower) || + p.description?.toLowerCase().includes(searchLower) + ); + } + + if (filters?.type) { + result = result.filter((p) => p.type === filters.type); + } + + if (filters?.isActive !== undefined) { + result = result.filter((p) => p.isActive === filters.isActive); + } + + return result; +} diff --git a/web/src/services/mockData.modules.ts b/web/src/services/mockData.modules.ts new file mode 100644 index 0000000..608a7cc --- /dev/null +++ b/web/src/services/mockData.modules.ts @@ -0,0 +1,678 @@ +/** + * Mock Data for Additional Modules + * Used as fallback when API calls fail + * Uses actual types from API files + */ + +import { Concepto, Presupuesto, PresupuestoEstado } from './presupuestos/presupuestos.api'; +import { Estimacion } from './presupuestos/estimaciones.api'; + +// ============================================================================= +// PRESUPUESTOS MOCK DATA +// ============================================================================= + +export const mockConceptos: Concepto[] = [ + { + id: 'concepto-001', + tenantId: '00000000-0000-0000-0003-000000000001', + codigo: '01', + descripcion: 'PRELIMINARES', + unidad: 'CAP', + tipo: 'capitulo', + nivel: 1, + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-01-01T10:00:00Z', + }, + { + id: 'concepto-002', + tenantId: '00000000-0000-0000-0003-000000000001', + codigo: '01.01', + descripcion: 'Limpieza y trazo', + unidad: 'M2', + tipo: 'concepto', + precioUnitario: 45.50, + parentId: 'concepto-001', + nivel: 2, + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-01-01T10:00:00Z', + }, + { + id: 'concepto-003', + tenantId: '00000000-0000-0000-0003-000000000001', + codigo: '01.02', + descripcion: 'Demolición de estructuras existentes', + unidad: 'M3', + tipo: 'concepto', + precioUnitario: 285.00, + parentId: 'concepto-001', + nivel: 2, + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-01-01T10:00:00Z', + }, + { + id: 'concepto-004', + tenantId: '00000000-0000-0000-0003-000000000001', + codigo: '02', + descripcion: 'CIMENTACIÓN', + unidad: 'CAP', + tipo: 'capitulo', + nivel: 1, + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-01-01T10:00:00Z', + }, + { + id: 'concepto-005', + tenantId: '00000000-0000-0000-0003-000000000001', + codigo: '02.01', + descripcion: 'Excavación a cielo abierto', + unidad: 'M3', + tipo: 'concepto', + precioUnitario: 125.00, + parentId: 'concepto-004', + nivel: 2, + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-01-01T10:00:00Z', + }, + { + id: 'concepto-006', + tenantId: '00000000-0000-0000-0003-000000000001', + codigo: '02.02', + descripcion: 'Zapata aislada de concreto f\'c=250 kg/cm2', + unidad: 'M3', + tipo: 'concepto', + precioUnitario: 3450.00, + parentId: 'concepto-004', + nivel: 2, + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-01-01T10:00:00Z', + }, +]; + +export const mockPresupuestos: Presupuesto[] = [ + { + id: 'presupuesto-001', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-001', + codigo: 'PRES-2026-001', + nombre: 'Presupuesto Fraccionamiento Los Álamos - Etapa 1', + version: 1, + montoTotal: 15000000, + estado: 'aprobado' as PresupuestoEstado, + fechaCreacion: '2025-05-01', + fechaAprobacion: '2025-05-15', + createdAt: '2025-05-01T10:00:00Z', + updatedAt: '2025-05-15T10:00:00Z', + }, + { + id: 'presupuesto-002', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-002', + codigo: 'PRES-2026-002', + nombre: 'Presupuesto Torre Corporativa Centro', + version: 2, + montoTotal: 8500000, + estado: 'aprobado' as PresupuestoEstado, + fechaCreacion: '2025-06-01', + fechaAprobacion: '2025-06-10', + createdAt: '2025-06-01T10:00:00Z', + updatedAt: '2025-06-10T10:00:00Z', + }, + { + id: 'presupuesto-003', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-003', + codigo: 'PRES-2026-003', + nombre: 'Presupuesto Plaza Comercial Norte', + version: 1, + montoTotal: 12000000, + estado: 'borrador' as PresupuestoEstado, + fechaCreacion: '2025-08-01', + createdAt: '2025-08-01T10:00:00Z', + updatedAt: '2025-08-01T10:00:00Z', + }, +]; + +export const mockEstimaciones: Estimacion[] = [ + { + id: 'estimacion-001', + tenantId: '00000000-0000-0000-0003-000000000001', + presupuestoId: 'presupuesto-001', + proyectoId: 'proj-001', + numero: 1, + periodo: '2025-06', + fechaInicio: '2025-06-01', + fechaFin: '2025-06-30', + montoEstimado: 1500000, + deductivas: 25000, + montoAprobado: 1475000, + estado: 'cobrado', + createdAt: '2025-07-01T10:00:00Z', + updatedAt: '2025-07-15T10:00:00Z', + }, + { + id: 'estimacion-002', + tenantId: '00000000-0000-0000-0003-000000000001', + presupuestoId: 'presupuesto-001', + proyectoId: 'proj-001', + numero: 2, + periodo: '2025-07', + fechaInicio: '2025-07-01', + fechaFin: '2025-07-31', + montoEstimado: 1800000, + deductivas: 35000, + montoAprobado: 1765000, + estado: 'facturado', + createdAt: '2025-08-01T10:00:00Z', + updatedAt: '2025-08-10T10:00:00Z', + }, + { + id: 'estimacion-003', + tenantId: '00000000-0000-0000-0003-000000000001', + presupuestoId: 'presupuesto-001', + proyectoId: 'proj-001', + numero: 3, + periodo: '2025-08', + fechaInicio: '2025-08-01', + fechaFin: '2025-08-31', + montoEstimado: 2100000, + deductivas: 42000, + montoAprobado: 2058000, + estado: 'aprobado', + createdAt: '2025-09-01T10:00:00Z', + updatedAt: '2025-09-05T10:00:00Z', + }, +]; + +// ============================================================================= +// HSE MOCK DATA - Simple interfaces to avoid circular deps +// ============================================================================= + +export interface MockIncidente { + id: string; + tenantId: string; + proyectoId: string; + codigo: string; + titulo: string; + descripcion: string; + tipo: string; + gravedad: string; + estado: string; + fechaOcurrencia: string; + ubicacion: string; + personasAfectadas: number; + createdAt: string; + updatedAt: string; +} + +export interface MockCapacitacion { + id: string; + tenantId: string; + titulo: string; + descripcion: string; + tipo: string; + duracionHoras: number; + instructor: string; + fechaProgramada: string; + estado: string; + asistentes: number; + createdAt: string; + updatedAt: string; +} + +export interface MockInspeccion { + id: string; + tenantId: string; + proyectoId: string; + codigo: string; + titulo: string; + tipo: string; + fechaProgramada: string; + fechaRealizacion?: string; + inspector: string; + estado: string; + hallazgos: number; + hallazgosCriticos: number; + createdAt: string; + updatedAt: string; +} + +export const mockIncidentes: MockIncidente[] = [ + { + id: 'incidente-001', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-001', + codigo: 'INC-2026-001', + titulo: 'Caída de altura menor', + descripcion: 'Trabajador resbaló en andamio mojado, sin lesiones mayores', + tipo: 'incidente', + gravedad: 'leve', + estado: 'cerrado', + fechaOcurrencia: '2026-01-15', + ubicacion: 'Manzana 2, Lote 5', + personasAfectadas: 1, + createdAt: '2026-01-15T10:00:00Z', + updatedAt: '2026-01-20T10:00:00Z', + }, + { + id: 'incidente-002', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-002', + codigo: 'INC-2026-002', + titulo: 'Exposición a material peligroso', + descripcion: 'Contacto con cemento sin guantes apropiados', + tipo: 'casi_accidente', + gravedad: 'moderado', + estado: 'investigacion', + fechaOcurrencia: '2026-01-28', + ubicacion: 'Nivel 3, Zona A', + personasAfectadas: 2, + createdAt: '2026-01-28T10:00:00Z', + updatedAt: '2026-01-30T10:00:00Z', + }, + { + id: 'incidente-003', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-001', + codigo: 'INC-2026-003', + titulo: 'Cable eléctrico expuesto', + descripcion: 'Se detectó cable sin protección en área de trabajo', + tipo: 'condicion_insegura', + gravedad: 'grave', + estado: 'reportado', + fechaOcurrencia: '2026-02-01', + ubicacion: 'Bodega principal', + personasAfectadas: 0, + createdAt: '2026-02-01T10:00:00Z', + updatedAt: '2026-02-01T10:00:00Z', + }, +]; + +export const mockCapacitaciones: MockCapacitacion[] = [ + { + id: 'capacitacion-001', + tenantId: '00000000-0000-0000-0003-000000000001', + titulo: 'Inducción de seguridad en obra', + descripcion: 'Capacitación básica para personal nuevo', + tipo: 'induccion', + duracionHoras: 4, + instructor: 'Ing. Roberto Sánchez', + fechaProgramada: '2026-02-05', + estado: 'completada', + asistentes: 25, + createdAt: '2026-01-20T10:00:00Z', + updatedAt: '2026-02-05T10:00:00Z', + }, + { + id: 'capacitacion-002', + tenantId: '00000000-0000-0000-0003-000000000001', + titulo: 'Trabajo en alturas', + descripcion: 'Capacitación específica para trabajo en andamios y estructuras', + tipo: 'especifica', + duracionHoras: 8, + instructor: 'Ing. María González', + fechaProgramada: '2026-02-10', + estado: 'programada', + asistentes: 15, + createdAt: '2026-01-25T10:00:00Z', + updatedAt: '2026-01-25T10:00:00Z', + }, + { + id: 'capacitacion-003', + tenantId: '00000000-0000-0000-0003-000000000001', + titulo: 'Primeros auxilios', + descripcion: 'Reciclaje anual de conocimientos en primeros auxilios', + tipo: 'reciclaje', + duracionHoras: 6, + instructor: 'Dr. Carlos López', + fechaProgramada: '2026-02-15', + estado: 'programada', + asistentes: 30, + createdAt: '2026-01-28T10:00:00Z', + updatedAt: '2026-01-28T10:00:00Z', + }, +]; + +export const mockInspecciones: MockInspeccion[] = [ + { + id: 'inspeccion-001', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-001', + codigo: 'INSP-2026-001', + titulo: 'Inspección semanal de obra', + tipo: 'rutinaria', + fechaProgramada: '2026-02-03', + fechaRealizacion: '2026-02-03', + inspector: 'Ing. Pedro Ramírez', + estado: 'completada', + hallazgos: 5, + hallazgosCriticos: 1, + createdAt: '2026-01-27T10:00:00Z', + updatedAt: '2026-02-03T10:00:00Z', + }, + { + id: 'inspeccion-002', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-002', + codigo: 'INSP-2026-002', + titulo: 'Auditoría de EPP', + tipo: 'auditoria', + fechaProgramada: '2026-02-08', + inspector: 'Ing. Ana Torres', + estado: 'programada', + hallazgos: 0, + hallazgosCriticos: 0, + createdAt: '2026-02-01T10:00:00Z', + updatedAt: '2026-02-01T10:00:00Z', + }, +]; + +// ============================================================================= +// BIDDING MOCK DATA - Using actual API types +// ============================================================================= + +import { Opportunity, Tender, Vendor } from './bidding'; + +export const mockOportunidades: Opportunity[] = [ + { + id: 'oportunidad-001', + tenantId: '00000000-0000-0000-0003-000000000001', + title: 'Construcción de Hospital Regional', + clientName: 'Gobierno del Estado', + description: 'Licitación para construcción de hospital de 200 camas', + estimatedValue: 150000000, + expectedCloseDate: '2026-03-15', + status: 'qualified', + probability: 45, + createdAt: '2026-01-15T10:00:00Z', + updatedAt: '2026-01-30T10:00:00Z', + }, + { + id: 'oportunidad-002', + tenantId: '00000000-0000-0000-0003-000000000001', + title: 'Ampliación Centro Comercial', + clientName: 'Grupo Comercial del Norte', + description: 'Ampliación de 15,000 m2 adicionales', + estimatedValue: 45000000, + expectedCloseDate: '2026-02-28', + status: 'proposal', + probability: 70, + createdAt: '2026-01-10T10:00:00Z', + updatedAt: '2026-02-01T10:00:00Z', + }, + { + id: 'oportunidad-003', + tenantId: '00000000-0000-0000-0003-000000000001', + title: 'Pavimentación Zona Industrial', + clientName: 'Municipio', + description: 'Pavimentación de 8 km de vialidades', + estimatedValue: 22000000, + expectedCloseDate: '2026-04-01', + status: 'lead', + probability: 30, + createdAt: '2026-02-01T10:00:00Z', + updatedAt: '2026-02-01T10:00:00Z', + }, +]; + +export const mockConcursos: Tender[] = [ + { + id: 'concurso-001', + tenantId: '00000000-0000-0000-0003-000000000001', + referenceNumber: 'LIC-2026-001', + title: 'Construcción de Puente Vehicular', + issuingEntity: 'SCT', + estimatedBudget: 85000000, + publicationDate: '2026-01-15', + submissionDeadline: '2026-02-15', + status: 'evaluation', + createdAt: '2026-01-10T10:00:00Z', + updatedAt: '2026-01-20T10:00:00Z', + }, + { + id: 'concurso-002', + tenantId: '00000000-0000-0000-0003-000000000001', + referenceNumber: 'LIC-2026-002', + title: 'Remodelación de Oficinas Gubernamentales', + issuingEntity: 'Gobierno Federal', + estimatedBudget: 12000000, + publicationDate: '2026-02-01', + submissionDeadline: '2026-02-20', + status: 'published', + createdAt: '2026-01-25T10:00:00Z', + updatedAt: '2026-01-25T10:00:00Z', + }, +]; + +export const mockProveedores: Vendor[] = [ + { + id: 'proveedor-001', + tenantId: '00000000-0000-0000-0003-000000000001', + businessName: 'Aceros del Norte S.A. de C.V.', + rfc: 'ANO123456789', + contactName: 'Juan Pérez', + phone: '555-123-4567', + email: 'ventas@acerosnorte.com', + isActive: true, + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + }, + { + id: 'proveedor-002', + tenantId: '00000000-0000-0000-0003-000000000001', + businessName: 'Maquinaria Pesada Central', + rfc: 'MPC987654321', + contactName: 'María García', + phone: '555-987-6543', + email: 'renta@maqpesada.com', + isActive: true, + createdAt: '2025-02-01T10:00:00Z', + updatedAt: '2026-01-10T10:00:00Z', + }, + { + id: 'proveedor-003', + tenantId: '00000000-0000-0000-0003-000000000001', + businessName: 'Instalaciones Eléctricas Profesionales', + rfc: 'IEP456789012', + contactName: 'Roberto Sánchez', + phone: '555-456-7890', + email: 'cotiza@iepro.com', + isActive: true, + createdAt: '2025-03-01T10:00:00Z', + updatedAt: '2026-01-05T10:00:00Z', + }, +]; + +// ============================================================================= +// PROGRESS (AVANCES) MOCK DATA +// ============================================================================= + +export interface MockAvance { + id: string; + tenantId: string; + proyectoId: string; + conceptoId: string; + periodo: string; + cantidadProgramada: number; + cantidadReal: number; + porcentajeAvance: number; + observaciones?: string; + createdAt: string; + updatedAt: string; +} + +export interface MockBitacora { + id: string; + tenantId: string; + proyectoId: string; + fecha: string; + clima: string; + personalObra: number; + personalContratista: number; + actividadesRealizadas: string; + observaciones?: string; + createdBy: string; + createdAt: string; + updatedAt: string; +} + +export const mockAvances: MockAvance[] = [ + { + id: 'avance-001', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-001', + conceptoId: 'concepto-002', + periodo: '2026-01', + cantidadProgramada: 1000, + cantidadReal: 950, + porcentajeAvance: 95, + observaciones: 'Ligero retraso por lluvia', + createdAt: '2026-01-31T10:00:00Z', + updatedAt: '2026-01-31T10:00:00Z', + }, + { + id: 'avance-002', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-001', + conceptoId: 'concepto-005', + periodo: '2026-01', + cantidadProgramada: 500, + cantidadReal: 520, + porcentajeAvance: 104, + observaciones: 'Avance adelantado', + createdAt: '2026-01-31T10:00:00Z', + updatedAt: '2026-01-31T10:00:00Z', + }, +]; + +export const mockBitacoras: MockBitacora[] = [ + { + id: 'bitacora-001', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-001', + fecha: '2026-02-03', + clima: 'soleado', + personalObra: 45, + personalContratista: 12, + actividadesRealizadas: 'Continuación de cimentación en manzana 3. Colado de zapatas Z-15 a Z-20.', + observaciones: 'Se recibió entrega de acero para siguientes etapas.', + createdBy: 'Ing. Carlos Mendoza', + createdAt: '2026-02-03T18:00:00Z', + updatedAt: '2026-02-03T18:00:00Z', + }, + { + id: 'bitacora-002', + tenantId: '00000000-0000-0000-0003-000000000001', + proyectoId: 'proj-001', + fecha: '2026-02-02', + clima: 'nublado', + personalObra: 42, + personalContratista: 15, + actividadesRealizadas: 'Armado de acero en zapatas. Preparación de cimbra para trabes.', + observaciones: 'Se realizó inspección de calidad sin observaciones.', + createdBy: 'Ing. Carlos Mendoza', + createdAt: '2026-02-02T18:00:00Z', + updatedAt: '2026-02-02T18:00:00Z', + }, +]; + +// ============================================================================= +// FINANCE MOCK DATA +// ============================================================================= + +export interface MockCuentaPorCobrar { + id: string; + tenantId: string; + clienteId: string; + clienteNombre: string; + facturaNumero: string; + concepto: string; + monto: number; + saldo: number; + fechaEmision: string; + fechaVencimiento: string; + estado: string; + createdAt: string; + updatedAt: string; +} + +export interface MockCuentaPorPagar { + id: string; + tenantId: string; + proveedorId: string; + proveedorNombre: string; + facturaNumero: string; + concepto: string; + monto: number; + saldo: number; + fechaEmision: string; + fechaVencimiento: string; + estado: string; + createdAt: string; + updatedAt: string; +} + +export const mockCuentasPorCobrar: MockCuentaPorCobrar[] = [ + { + id: 'cxc-001', + tenantId: '00000000-0000-0000-0003-000000000001', + clienteId: 'cliente-001', + clienteNombre: 'Desarrollos Inmobiliarios del Norte', + facturaNumero: 'FAC-2026-0125', + concepto: 'Estimación #3 Fraccionamiento Los Álamos', + monto: 2058000, + saldo: 2058000, + fechaEmision: '2026-01-15', + fechaVencimiento: '2026-02-15', + estado: 'pendiente', + createdAt: '2026-01-15T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + }, + { + id: 'cxc-002', + tenantId: '00000000-0000-0000-0003-000000000001', + clienteId: 'cliente-002', + clienteNombre: 'Grupo Constructor Centro', + facturaNumero: 'FAC-2026-0098', + concepto: 'Anticipo obra Torre Corporativa', + monto: 1500000, + saldo: 500000, + fechaEmision: '2026-01-01', + fechaVencimiento: '2026-01-31', + estado: 'parcial', + createdAt: '2026-01-01T10:00:00Z', + updatedAt: '2026-01-20T10:00:00Z', + }, +]; + +export const mockCuentasPorPagar: MockCuentaPorPagar[] = [ + { + id: 'cxp-001', + tenantId: '00000000-0000-0000-0003-000000000001', + proveedorId: 'proveedor-001', + proveedorNombre: 'Aceros del Norte S.A. de C.V.', + facturaNumero: 'A-45678', + concepto: 'Acero de refuerzo para cimentación', + monto: 450000, + saldo: 450000, + fechaEmision: '2026-01-20', + fechaVencimiento: '2026-02-20', + estado: 'pendiente', + createdAt: '2026-01-20T10:00:00Z', + updatedAt: '2026-01-20T10:00:00Z', + }, + { + id: 'cxp-002', + tenantId: '00000000-0000-0000-0003-000000000001', + proveedorId: 'proveedor-002', + proveedorNombre: 'Maquinaria Pesada Central', + facturaNumero: 'MPC-2026-089', + concepto: 'Renta de retroexcavadora enero', + monto: 85000, + saldo: 0, + fechaEmision: '2026-01-05', + fechaVencimiento: '2026-01-20', + estado: 'pagada', + createdAt: '2026-01-05T10:00:00Z', + updatedAt: '2026-01-18T10:00:00Z', + }, +]; diff --git a/web/src/services/mockData.ts b/web/src/services/mockData.ts new file mode 100644 index 0000000..ff365f7 --- /dev/null +++ b/web/src/services/mockData.ts @@ -0,0 +1,240 @@ +/** + * Mock Data for Development/Demo + * Used as fallback when API calls fail + */ + +import { + DashboardStats, + ProjectSummary, + EarnedValueMetrics, + Alert, + SCurveDataPoint, +} from './reports'; + +// ============================================================================= +// Dashboard Stats Mock +// ============================================================================= + +export const mockDashboardStats: DashboardStats = { + totalProyectos: 12, + proyectosActivos: 8, + presupuestoTotal: 45000000, + avancePromedio: 67.5, + alertasActivas: 3, +}; + +// ============================================================================= +// Projects Summary Mock +// ============================================================================= + +export const mockProjectsSummary: ProjectSummary[] = [ + { + id: 'proj-001', + nombre: 'Fraccionamiento Los Alamos', + presupuesto: 15000000, + avanceReal: 72, + avanceProgramado: 68, + spi: 1.06, + cpi: 0.98, + status: 'green', + }, + { + id: 'proj-002', + nombre: 'Torre Corporativa Centro', + presupuesto: 8500000, + avanceReal: 45, + avanceProgramado: 52, + spi: 0.87, + cpi: 0.92, + status: 'yellow', + }, + { + id: 'proj-003', + nombre: 'Plaza Comercial Norte', + presupuesto: 12000000, + avanceReal: 28, + avanceProgramado: 40, + spi: 0.70, + cpi: 0.85, + status: 'red', + }, + { + id: 'proj-004', + nombre: 'Residencial Las Palmas', + presupuesto: 6500000, + avanceReal: 95, + avanceProgramado: 92, + spi: 1.03, + cpi: 1.02, + status: 'green', + }, + { + id: 'proj-005', + nombre: 'Bodega Industrial Sur', + presupuesto: 3000000, + avanceReal: 60, + avanceProgramado: 58, + spi: 1.03, + cpi: 0.96, + status: 'green', + }, +]; + +// ============================================================================= +// Alerts Mock +// ============================================================================= + +export const mockAlerts: Alert[] = [ + { + id: 'alert-001', + title: 'Retraso en entrega de materiales', + message: 'El proveedor de acero reporta retraso de 5 días en la entrega programada para la semana 12.', + severity: 'warning', + type: 'schedule', + projectId: 'proj-002', + projectName: 'Torre Corporativa Centro', + createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + acknowledgedAt: undefined, + }, + { + id: 'alert-002', + title: 'SPI crítico detectado', + message: 'El proyecto Plaza Comercial Norte tiene un SPI de 0.70, muy por debajo del umbral aceptable.', + severity: 'critical', + type: 'schedule', + projectId: 'proj-003', + projectName: 'Plaza Comercial Norte', + createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + acknowledgedAt: undefined, + }, + { + id: 'alert-003', + title: 'Inspección de calidad pendiente', + message: 'La inspección de cimentación para el lote 15 está programada para mañana.', + severity: 'info', + type: 'quality', + projectId: 'proj-001', + projectName: 'Fraccionamiento Los Alamos', + createdAt: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), + acknowledgedAt: undefined, + }, +]; + +// ============================================================================= +// Project KPIs Mock (Factory function based on project) +// ============================================================================= + +export function getMockProjectKPIs(projectId: string): EarnedValueMetrics { + const projectKPIsMap: Record = { + 'proj-001': { + date: new Date().toISOString(), + spi: 1.06, + cpi: 0.98, + tcpi: 1.02, + sv: 450000, + cv: -120000, + pv: 10200000, + ev: 10800000, + ac: 11000000, + bac: 15000000, + eac: 15306122, + etc: 4306122, + vac: -306122, + percentComplete: 72, + status: 'green', + }, + 'proj-002': { + date: new Date().toISOString(), + spi: 0.87, + cpi: 0.92, + tcpi: 1.15, + sv: -595000, + cv: -340000, + pv: 4420000, + ev: 3825000, + ac: 4165000, + bac: 8500000, + eac: 9239130, + etc: 5074130, + vac: -739130, + percentComplete: 45, + status: 'yellow', + }, + 'proj-003': { + date: new Date().toISOString(), + spi: 0.70, + cpi: 0.85, + tcpi: 1.25, + sv: -1440000, + cv: -588235, + pv: 4800000, + ev: 3360000, + ac: 3952941, + bac: 12000000, + eac: 14117647, + etc: 10164706, + vac: -2117647, + percentComplete: 28, + status: 'red', + }, + }; + + return projectKPIsMap[projectId] || { + date: new Date().toISOString(), + spi: 1.0, + cpi: 1.0, + tcpi: 1.0, + sv: 0, + cv: 0, + pv: 1000000, + ev: 1000000, + ac: 1000000, + bac: 5000000, + eac: 5000000, + etc: 4000000, + vac: 0, + percentComplete: 20, + status: 'green', + }; +} + +// ============================================================================= +// Earned Value Data Mock +// ============================================================================= + +export function getMockEarnedValue(projectId: string): EarnedValueMetrics { + return getMockProjectKPIs(projectId); +} + +// ============================================================================= +// S-Curve Data Mock +// ============================================================================= + +export function getMockSCurveData(_projectId: string): SCurveDataPoint[] { + const baseData: SCurveDataPoint[] = []; + let plannedCum = 0; + let earnedCum = 0; + let actualCum = 0; + + for (let i = 0; i < 12; i++) { + const plannedValue = 1000000 + Math.random() * 500000; + const earnedValue = i < 8 ? plannedValue * (0.9 + Math.random() * 0.2) : 0; + const actualCost = i < 8 ? earnedValue * (0.95 + Math.random() * 0.1) : 0; + + plannedCum += plannedValue; + earnedCum += earnedValue; + actualCum += actualCost; + + baseData.push({ + date: `2026-${String(i + 1).padStart(2, '0')}-01`, + plannedValue, + earnedValue, + actualCost, + plannedCumulative: plannedCum, + earnedCumulative: earnedCum, + actualCumulative: actualCum, + }); + } + + return baseData; +} diff --git a/web/src/stores/authStore.ts b/web/src/stores/authStore.ts index b902732..acbbba2 100644 --- a/web/src/stores/authStore.ts +++ b/web/src/stores/authStore.ts @@ -1,3 +1,8 @@ +/** + * Auth Store - Zustand store for authentication state management + * Based on gamilit implementation with session management + */ + import { create } from 'zustand'; import { persist } from 'zustand/middleware'; @@ -12,27 +17,120 @@ export interface User { } interface AuthState { + // State user: User | null; accessToken: string | null; refreshToken: string | null; isAuthenticated: boolean; + isLoading: boolean; + isInitialized: boolean; + error: string | null; + sessionExpiresAt: number | null; + + // Actions setUser: (user: User | null) => void; setTokens: (accessToken: string, refreshToken: string) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setInitialized: (initialized: boolean) => void; logout: () => void; + clearError: () => void; + checkSession: () => boolean; + extendSession: () => void; } +// Session duration: 7 days +const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; + export const useAuthStore = create()( persist( - (set) => ({ + (set, get) => ({ + // Initial state user: null, accessToken: null, refreshToken: null, isAuthenticated: false, - setUser: (user) => set({ user, isAuthenticated: !!user }), + isLoading: true, // Start as loading until initialized + isInitialized: false, + error: null, + sessionExpiresAt: null, + + // Set user and authentication state + setUser: (user) => + set({ + user, + isAuthenticated: !!user, + isLoading: false, + isInitialized: true, + error: null, + }), + + // Set tokens and extend session setTokens: (accessToken, refreshToken) => - set({ accessToken, refreshToken, isAuthenticated: true }), + set({ + accessToken, + refreshToken, + isAuthenticated: true, + isLoading: false, + isInitialized: true, + sessionExpiresAt: Date.now() + SESSION_DURATION, + error: null, + }), + + // Set loading state + setLoading: (isLoading) => set({ isLoading }), + + // Set error + setError: (error) => set({ error, isLoading: false }), + + // Set initialized (called after initial session check) + setInitialized: (isInitialized) => set({ isInitialized, isLoading: false }), + + // Clear auth state (logout) logout: () => - set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false }), + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + error: null, + sessionExpiresAt: null, + }), + + // Clear error + clearError: () => set({ error: null }), + + // Check if session is still valid + checkSession: () => { + const { sessionExpiresAt, accessToken } = get(); + + // No token = not authenticated + if (!accessToken) { + return false; + } + + // No expiration set = assume valid (will be validated by API) + if (!sessionExpiresAt) { + return true; + } + + // Check if session has expired + const isValid = Date.now() < sessionExpiresAt; + + if (!isValid) { + // Session expired - clear auth + get().logout(); + } + + return isValid; + }, + + // Extend session expiration + extendSession: () => + set({ + sessionExpiresAt: Date.now() + SESSION_DURATION, + }), }), { name: 'erp-construccion-auth', @@ -41,7 +139,26 @@ export const useAuthStore = create()( refreshToken: state.refreshToken, user: state.user, isAuthenticated: state.isAuthenticated, + sessionExpiresAt: state.sessionExpiresAt, }), + // Rehydrate: mark as initialized after loading from storage + onRehydrateStorage: () => (state) => { + if (state) { + // Check if session is still valid after rehydration + const isValid = state.checkSession(); + state.setInitialized(true); + + if (!isValid) { + state.logout(); + } + } + }, } ) ); + +// Selector hooks for performance +export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated); +export const useUser = () => useAuthStore((state) => state.user); +export const useAuthLoading = () => useAuthStore((state) => state.isLoading); +export const useAuthInitialized = () => useAuthStore((state) => state.isInitialized); diff --git a/web/src/types/finance.types.ts b/web/src/types/finance.types.ts new file mode 100644 index 0000000..7179286 --- /dev/null +++ b/web/src/types/finance.types.ts @@ -0,0 +1,556 @@ +/** + * Finance Types - Contabilidad, CxC, CxP, Flujo de Efectivo + */ + +// ============================================================================ +// ACCOUNT TYPES (Catálogo de Cuentas) +// ============================================================================ + +export type AccountType = 'asset' | 'liability' | 'equity' | 'income' | 'expense'; + +export interface Account { + id: string; + tenantId: string; + code: string; + name: string; + type: AccountType; + parentId?: string; + level: number; + isActive: boolean; + allowTransactions: boolean; + balance: number; + children?: Account[]; + createdAt: string; + updatedAt: string; +} + +export interface AccountFilters { + type?: AccountType; + isActive?: boolean; + level?: number; + search?: string; + page?: number; + limit?: number; +} + +export interface CreateAccountDto { + code: string; + name: string; + type: AccountType; + parentId?: string; + allowTransactions?: boolean; + isActive?: boolean; +} + +export interface UpdateAccountDto { + code?: string; + name?: string; + parentId?: string; + allowTransactions?: boolean; + isActive?: boolean; +} + +// ============================================================================ +// ACCOUNTING ENTRY TYPES (Pólizas Contables) +// ============================================================================ + +export type EntryType = 'income' | 'expense' | 'transfer' | 'adjustment' | 'opening' | 'closing'; +export type EntryStatus = 'draft' | 'submitted' | 'approved' | 'posted' | 'cancelled' | 'reversed'; + +export interface AccountingEntryLine { + id: string; + accountId: string; + account?: Account; + debit: number; + credit: number; + description?: string; + reference?: string; +} + +export interface AccountingEntry { + id: string; + tenantId: string; + number: string; + entryNumber: string; // Alias for number + date: string; + type: EntryType; + status: EntryStatus; + reference?: string; + description: string; + lines: AccountingEntryLine[]; + totalDebit: number; + totalCredit: number; + isBalanced: boolean; + postedAt?: string; + postedBy?: string; + createdAt: string; + updatedAt: string; +} + +export interface EntryFilters { + type?: EntryType; + status?: EntryStatus; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface CreateEntryLineDto { + accountId: string; + debit: number; + credit: number; + description?: string; + reference?: string; +} + +export interface CreateEntryDto { + date: string; + type: EntryType; + reference?: string; + description: string; + lines: CreateEntryLineDto[]; +} + +export interface UpdateEntryDto { + date?: string; + type?: EntryType; + reference?: string; + description?: string; + lines?: CreateEntryLineDto[]; +} + +// ============================================================================ +// TRIAL BALANCE (Balanza de Comprobación) +// ============================================================================ + +export interface TrialBalanceItem { + accountId: string; + accountCode: string; + accountName: string; + accountType: AccountType; + level: number; + initialDebit: number; + initialCredit: number; + periodDebit: number; + periodCredit: number; + finalDebit: number; + finalCredit: number; +} + +export interface TrialBalance { + period: string; + startDate: string; + endDate: string; + items: TrialBalanceItem[]; + totals: { + initialDebit: number; + initialCredit: number; + periodDebit: number; + periodCredit: number; + finalDebit: number; + finalCredit: number; + }; +} + +export interface AccountLedgerItem { + entryId: string; + entryNumber: string; + date: string; + description: string; + reference?: string; + debit: number; + credit: number; + balance: number; +} + +export interface AccountLedger { + account: Account; + period: string; + initialBalance: number; + items: AccountLedgerItem[]; + finalBalance: number; +} + +// ============================================================================ +// ACCOUNTS RECEIVABLE (CxC) +// ============================================================================ + +export type ARStatus = 'pending' | 'partial' | 'paid' | 'overdue' | 'cancelled'; + +export interface AccountsReceivable { + id: string; + tenantId: string; + partnerId: string; + partnerName: string; + documentType: string; + documentNumber: string; + documentDate: string; + dueDate: string; + originalAmount: number; + paidAmount: number; + balanceAmount: number; + status: ARStatus; + daysOverdue: number; + payments: ARPayment[]; + createdAt: string; + updatedAt: string; +} + +export interface ARPayment { + id: string; + date: string; + amount: number; + method: string; + reference?: string; + notes?: string; +} + +export interface ARFilters { + partnerId?: string; + status?: ARStatus; + overdue?: boolean; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface ARStats { + totalPending: number; + totalOverdue: number; + current: number; + overdue30: number; + overdue60: number; + overdue90Plus: number; + count: number; +} + +export interface RegisterARPaymentDto { + date: string; + amount: number; + method: string; + reference?: string; + notes?: string; +} + +// ============================================================================ +// ACCOUNTS PAYABLE (CxP) +// ============================================================================ + +export type APStatus = 'pending' | 'scheduled' | 'partial' | 'paid' | 'overdue' | 'cancelled'; + +export interface AccountsPayable { + id: string; + tenantId: string; + partnerId: string; + partnerName: string; + documentType: string; + documentNumber: string; + documentDate: string; + dueDate: string; + scheduledDate?: string; + originalAmount: number; + paidAmount: number; + balanceAmount: number; + status: APStatus; + daysOverdue: number; + payments: APPayment[]; + createdAt: string; + updatedAt: string; +} + +export interface APPayment { + id: string; + date: string; + amount: number; + method: string; + reference?: string; + bankAccount?: string; + notes?: string; +} + +export interface APFilters { + partnerId?: string; + status?: APStatus; + overdue?: boolean; + scheduled?: boolean; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface APStats { + totalPending: number; + totalOverdue: number; + totalScheduled: number; + current: number; + overdue30: number; + overdue60: number; + overdue90Plus: number; + count: number; +} + +export interface ScheduleAPPaymentDto { + scheduledDate: string; + bankAccount?: string; + notes?: string; +} + +export interface RegisterAPPaymentDto { + date: string; + amount: number; + method: string; + reference?: string; + bankAccount?: string; + notes?: string; +} + +// ============================================================================ +// CASH FLOW (Flujo de Efectivo) +// ============================================================================ + +export interface CashFlowItem { + category: string; + subcategory?: string; + description: string; + amount: number; + type: 'inflow' | 'outflow'; +} + +export interface CashFlowPeriod { + period: string; + startDate: string; + endDate: string; + openingBalance: number; + totalInflows: number; + totalOutflows: number; + netFlow: number; + closingBalance: number; + items: CashFlowItem[]; +} + +export interface CashFlowProjection { + periods: CashFlowPeriod[]; + summary: { + projectedInflows: number; + projectedOutflows: number; + projectedNetFlow: number; + lowestBalance: number; + lowestBalancePeriod: string; + }; +} + +// Additional types for Cash Flow Dashboard +export type CashFlowPeriodType = 'day' | 'week' | 'month' | 'quarter' | 'year'; + +export interface CashFlowCategory { + category: string; + amount: number; + count?: number; +} + +export interface CashFlowTransaction { + id: string; + date: string; + description: string; + category: string; + type: 'inflow' | 'outflow'; + amount: number; +} + +export interface CashFlowSummary { + openingBalance: number; + closingBalance: number; + totalInflows: number; + totalOutflows: number; + netChange: number; + inflowCount: number; + outflowCount: number; + inflowsByCategory: CashFlowCategory[]; + outflowsByCategory: CashFlowCategory[]; + recentTransactions?: CashFlowTransaction[]; +} + +export interface DailyProjection { + date: string; + inflows: number; + outflows: number; + net: number; + balance: number; +} + +export interface CashFlowForecast { + expectedInflows: number; + expectedOutflows: number; + projectedBalance: number; + dailyProjection: DailyProjection[]; +} + +// ============================================================================ +// INVOICES (Facturación) +// ============================================================================ + +export type InvoiceStatus = 'draft' | 'sent' | 'viewed' | 'paid' | 'partial' | 'overdue' | 'cancelled'; + +export interface InvoiceLine { + id: string; + productId?: string; + description: string; + quantity: number; + unitPrice: number; + discount: number; + subtotal: number; + taxAmount: number; + total: number; +} + +export interface Invoice { + id: string; + tenantId: string; + number: string; + invoiceNumber: string; // Alias for number + partnerId: string; + partnerName: string; + customerName: string; // Alias for partnerName + partnerRfc?: string; + customerRfc?: string; // Alias for partnerRfc + date: string; + dueDate?: string; + status: InvoiceStatus; + cfdiStatus?: 'none' | 'stamped' | 'cancelled'; + cfdiUuid?: string; + lines: InvoiceLine[]; + subtotal: number; + discountTotal: number; + taxTotal: number; + total: number; + paidAmount: number; + balanceAmount: number; + currency: string; + notes?: string; + cfdiUse?: string; + paymentMethod?: string; + paymentForm?: string; + uuid?: string; + sentAt?: string; + paidAt?: string; + cancelledAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface InvoiceFilters { + partnerId?: string; + status?: InvoiceStatus; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface InvoiceStats { + totalDraft: number; + totalSent: number; + totalPaid: number; + totalOverdue: number; + totalAmount: number; + totalInvoiced: number; // Alias for totalAmount + paidAmount: number; + pendingAmount: number; + totalPending: number; // Alias for pendingAmount + count: number; +} + +export interface CreateInvoiceLineDto { + productId?: string; + description: string; + quantity: number; + unitPrice: number; + discount?: number; +} + +export interface CreateInvoiceDto { + partnerId: string; + date: string; + dueDate: string; + lines: CreateInvoiceLineDto[]; + currency?: string; + notes?: string; + cfdiUse?: string; + paymentMethod?: string; + paymentForm?: string; +} + +export interface UpdateInvoiceDto { + partnerId?: string; + date?: string; + dueDate?: string; + lines?: CreateInvoiceLineDto[]; + notes?: string; + cfdiUse?: string; + paymentMethod?: string; + paymentForm?: string; +} + +// ============================================================================ +// CONSTANTS / OPTIONS +// ============================================================================ + +export const ACCOUNT_TYPE_OPTIONS = [ + { value: 'asset', label: 'Activo', color: 'blue' }, + { value: 'liability', label: 'Pasivo', color: 'red' }, + { value: 'equity', label: 'Capital', color: 'purple' }, + { value: 'income', label: 'Ingreso', color: 'green' }, + { value: 'expense', label: 'Gasto', color: 'orange' }, +] as const; + +export const ENTRY_TYPE_OPTIONS = [ + { value: 'income', label: 'Ingreso', color: 'green' }, + { value: 'expense', label: 'Egreso', color: 'red' }, + { value: 'transfer', label: 'Traspaso', color: 'blue' }, + { value: 'adjustment', label: 'Ajuste', color: 'yellow' }, + { value: 'opening', label: 'Apertura', color: 'purple' }, + { value: 'closing', label: 'Cierre', color: 'gray' }, +] as const; + +export const ENTRY_STATUS_OPTIONS = [ + { value: 'draft', label: 'Borrador', color: 'gray' }, + { value: 'submitted', label: 'Enviada', color: 'blue' }, + { value: 'approved', label: 'Aprobada', color: 'yellow' }, + { value: 'posted', label: 'Contabilizada', color: 'green' }, + { value: 'cancelled', label: 'Cancelada', color: 'red' }, + { value: 'reversed', label: 'Reversada', color: 'purple' }, +] as const; + +export const AR_STATUS_OPTIONS = [ + { value: 'pending', label: 'Pendiente', color: 'yellow' }, + { value: 'partial', label: 'Parcial', color: 'blue' }, + { value: 'paid', label: 'Pagada', color: 'green' }, + { value: 'overdue', label: 'Vencida', color: 'red' }, + { value: 'cancelled', label: 'Cancelada', color: 'gray' }, +] as const; + +export const AP_STATUS_OPTIONS = [ + { value: 'pending', label: 'Pendiente', color: 'yellow' }, + { value: 'scheduled', label: 'Programada', color: 'blue' }, + { value: 'partial', label: 'Parcial', color: 'purple' }, + { value: 'paid', label: 'Pagada', color: 'green' }, + { value: 'overdue', label: 'Vencida', color: 'red' }, + { value: 'cancelled', label: 'Cancelada', color: 'gray' }, +] as const; + +export const INVOICE_STATUS_OPTIONS = [ + { value: 'draft', label: 'Borrador', color: 'gray' }, + { value: 'sent', label: 'Enviada', color: 'blue' }, + { value: 'viewed', label: 'Vista', color: 'purple' }, + { value: 'paid', label: 'Pagada', color: 'green' }, + { value: 'partial', label: 'Parcial', color: 'yellow' }, + { value: 'overdue', label: 'Vencida', color: 'red' }, + { value: 'cancelled', label: 'Cancelada', color: 'gray' }, +] as const; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 1c2b9fb..132b7f9 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -135,3 +135,52 @@ export type { Nullable, Optional, } from './common.types'; + +// Finance Types +export type { + AccountType, + Account, + AccountFilters, + CreateAccountDto, + UpdateAccountDto, + EntryStatus, + EntryType, + AccountingEntryLine, + AccountingEntry, + EntryFilters, + CreateEntryDto, + UpdateEntryDto, + ARStatus, + AccountsReceivable, + ARFilters, + ARStats, + RegisterARPaymentDto, + APStatus, + AccountsPayable, + APFilters, + APStats, + RegisterAPPaymentDto, + CashFlowPeriod, + CashFlowPeriodType, + CashFlowCategory, + CashFlowSummary, + CashFlowTransaction, + DailyProjection, + CashFlowForecast, + InvoiceStatus, + InvoiceLine, + Invoice, + InvoiceFilters, + InvoiceStats, + CreateInvoiceDto, + UpdateInvoiceDto, +} from './finance.types'; + +export { + ACCOUNT_TYPE_OPTIONS, + ENTRY_STATUS_OPTIONS, + ENTRY_TYPE_OPTIONS, + AR_STATUS_OPTIONS, + AP_STATUS_OPTIONS, + INVOICE_STATUS_OPTIONS, +} from './finance.types'; diff --git a/web/src/utils/authCleanup.ts b/web/src/utils/authCleanup.ts new file mode 100644 index 0000000..4a0ee91 --- /dev/null +++ b/web/src/utils/authCleanup.ts @@ -0,0 +1,98 @@ +/** + * Auth Cleanup Utilities + * Ensures complete cleanup during logout to prevent race conditions + * Based on gamilit implementation + */ + +import { useAuthStore } from '../stores/authStore'; + +// Flag to prevent race conditions during logout +const LOGOUT_FLAG_KEY = 'erp_is_logging_out'; + +/** + * Check if logout is in progress + */ +export function isLoggingOut(): boolean { + return localStorage.getItem(LOGOUT_FLAG_KEY) === 'true'; +} + +/** + * Clear all authentication data from localStorage + */ +export function clearAllAuthData(): void { + // Remove auth-specific keys + localStorage.removeItem('erp-construccion-auth'); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('auth-token'); + localStorage.removeItem('refresh-token'); + + // Clear any cached user/session data + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if ( + key?.startsWith('user-') || + key?.startsWith('session-') || + key?.startsWith('erp-') + ) { + keysToRemove.push(key); + } + } + keysToRemove.forEach((key) => localStorage.removeItem(key)); + + // Clear the logout flag + localStorage.removeItem(LOGOUT_FLAG_KEY); +} + +/** + * Perform complete logout + * @param backendLogout - Optional function to call backend logout endpoint + */ +export async function performLogout( + backendLogout?: () => Promise +): Promise { + console.log('[authCleanup] Starting logout sequence...'); + + // CRITICAL: Set logout flag FIRST to prevent race condition + localStorage.setItem(LOGOUT_FLAG_KEY, 'true'); + + try { + // Try to call backend logout + if (backendLogout) { + try { + await backendLogout(); + console.log('[authCleanup] Backend logout successful'); + } catch (error) { + console.error('[authCleanup] Backend logout failed:', error); + // Continue with local cleanup even if backend fails + } + } + } finally { + // Always clear local data + clearAllAuthData(); + + // Clear Zustand store + useAuthStore.getState().logout(); + + console.log('[authCleanup] Local cleanup complete'); + + // Force redirect to login + window.location.href = '/auth/login'; + } +} + +/** + * Check if we should restore session on app load + * Returns false if logout was in progress + */ +export function shouldRestoreSession(): boolean { + if (isLoggingOut()) { + console.log('[authCleanup] Logout was in progress, clearing auth data'); + clearAllAuthData(); + return false; + } + return true; +} + +export default { performLogout, clearAllAuthData, isLoggingOut, shouldRestoreSession }; diff --git a/web/src/utils/index.ts b/web/src/utils/index.ts index ca8b964..6a46faf 100644 --- a/web/src/utils/index.ts +++ b/web/src/utils/index.ts @@ -74,3 +74,11 @@ export { AUTOSAVE_DEBOUNCE_MS, MESSAGES, } from './constants'; + +// Auth Cleanup Utilities +export { + performLogout, + clearAllAuthData, + isLoggingOut, + shouldRestoreSession, +} from './authCleanup'; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 11f02fe..a13e2d9 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -1 +1,14 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_TENANT_ID: string; + readonly VITE_APP_ENV: 'development' | 'staging' | 'production'; + readonly VITE_SHOW_DEMO_LOGIN?: string; + readonly VITE_DEMO_EMAIL?: string; + readonly VITE_DEMO_PASSWORD?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index dfc0e29..ca02e20 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -20,11 +20,13 @@ export default defineConfig({ }, }, server: { - port: 5173, + // Puerto oficial según DEVENV-PORTS-INVENTORY.yml + port: 3020, host: true, proxy: { '/api': { - target: 'http://localhost:3000', + // Backend en puerto 3021 según inventario oficial + target: 'http://localhost:3021', changeOrigin: true, }, },