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 (
+
+
+
+
+ }
+ >
+
+
+ );
+}
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 (
+
+
+
+
+ }
+ >
+
+
+ );
+}
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 (
+
+
+
+
+ }
+ >
+
+
+ );
+}
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 */}
-
+
- {/* 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?
+
{
+ setEmail(import.meta.env.VITE_DEMO_EMAIL || 'admin@demo.com');
+ setPassword(import.meta.env.VITE_DEMO_PASSWORD || 'demo123');
+ }}
+ className="text-sm text-blue-600 hover:text-blue-700 font-medium"
+ >
+ Usar credenciales de 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,
},
},