[MAI-002] feat: Implement frontend Proyectos module
- API services: 5 files (fraccionamientos, etapas, manzanas, lotes, prototipos) - React Query hooks: useConstruccion.ts with 25+ hooks - Pages: 6 pages for CRUD operations - FraccionamientosPage, FraccionamientoDetailPage - EtapasPage, ManzanasPage, LotesPage, PrototiposPage - Components: LoteStatusBadge, HierarchyBreadcrumb - AdminLayout with sidebar navigation - Auth store with Zustand + persist - React Query provider + react-hot-toast setup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fdd4559508
commit
f3d91433fe
20
web/.eslintrc.cjs
Normal file
20
web/.eslintrc.cjs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true, allowExportNames: ['getStatusColor'] },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
69
web/package-lock.json
generated
69
web/package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.3",
|
"@hookform/resolvers": "^3.3.3",
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"date-fns": "^3.0.6",
|
"date-fns": "^3.0.6",
|
||||||
@ -16,6 +17,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.49.2",
|
"react-hook-form": "^7.49.2",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-router-dom": "^6.20.1",
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
@ -84,7 +86,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@ -1317,6 +1318,32 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.90.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||||
|
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.90.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
|
||||||
|
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.90.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@ -1389,7 +1416,6 @@
|
|||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@ -1454,7 +1480,6 @@
|
|||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
@ -1645,7 +1670,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -1879,7 +1903,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@ -2100,7 +2123,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/date-fns": {
|
"node_modules/date-fns": {
|
||||||
@ -2322,7 +2344,6 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@ -2864,6 +2885,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/goober": {
|
||||||
|
"version": "2.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||||
|
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"csstype": "^3.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@ -3073,7 +3103,6 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@ -3584,7 +3613,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@ -3780,7 +3808,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@ -3793,7 +3820,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@ -3807,7 +3833,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz",
|
||||||
"integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==",
|
"integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@ -3819,6 +3844,23 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hot-toast": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.1.3",
|
||||||
|
"goober": "^2.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16",
|
||||||
|
"react-dom": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@ -4258,7 +4300,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -4331,7 +4372,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -4403,7 +4443,6 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|||||||
@ -12,17 +12,19 @@
|
|||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.3.3",
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"date-fns": "^3.0.6",
|
||||||
|
"lucide-react": "^0.303.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.1",
|
|
||||||
"zustand": "^4.4.7",
|
|
||||||
"axios": "^1.6.2",
|
|
||||||
"react-hook-form": "^7.49.2",
|
"react-hook-form": "^7.49.2",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-router-dom": "^6.20.1",
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"@hookform/resolvers": "^3.3.3",
|
"zustand": "^4.4.7"
|
||||||
"date-fns": "^3.0.6",
|
|
||||||
"clsx": "^2.0.0",
|
|
||||||
"lucide-react": "^0.303.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
@ -30,14 +32,14 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
"typescript": "^5.2.2",
|
|
||||||
"vite": "^5.0.8",
|
|
||||||
"autoprefixer": "^10.4.16",
|
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"tailwindcss": "^3.4.0"
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0",
|
"node": ">=18.0.0",
|
||||||
|
|||||||
@ -1,33 +1,49 @@
|
|||||||
/**
|
/**
|
||||||
* App Component
|
* App Component
|
||||||
* Root component con routing básico
|
* Root component con routing para ERP Construccion
|
||||||
*
|
|
||||||
* @author Frontend-Agent
|
|
||||||
* @date 2025-11-20
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AdminLayout } from './layouts/AdminLayout';
|
||||||
|
import {
|
||||||
|
FraccionamientosPage,
|
||||||
|
FraccionamientoDetailPage,
|
||||||
|
EtapasPage,
|
||||||
|
LotesPage,
|
||||||
|
PrototiposPage,
|
||||||
|
} from './pages/admin/proyectos';
|
||||||
|
import { ManzanasPage } from './pages/admin/proyectos/ManzanasPage';
|
||||||
|
|
||||||
/**
|
|
||||||
* Componente principal de la aplicación
|
|
||||||
* TODO: Agregar rutas de los diferentes portales (admin, supervisor, obra)
|
|
||||||
*/
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Ruta principal */}
|
{/* Ruta principal - redirect to admin */}
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<Navigate to="/admin/proyectos/fraccionamientos" replace />} />
|
||||||
|
|
||||||
{/* Portal Admin */}
|
{/* Portal Admin */}
|
||||||
<Route path="/admin/*" element={<div>Admin Portal (TODO)</div>} />
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
|
<Route index element={<Navigate to="proyectos/fraccionamientos" replace />} />
|
||||||
|
<Route path="proyectos">
|
||||||
|
<Route index element={<Navigate to="fraccionamientos" replace />} />
|
||||||
|
<Route path="fraccionamientos" element={<FraccionamientosPage />} />
|
||||||
|
<Route path="fraccionamientos/:id" element={<FraccionamientoDetailPage />} />
|
||||||
|
<Route path="etapas" element={<EtapasPage />} />
|
||||||
|
<Route path="manzanas" element={<ManzanasPage />} />
|
||||||
|
<Route path="lotes" element={<LotesPage />} />
|
||||||
|
<Route path="prototipos" element={<PrototiposPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Portal Supervisor */}
|
{/* Portal Supervisor */}
|
||||||
<Route path="/supervisor/*" element={<div>Supervisor Portal (TODO)</div>} />
|
<Route path="/supervisor/*" element={<div className="p-8">Supervisor Portal (TODO)</div>} />
|
||||||
|
|
||||||
{/* Portal Obra */}
|
{/* Portal Obra */}
|
||||||
<Route path="/obra/*" element={<div>Obra Portal (TODO)</div>} />
|
<Route path="/obra/*" element={<div className="p-8">Obra Portal (TODO)</div>} />
|
||||||
|
|
||||||
|
{/* Auth routes placeholder */}
|
||||||
|
<Route path="/auth/login" element={<LoginPlaceholder />} />
|
||||||
|
|
||||||
{/* 404 */}
|
{/* 404 */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
@ -37,22 +53,21 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function LoginPlaceholder() {
|
||||||
* Página de inicio temporal
|
|
||||||
*/
|
|
||||||
function HomePage() {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
<h1>🏗️ Sistema Administración de Obra</h1>
|
<div className="bg-white p-8 rounded-lg shadow-sm max-w-md w-full">
|
||||||
<p>MVP - INFONAVIT</p>
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">Login</h1>
|
||||||
<ul>
|
<p className="text-gray-600 mb-4">
|
||||||
<li><a href="/admin">Portal Administrador</a></li>
|
Pagina de login placeholder. Por ahora accede directamente a /admin.
|
||||||
<li><a href="/supervisor">Portal Supervisor</a></li>
|
</p>
|
||||||
<li><a href="/obra">Portal Obra</a></li>
|
<a
|
||||||
</ul>
|
href="/admin"
|
||||||
<p style={{ marginTop: '2rem', color: '#666' }}>
|
className="block w-full text-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
Versión: 1.0.0 | Entorno: {import.meta.env.MODE}
|
>
|
||||||
</p>
|
Ir al Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
36
web/src/components/proyectos/HierarchyBreadcrumb.tsx
Normal file
36
web/src/components/proyectos/HierarchyBreadcrumb.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ChevronRight, Home } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HierarchyBreadcrumbProps {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HierarchyBreadcrumb({ items }: HierarchyBreadcrumbProps) {
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center text-sm text-gray-500 mb-4">
|
||||||
|
<Link
|
||||||
|
to="/admin/proyectos"
|
||||||
|
className="flex items-center hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<span key={index} className="flex items-center">
|
||||||
|
<ChevronRight className="w-4 h-4 mx-2" />
|
||||||
|
{item.href ? (
|
||||||
|
<Link to={item.href} className="hover:text-gray-700 transition-colors">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-900 font-medium">{item.label}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
web/src/components/proyectos/LoteStatusBadge.tsx
Normal file
64
web/src/components/proyectos/LoteStatusBadge.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { LoteStatus } from '../../services/construccion/lotes.api';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface LoteStatusBadgeProps {
|
||||||
|
status: LoteStatus;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<LoteStatus, { label: string; className: string }> = {
|
||||||
|
available: {
|
||||||
|
label: 'Disponible',
|
||||||
|
className: 'bg-green-100 text-green-800',
|
||||||
|
},
|
||||||
|
reserved: {
|
||||||
|
label: 'Reservado',
|
||||||
|
className: 'bg-yellow-100 text-yellow-800',
|
||||||
|
},
|
||||||
|
sold: {
|
||||||
|
label: 'Vendido',
|
||||||
|
className: 'bg-blue-100 text-blue-800',
|
||||||
|
},
|
||||||
|
blocked: {
|
||||||
|
label: 'Bloqueado',
|
||||||
|
className: 'bg-gray-100 text-gray-800',
|
||||||
|
},
|
||||||
|
in_construction: {
|
||||||
|
label: 'En Construccion',
|
||||||
|
className: 'bg-orange-100 text-orange-800',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-2 py-0.5 text-xs',
|
||||||
|
md: 'px-2.5 py-1 text-sm',
|
||||||
|
lg: 'px-3 py-1.5 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoteStatusBadge({ status, size = 'md' }: LoteStatusBadgeProps) {
|
||||||
|
const config = statusConfig[status] || statusConfig.available;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center font-medium rounded-full',
|
||||||
|
config.className,
|
||||||
|
sizeClasses[size]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status color utility - exported from separate constant to avoid Fast Refresh warning
|
||||||
|
const statusColors: Record<LoteStatus, string> = {
|
||||||
|
available: '#22c55e',
|
||||||
|
reserved: '#eab308',
|
||||||
|
sold: '#3b82f6',
|
||||||
|
blocked: '#6b7280',
|
||||||
|
in_construction: '#f97316',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStatusColor = (status: LoteStatus): string =>
|
||||||
|
statusColors[status] || '#6b7280';
|
||||||
2
web/src/components/proyectos/index.ts
Normal file
2
web/src/components/proyectos/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { LoteStatusBadge, getStatusColor } from './LoteStatusBadge';
|
||||||
|
export { HierarchyBreadcrumb } from './HierarchyBreadcrumb';
|
||||||
1
web/src/hooks/index.ts
Normal file
1
web/src/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './useConstruccion';
|
||||||
397
web/src/hooks/useConstruccion.ts
Normal file
397
web/src/hooks/useConstruccion.ts
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { ApiError } from '../services/api';
|
||||||
|
import {
|
||||||
|
fraccionamientosApi,
|
||||||
|
FraccionamientoFilters,
|
||||||
|
CreateFraccionamientoDto,
|
||||||
|
UpdateFraccionamientoDto,
|
||||||
|
} from '../services/construccion/fraccionamientos.api';
|
||||||
|
import {
|
||||||
|
etapasApi,
|
||||||
|
EtapaFilters,
|
||||||
|
CreateEtapaDto,
|
||||||
|
UpdateEtapaDto,
|
||||||
|
} from '../services/construccion/etapas.api';
|
||||||
|
import {
|
||||||
|
manzanasApi,
|
||||||
|
ManzanaFilters,
|
||||||
|
CreateManzanaDto,
|
||||||
|
UpdateManzanaDto,
|
||||||
|
} from '../services/construccion/manzanas.api';
|
||||||
|
import {
|
||||||
|
lotesApi,
|
||||||
|
LoteFilters,
|
||||||
|
LoteStatus,
|
||||||
|
CreateLoteDto,
|
||||||
|
UpdateLoteDto,
|
||||||
|
} from '../services/construccion/lotes.api';
|
||||||
|
import {
|
||||||
|
prototiposApi,
|
||||||
|
PrototipoFilters,
|
||||||
|
CreatePrototipoDto,
|
||||||
|
UpdatePrototipoDto,
|
||||||
|
} from '../services/construccion/prototipos.api';
|
||||||
|
|
||||||
|
// Query Keys
|
||||||
|
export const construccionKeys = {
|
||||||
|
fraccionamientos: {
|
||||||
|
all: ['construccion', 'fraccionamientos'] as const,
|
||||||
|
list: (filters?: FraccionamientoFilters) =>
|
||||||
|
[...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,
|
||||||
|
detail: (id: string) => [...construccionKeys.etapas.all, 'detail', id] as const,
|
||||||
|
},
|
||||||
|
manzanas: {
|
||||||
|
all: ['construccion', 'manzanas'] as const,
|
||||||
|
list: (filters?: ManzanaFilters) =>
|
||||||
|
[...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,
|
||||||
|
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) =>
|
||||||
|
[...construccionKeys.prototipos.all, 'list', filters] as const,
|
||||||
|
detail: (id: string) => [...construccionKeys.prototipos.all, 'detail', id] as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
const handleError = (error: AxiosError<ApiError>) => {
|
||||||
|
const message = error.response?.data?.message || 'Ha ocurrido un error';
|
||||||
|
toast.error(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== FRACCIONAMIENTOS ====================
|
||||||
|
|
||||||
|
export function useFraccionamientos(filters?: FraccionamientoFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.fraccionamientos.list(filters),
|
||||||
|
queryFn: () => fraccionamientosApi.list(filters),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFraccionamiento(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.fraccionamientos.detail(id),
|
||||||
|
queryFn: () => fraccionamientosApi.get(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFraccionamiento() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateFraccionamientoDto) => fraccionamientosApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.fraccionamientos.all });
|
||||||
|
toast.success('Fraccionamiento creado exitosamente');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateFraccionamiento() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateFraccionamientoDto }) =>
|
||||||
|
fraccionamientosApi.update(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.fraccionamientos.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.fraccionamientos.detail(id) });
|
||||||
|
toast.success('Fraccionamiento actualizado');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteFraccionamiento() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => fraccionamientosApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.fraccionamientos.all });
|
||||||
|
toast.success('Fraccionamiento eliminado');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ETAPAS ====================
|
||||||
|
|
||||||
|
export function useEtapas(filters?: EtapaFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.etapas.list(filters),
|
||||||
|
queryFn: () => etapasApi.list(filters),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEtapa(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.etapas.detail(id),
|
||||||
|
queryFn: () => etapasApi.get(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateEtapa() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateEtapaDto) => etapasApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.etapas.all });
|
||||||
|
toast.success('Etapa creada exitosamente');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateEtapa() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateEtapaDto }) => etapasApi.update(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.etapas.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.etapas.detail(id) });
|
||||||
|
toast.success('Etapa actualizada');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEtapa() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => etapasApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.etapas.all });
|
||||||
|
toast.success('Etapa eliminada');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MANZANAS ====================
|
||||||
|
|
||||||
|
export function useManzanas(filters?: ManzanaFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.manzanas.list(filters),
|
||||||
|
queryFn: () => manzanasApi.list(filters),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useManzana(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.manzanas.detail(id),
|
||||||
|
queryFn: () => manzanasApi.get(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateManzana() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateManzanaDto) => manzanasApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.manzanas.all });
|
||||||
|
toast.success('Manzana creada exitosamente');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateManzana() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateManzanaDto }) =>
|
||||||
|
manzanasApi.update(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.manzanas.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.manzanas.detail(id) });
|
||||||
|
toast.success('Manzana actualizada');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteManzana() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => manzanasApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.manzanas.all });
|
||||||
|
toast.success('Manzana eliminada');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== LOTES ====================
|
||||||
|
|
||||||
|
export function useLotes(filters?: LoteFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.lotes.list(filters),
|
||||||
|
queryFn: () => lotesApi.list(filters),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLote(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.lotes.detail(id),
|
||||||
|
queryFn: () => lotesApi.get(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoteStats(manzanaId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.lotes.stats(manzanaId),
|
||||||
|
queryFn: () => lotesApi.getStats(manzanaId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateLote() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateLoteDto) => lotesApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.lotes.all });
|
||||||
|
toast.success('Lote creado exitosamente');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLote() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateLoteDto }) => lotesApi.update(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.lotes.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.lotes.detail(id) });
|
||||||
|
toast.success('Lote actualizado');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteLote() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => lotesApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.lotes.all });
|
||||||
|
toast.success('Lote eliminado');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLoteStatus() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: LoteStatus }) =>
|
||||||
|
lotesApi.updateStatus(id, status),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.lotes.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.lotes.detail(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['construccion', 'lotes', 'stats'] });
|
||||||
|
toast.success('Estado del lote actualizado');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignPrototipo() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, prototipoId }: { id: string; prototipoId: string }) =>
|
||||||
|
lotesApi.assignPrototipo(id, prototipoId),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.lotes.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.lotes.detail(id) });
|
||||||
|
toast.success('Prototipo asignado al lote');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PROTOTIPOS ====================
|
||||||
|
|
||||||
|
export function usePrototipos(filters?: PrototipoFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.prototipos.list(filters),
|
||||||
|
queryFn: () => prototiposApi.list(filters),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrototipo(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: construccionKeys.prototipos.detail(id),
|
||||||
|
queryFn: () => prototiposApi.get(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePrototipo() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreatePrototipoDto) => prototiposApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.prototipos.all });
|
||||||
|
toast.success('Prototipo creado exitosamente');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePrototipo() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdatePrototipoDto }) =>
|
||||||
|
prototiposApi.update(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.prototipos.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.prototipos.detail(id) });
|
||||||
|
toast.success('Prototipo actualizado');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePrototipo() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => prototiposApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.prototipos.all });
|
||||||
|
toast.success('Prototipo eliminado');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTogglePrototipoActive() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||||
|
prototiposApi.toggleActive(id, isActive),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.prototipos.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: construccionKeys.prototipos.detail(id) });
|
||||||
|
toast.success('Estado del prototipo actualizado');
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
155
web/src/layouts/AdminLayout.tsx
Normal file
155
web/src/layouts/AdminLayout.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Layers,
|
||||||
|
LayoutGrid,
|
||||||
|
Map,
|
||||||
|
Home,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
|
ChevronDown,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ label: 'Fraccionamientos', href: '/admin/proyectos/fraccionamientos', icon: Building2 },
|
||||||
|
{ label: 'Etapas', href: '/admin/proyectos/etapas', icon: Layers },
|
||||||
|
{ label: 'Manzanas', href: '/admin/proyectos/manzanas', icon: LayoutGrid },
|
||||||
|
{ label: 'Lotes', href: '/admin/proyectos/lotes', icon: Map },
|
||||||
|
{ label: 'Prototipos', href: '/admin/proyectos/prototipos', icon: Home },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
{/* Mobile sidebar backdrop */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-600 bg-opacity-75 z-20 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={clsx(
|
||||||
|
'fixed inset-y-0 left-0 z-30 w-64 bg-white shadow-lg transform transition-transform duration-300 lg:translate-x-0 lg:static',
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between h-16 px-4 border-b">
|
||||||
|
<Link to="/admin" className="flex items-center space-x-2">
|
||||||
|
<Building2 className="w-8 h-8 text-blue-600" />
|
||||||
|
<span className="text-xl font-bold text-gray-900">ERP Construccion</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="lg:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="px-4 py-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
|
Proyectos
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = location.pathname === item.href;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
to={item.href}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center px-3 py-2 rounded-lg transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 mr-3" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="lg:pl-64">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="sticky top-0 z-10 bg-white shadow-sm">
|
||||||
|
<div className="flex items-center justify-between h-16 px-4">
|
||||||
|
<button
|
||||||
|
className="lg:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<Menu className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* User menu */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-100"
|
||||||
|
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<span className="hidden sm:block text-sm font-medium text-gray-700">
|
||||||
|
{user?.firstName || user?.email || 'Usuario'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{userMenuOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border py-1">
|
||||||
|
<button
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
Cerrar Sesion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,18 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* Main Entry Point
|
* Main Entry Point
|
||||||
* MVP Sistema Administración de Obra e INFONAVIT
|
* ERP Sistema Administracion de Obra e INFONAVIT
|
||||||
*
|
|
||||||
* @author Frontend-Agent
|
|
||||||
* @date 2025-11-20
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
</React.StrictMode>,
|
<App />
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
background: '#363636',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#22c55e',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#ef4444',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
383
web/src/pages/admin/proyectos/EtapasPage.tsx
Normal file
383
web/src/pages/admin/proyectos/EtapasPage.tsx
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { Plus, Trash2, Search, LayoutGrid } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useEtapas,
|
||||||
|
useFraccionamientos,
|
||||||
|
useDeleteEtapa,
|
||||||
|
useCreateEtapa,
|
||||||
|
} from '../../../hooks/useConstruccion';
|
||||||
|
import { EtapaStatus, CreateEtapaDto } from '../../../services/construccion/etapas.api';
|
||||||
|
import { HierarchyBreadcrumb } from '../../../components/proyectos';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const statusLabels: Record<EtapaStatus, string> = {
|
||||||
|
planned: 'Planeada',
|
||||||
|
in_progress: 'En Progreso',
|
||||||
|
completed: 'Completada',
|
||||||
|
cancelled: 'Cancelada',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<EtapaStatus, string> = {
|
||||||
|
planned: 'bg-gray-100 text-gray-800',
|
||||||
|
in_progress: 'bg-blue-100 text-blue-800',
|
||||||
|
completed: 'bg-green-100 text-green-800',
|
||||||
|
cancelled: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EtapasPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<EtapaStatus | ''>('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fraccionamientoId = searchParams.get('fraccionamientoId') || '';
|
||||||
|
|
||||||
|
const { data: fraccionamientosData } = useFraccionamientos();
|
||||||
|
const { data, isLoading, error } = useEtapas({
|
||||||
|
search: search || undefined,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
fraccionamientoId: fraccionamientoId || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useDeleteEtapa();
|
||||||
|
const createMutation = useCreateEtapa();
|
||||||
|
|
||||||
|
const etapas = data?.items || [];
|
||||||
|
const fraccionamientos = fraccionamientosData?.items || [];
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (formData: CreateEtapaDto) => {
|
||||||
|
await createMutation.mutateAsync(formData);
|
||||||
|
setShowModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HierarchyBreadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: 'Etapas' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Etapas</h1>
|
||||||
|
<p className="text-gray-600">Gestion de etapas de construccion</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Nueva Etapa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre o codigo..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={fraccionamientoId}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
searchParams.set('fraccionamientoId', e.target.value);
|
||||||
|
} else {
|
||||||
|
searchParams.delete('fraccionamientoId');
|
||||||
|
}
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Todos los fraccionamientos</option>
|
||||||
|
{fraccionamientos.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value as EtapaStatus | '')}
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">Cargando...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
|
||||||
|
) : etapas.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">No hay etapas</div>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Codigo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Nombre
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Fraccionamiento
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Total Lotes
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{etapas.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{item.code}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{item.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{item.fraccionamiento?.nombre || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{item.totalLots}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'px-2 py-1 text-xs font-medium rounded-full',
|
||||||
|
statusColors[item.status]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabels[item.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/admin/proyectos/manzanas?etapaId=${item.id}`}
|
||||||
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||||
|
title="Ver manzanas"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||||
|
title="Eliminar"
|
||||||
|
onClick={() => setDeleteConfirm(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<CreateEtapaModal
|
||||||
|
fraccionamientos={fraccionamientos}
|
||||||
|
defaultFraccionamientoId={fraccionamientoId}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
¿Esta seguro de eliminar esta etapa?
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
|
onClick={() => handleDelete(deleteConfirm)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateEtapaModalProps {
|
||||||
|
fraccionamientos: { id: string; nombre: string }[];
|
||||||
|
defaultFraccionamientoId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: CreateEtapaDto) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateEtapaModal({
|
||||||
|
fraccionamientos,
|
||||||
|
defaultFraccionamientoId,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
}: CreateEtapaModalProps) {
|
||||||
|
const [formData, setFormData] = useState<CreateEtapaDto>({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
fraccionamientoId: defaultFraccionamientoId || '',
|
||||||
|
description: '',
|
||||||
|
sequence: 1,
|
||||||
|
totalLots: 0,
|
||||||
|
status: 'planned',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Nueva Etapa</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Fraccionamiento *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.fraccionamientoId}
|
||||||
|
onChange={(e) => setFormData({ ...formData, fraccionamientoId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar fraccionamiento</option>
|
||||||
|
{fraccionamientos.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Secuencia</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.sequence}
|
||||||
|
onChange={(e) => setFormData({ ...formData, sequence: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Total Lotes</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.totalLots}
|
||||||
|
onChange={(e) => setFormData({ ...formData, totalLots: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Estado</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as EtapaStatus })}
|
||||||
|
>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creando...' : 'Crear Etapa'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
web/src/pages/admin/proyectos/FraccionamientoDetailPage.tsx
Normal file
384
web/src/pages/admin/proyectos/FraccionamientoDetailPage.tsx
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { Plus, Pencil, Trash2, ArrowLeft, Layers } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useFraccionamiento,
|
||||||
|
useEtapas,
|
||||||
|
useCreateEtapa,
|
||||||
|
useDeleteEtapa,
|
||||||
|
} from '../../../hooks/useConstruccion';
|
||||||
|
import { FraccionamientoEstado } from '../../../services/construccion/fraccionamientos.api';
|
||||||
|
import { CreateEtapaDto, EtapaStatus } from '../../../services/construccion/etapas.api';
|
||||||
|
import { HierarchyBreadcrumb } from '../../../components/proyectos';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const estadoColors: Record<FraccionamientoEstado, string> = {
|
||||||
|
activo: 'bg-green-100 text-green-800',
|
||||||
|
pausado: 'bg-yellow-100 text-yellow-800',
|
||||||
|
completado: 'bg-blue-100 text-blue-800',
|
||||||
|
cancelado: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const estadoLabels: Record<FraccionamientoEstado, string> = {
|
||||||
|
activo: 'Activo',
|
||||||
|
pausado: 'Pausado',
|
||||||
|
completado: 'Completado',
|
||||||
|
cancelado: 'Cancelado',
|
||||||
|
};
|
||||||
|
|
||||||
|
const etapaStatusLabels: Record<EtapaStatus, string> = {
|
||||||
|
planned: 'Planeada',
|
||||||
|
in_progress: 'En Progreso',
|
||||||
|
completed: 'Completada',
|
||||||
|
cancelled: 'Cancelada',
|
||||||
|
};
|
||||||
|
|
||||||
|
const etapaStatusColors: Record<EtapaStatus, string> = {
|
||||||
|
planned: 'bg-gray-100 text-gray-800',
|
||||||
|
in_progress: 'bg-blue-100 text-blue-800',
|
||||||
|
completed: 'bg-green-100 text-green-800',
|
||||||
|
cancelled: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FraccionamientoDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [showEtapaModal, setShowEtapaModal] = useState(false);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: fraccionamiento, isLoading } = useFraccionamiento(id!);
|
||||||
|
const { data: etapasData } = useEtapas({ fraccionamientoId: id });
|
||||||
|
const createEtapaMutation = useCreateEtapa();
|
||||||
|
const deleteEtapaMutation = useDeleteEtapa();
|
||||||
|
|
||||||
|
const etapas = etapasData?.items || [];
|
||||||
|
|
||||||
|
const handleCreateEtapa = async (data: CreateEtapaDto) => {
|
||||||
|
await createEtapaMutation.mutateAsync(data);
|
||||||
|
setShowEtapaModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEtapa = async (etapaId: string) => {
|
||||||
|
await deleteEtapaMutation.mutateAsync(etapaId);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-8 text-center">Cargando...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fraccionamiento) {
|
||||||
|
return <div className="p-8 text-center text-red-500">Fraccionamiento no encontrado</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HierarchyBreadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: 'Fraccionamientos', href: '/admin/proyectos/fraccionamientos' },
|
||||||
|
{ label: fraccionamiento.nombre },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{fraccionamiento.nombre}</h1>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'px-2 py-1 text-xs font-medium rounded-full',
|
||||||
|
estadoColors[fraccionamiento.estado]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{estadoLabels[fraccionamiento.estado]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500">Codigo: {fraccionamiento.codigo}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link
|
||||||
|
to="/admin/proyectos/fraccionamientos"
|
||||||
|
className="flex items-center px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Volver
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
onClick={() => window.alert('Edicion en desarrollo')}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500">Descripcion</label>
|
||||||
|
<p className="text-gray-900">{fraccionamiento.descripcion || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500">Direccion</label>
|
||||||
|
<p className="text-gray-900">{fraccionamiento.direccion || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500">Fecha Inicio</label>
|
||||||
|
<p className="text-gray-900">
|
||||||
|
{fraccionamiento.fechaInicio
|
||||||
|
? new Date(fraccionamiento.fechaInicio).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500">Fecha Fin Estimada</label>
|
||||||
|
<p className="text-gray-900">
|
||||||
|
{fraccionamiento.fechaFinEstimada
|
||||||
|
? new Date(fraccionamiento.fechaFinEstimada).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Etapas Section */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="w-5 h-5 text-gray-500" />
|
||||||
|
<h2 className="text-lg font-semibold">Etapas</h2>
|
||||||
|
<span className="text-sm text-gray-500">({etapas.length})</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700"
|
||||||
|
onClick={() => setShowEtapaModal(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
Agregar Etapa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{etapas.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">No hay etapas registradas</p>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Codigo
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Nombre
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Total Lotes
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{etapas.map((etapa) => (
|
||||||
|
<tr key={etapa.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">{etapa.code}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">{etapa.name}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">{etapa.totalLots}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'px-2 py-1 text-xs font-medium rounded-full',
|
||||||
|
etapaStatusColors[etapa.status]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{etapaStatusLabels[etapa.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/admin/proyectos/etapas/${etapa.id}`}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||||
|
title="Ver manzanas"
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="Eliminar"
|
||||||
|
onClick={() => setDeleteConfirm(etapa.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Etapa Modal */}
|
||||||
|
{showEtapaModal && (
|
||||||
|
<EtapaModal
|
||||||
|
fraccionamientoId={id!}
|
||||||
|
onClose={() => setShowEtapaModal(false)}
|
||||||
|
onSubmit={handleCreateEtapa}
|
||||||
|
isLoading={createEtapaMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
¿Esta seguro de eliminar esta etapa?
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
|
onClick={() => handleDeleteEtapa(deleteConfirm)}
|
||||||
|
disabled={deleteEtapaMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteEtapaMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EtapaModalProps {
|
||||||
|
fraccionamientoId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: CreateEtapaDto) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EtapaModal({ fraccionamientoId, onClose, onSubmit, isLoading }: EtapaModalProps) {
|
||||||
|
const [formData, setFormData] = useState<CreateEtapaDto>({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
fraccionamientoId,
|
||||||
|
description: '',
|
||||||
|
sequence: 1,
|
||||||
|
totalLots: 0,
|
||||||
|
status: 'planned',
|
||||||
|
startDate: '',
|
||||||
|
expectedEndDate: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Nueva Etapa</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Secuencia</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.sequence}
|
||||||
|
onChange={(e) => setFormData({ ...formData, sequence: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Descripcion</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Total Lotes</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.totalLots}
|
||||||
|
onChange={(e) => setFormData({ ...formData, totalLots: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Estado</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as EtapaStatus })}
|
||||||
|
>
|
||||||
|
{Object.entries(etapaStatusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creando...' : 'Crear Etapa'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
web/src/pages/admin/proyectos/FraccionamientosPage.tsx
Normal file
384
web/src/pages/admin/proyectos/FraccionamientosPage.tsx
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Plus, Pencil, Trash2, Eye, Search } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useFraccionamientos,
|
||||||
|
useDeleteFraccionamiento,
|
||||||
|
useCreateFraccionamiento,
|
||||||
|
useUpdateFraccionamiento,
|
||||||
|
} from '../../../hooks/useConstruccion';
|
||||||
|
import {
|
||||||
|
Fraccionamiento,
|
||||||
|
FraccionamientoEstado,
|
||||||
|
CreateFraccionamientoDto,
|
||||||
|
} from '../../../services/construccion/fraccionamientos.api';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const estadoColors: Record<FraccionamientoEstado, string> = {
|
||||||
|
activo: 'bg-green-100 text-green-800',
|
||||||
|
pausado: 'bg-yellow-100 text-yellow-800',
|
||||||
|
completado: 'bg-blue-100 text-blue-800',
|
||||||
|
cancelado: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const estadoLabels: Record<FraccionamientoEstado, string> = {
|
||||||
|
activo: 'Activo',
|
||||||
|
pausado: 'Pausado',
|
||||||
|
completado: 'Completado',
|
||||||
|
cancelado: 'Cancelado',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FraccionamientosPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [estadoFilter, setEstadoFilter] = useState<FraccionamientoEstado | ''>('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState<Fraccionamiento | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useFraccionamientos({
|
||||||
|
search: search || undefined,
|
||||||
|
estado: estadoFilter || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useDeleteFraccionamiento();
|
||||||
|
const createMutation = useCreateFraccionamiento();
|
||||||
|
const updateMutation = useUpdateFraccionamiento();
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: CreateFraccionamientoDto) => {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(formData);
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fraccionamientos = data?.items || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Fraccionamientos</h1>
|
||||||
|
<p className="text-gray-600">Gestion de fraccionamientos y desarrollos</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Nuevo Fraccionamiento
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre o codigo..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={estadoFilter}
|
||||||
|
onChange={(e) => setEstadoFilter(e.target.value as FraccionamientoEstado | '')}
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
{Object.entries(estadoLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">Cargando...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
|
||||||
|
) : fraccionamientos.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">No hay fraccionamientos</div>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Codigo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Nombre
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Fecha Inicio
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{fraccionamientos.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{item.codigo}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{item.nombre}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'px-2 py-1 text-xs font-medium rounded-full',
|
||||||
|
estadoColors[item.estado]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{estadoLabels[item.estado]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{item.fechaInicio
|
||||||
|
? new Date(item.fechaInicio).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/admin/proyectos/fraccionamientos/${item.id}`}
|
||||||
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||||
|
title="Ver detalle"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||||
|
title="Editar"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||||
|
title="Eliminar"
|
||||||
|
onClick={() => setDeleteConfirm(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<FraccionamientoModal
|
||||||
|
item={editingItem}
|
||||||
|
onClose={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
¿Esta seguro de eliminar este fraccionamiento? Esta accion no se puede deshacer.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
|
onClick={() => handleDelete(deleteConfirm)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal Component
|
||||||
|
interface FraccionamientoModalProps {
|
||||||
|
item: Fraccionamiento | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: CreateFraccionamientoDto) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FraccionamientoModal({ item, onClose, onSubmit, isLoading }: FraccionamientoModalProps) {
|
||||||
|
const [formData, setFormData] = useState<CreateFraccionamientoDto>({
|
||||||
|
codigo: item?.codigo || '',
|
||||||
|
nombre: item?.nombre || '',
|
||||||
|
proyectoId: item?.proyectoId || 'default-project-id',
|
||||||
|
descripcion: item?.descripcion || '',
|
||||||
|
direccion: item?.direccion || '',
|
||||||
|
fechaInicio: item?.fechaInicio?.split('T')[0] || '',
|
||||||
|
fechaFinEstimada: item?.fechaFinEstimada?.split('T')[0] || '',
|
||||||
|
estado: item?.estado || 'activo',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{item ? 'Editar Fraccionamiento' : 'Nuevo Fraccionamiento'}
|
||||||
|
</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Codigo *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.codigo}
|
||||||
|
onChange={(e) => setFormData({ ...formData, codigo: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Estado
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.estado}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, estado: e.target.value as FraccionamientoEstado })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.entries(estadoLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nombre *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.nombre}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Descripcion
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.descripcion}
|
||||||
|
onChange={(e) => setFormData({ ...formData, descripcion: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Direccion
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.direccion}
|
||||||
|
onChange={(e) => setFormData({ ...formData, direccion: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Fecha Inicio
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.fechaInicio}
|
||||||
|
onChange={(e) => setFormData({ ...formData, fechaInicio: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Fecha Fin Estimada
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.fechaFinEstimada}
|
||||||
|
onChange={(e) => setFormData({ ...formData, fechaFinEstimada: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
547
web/src/pages/admin/proyectos/LotesPage.tsx
Normal file
547
web/src/pages/admin/proyectos/LotesPage.tsx
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Plus, Trash2, Search, RefreshCw } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useLotes,
|
||||||
|
useLoteStats,
|
||||||
|
useManzanas,
|
||||||
|
usePrototipos,
|
||||||
|
useDeleteLote,
|
||||||
|
useCreateLote,
|
||||||
|
useUpdateLoteStatus,
|
||||||
|
useAssignPrototipo,
|
||||||
|
} from '../../../hooks/useConstruccion';
|
||||||
|
import { LoteStatus, CreateLoteDto } from '../../../services/construccion/lotes.api';
|
||||||
|
import { HierarchyBreadcrumb, LoteStatusBadge, getStatusColor } from '../../../components/proyectos';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const statusLabels: Record<LoteStatus, string> = {
|
||||||
|
available: 'Disponible',
|
||||||
|
reserved: 'Reservado',
|
||||||
|
sold: 'Vendido',
|
||||||
|
blocked: 'Bloqueado',
|
||||||
|
in_construction: 'En Construccion',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LotesPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<LoteStatus | ''>('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [showStatusModal, setShowStatusModal] = useState<string | null>(null);
|
||||||
|
const [showPrototipoModal, setShowPrototipoModal] = useState<string | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const manzanaId = searchParams.get('manzanaId') || '';
|
||||||
|
|
||||||
|
const { data: manzanasData } = useManzanas();
|
||||||
|
const { data: prototiposData } = usePrototipos({ isActive: true });
|
||||||
|
const { data, isLoading, error } = useLotes({
|
||||||
|
search: search || undefined,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
manzanaId: manzanaId || undefined,
|
||||||
|
});
|
||||||
|
const { data: stats } = useLoteStats(manzanaId || undefined);
|
||||||
|
|
||||||
|
const deleteMutation = useDeleteLote();
|
||||||
|
const createMutation = useCreateLote();
|
||||||
|
const updateStatusMutation = useUpdateLoteStatus();
|
||||||
|
const assignPrototipoMutation = useAssignPrototipo();
|
||||||
|
|
||||||
|
const lotes = data?.items || [];
|
||||||
|
const manzanas = manzanasData?.items || [];
|
||||||
|
const prototipos = prototiposData?.items || [];
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (formData: CreateLoteDto) => {
|
||||||
|
await createMutation.mutateAsync(formData);
|
||||||
|
setShowModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (id: string, status: LoteStatus) => {
|
||||||
|
await updateStatusMutation.mutateAsync({ id, status });
|
||||||
|
setShowStatusModal(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssignPrototipo = async (id: string, prototipoId: string) => {
|
||||||
|
await assignPrototipoMutation.mutateAsync({ id, prototipoId });
|
||||||
|
setShowPrototipoModal(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HierarchyBreadcrumb items={[{ label: 'Lotes' }]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Lotes</h1>
|
||||||
|
<p className="text-gray-600">Gestion de lotes y terrenos</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Nuevo Lote
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||||
|
<StatCard label="Total" value={stats.total} color="#6b7280" />
|
||||||
|
<StatCard label="Disponibles" value={stats.available} color={getStatusColor('available')} />
|
||||||
|
<StatCard label="Reservados" value={stats.reserved} color={getStatusColor('reserved')} />
|
||||||
|
<StatCard label="Vendidos" value={stats.sold} color={getStatusColor('sold')} />
|
||||||
|
<StatCard label="Bloqueados" value={stats.blocked} color={getStatusColor('blocked')} />
|
||||||
|
<StatCard
|
||||||
|
label="En Construccion"
|
||||||
|
value={stats.inConstruction}
|
||||||
|
color={getStatusColor('in_construction')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por codigo..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={manzanaId}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
searchParams.set('manzanaId', e.target.value);
|
||||||
|
} else {
|
||||||
|
searchParams.delete('manzanaId');
|
||||||
|
}
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Todas las manzanas</option>
|
||||||
|
{manzanas.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.code} - {m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value as LoteStatus | '')}
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">Cargando...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
|
||||||
|
) : lotes.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">No hay lotes</div>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Codigo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
No. Oficial
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Area (m2)
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Prototipo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{lotes.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{item.code}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{item.officialNumber || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{item.areaM2.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{item.prototipo ? (
|
||||||
|
<span className="text-blue-600">{item.prototipo.name}</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="text-gray-400 hover:text-blue-600 underline"
|
||||||
|
onClick={() => setShowPrototipoModal(item.id)}
|
||||||
|
>
|
||||||
|
Asignar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<LoteStatusBadge status={item.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||||
|
title="Cambiar estado"
|
||||||
|
onClick={() => setShowStatusModal(item.id)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||||
|
title="Eliminar"
|
||||||
|
onClick={() => setDeleteConfirm(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<CreateLoteModal
|
||||||
|
manzanas={manzanas}
|
||||||
|
defaultManzanaId={manzanaId}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Modal */}
|
||||||
|
{showStatusModal && (
|
||||||
|
<StatusChangeModal
|
||||||
|
currentStatus={lotes.find((l) => l.id === showStatusModal)?.status || 'available'}
|
||||||
|
onClose={() => setShowStatusModal(null)}
|
||||||
|
onSubmit={(status) => handleStatusChange(showStatusModal, status)}
|
||||||
|
isLoading={updateStatusMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prototipo Modal */}
|
||||||
|
{showPrototipoModal && (
|
||||||
|
<AssignPrototipoModal
|
||||||
|
prototipos={prototipos}
|
||||||
|
onClose={() => setShowPrototipoModal(null)}
|
||||||
|
onSubmit={(prototipoId) => handleAssignPrototipo(showPrototipoModal, prototipoId)}
|
||||||
|
isLoading={assignPrototipoMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
|
||||||
|
<p className="text-gray-600 mb-6">¿Esta seguro de eliminar este lote?</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
|
onClick={() => handleDelete(deleteConfirm)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: color }} />
|
||||||
|
<span className="text-sm text-gray-600">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateLoteModalProps {
|
||||||
|
manzanas: { id: string; code: string; name: string }[];
|
||||||
|
defaultManzanaId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: CreateLoteDto) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateLoteModal({
|
||||||
|
manzanas,
|
||||||
|
defaultManzanaId,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
}: CreateLoteModalProps) {
|
||||||
|
const [formData, setFormData] = useState<CreateLoteDto>({
|
||||||
|
code: '',
|
||||||
|
manzanaId: defaultManzanaId || '',
|
||||||
|
areaM2: 0,
|
||||||
|
frontM: 0,
|
||||||
|
depthM: 0,
|
||||||
|
status: 'available',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Nuevo Lote</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Manzana *</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.manzanaId}
|
||||||
|
onChange={(e) => setFormData({ ...formData, manzanaId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar manzana</option>
|
||||||
|
{manzanas.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.code} - {m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">No. Oficial</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.officialNumber || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, officialNumber: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Area (m2) *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.areaM2}
|
||||||
|
onChange={(e) => setFormData({ ...formData, areaM2: parseFloat(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Frente (m) *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.frontM}
|
||||||
|
onChange={(e) => setFormData({ ...formData, frontM: parseFloat(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Fondo (m) *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.depthM}
|
||||||
|
onChange={(e) => setFormData({ ...formData, depthM: parseFloat(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creando...' : 'Crear Lote'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusChangeModalProps {
|
||||||
|
currentStatus: LoteStatus;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (status: LoteStatus) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusChangeModal({ currentStatus, onClose, onSubmit, isLoading }: StatusChangeModalProps) {
|
||||||
|
const [status, setStatus] = useState<LoteStatus>(currentStatus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Cambiar Estado</h3>
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
{(Object.entries(statusLabels) as [LoteStatus, string][]).map(([value, label]) => (
|
||||||
|
<label
|
||||||
|
key={value}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center p-3 border rounded-lg cursor-pointer transition-colors',
|
||||||
|
status === value ? 'border-blue-500 bg-blue-50' : 'hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="status"
|
||||||
|
value={value}
|
||||||
|
checked={status === value}
|
||||||
|
onChange={(e) => setStatus(e.target.value as LoteStatus)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<LoteStatusBadge status={value} />
|
||||||
|
<span className="ml-3 text-sm text-gray-700">{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
onClick={() => onSubmit(status)}
|
||||||
|
disabled={isLoading || status === currentStatus}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Actualizando...' : 'Actualizar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssignPrototipoModalProps {
|
||||||
|
prototipos: { id: string; code: string; name: string }[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (prototipoId: string) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignPrototipoModal({
|
||||||
|
prototipos,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
}: AssignPrototipoModalProps) {
|
||||||
|
const [prototipoId, setPrototipoId] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Asignar Prototipo</h3>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 mb-6"
|
||||||
|
value={prototipoId}
|
||||||
|
onChange={(e) => setPrototipoId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar prototipo</option>
|
||||||
|
{prototipos.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.code} - {p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
onClick={() => onSubmit(prototipoId)}
|
||||||
|
disabled={isLoading || !prototipoId}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Asignando...' : 'Asignar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
web/src/pages/admin/proyectos/ManzanasPage.tsx
Normal file
316
web/src/pages/admin/proyectos/ManzanasPage.tsx
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { Plus, Trash2, Search, Map } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useManzanas,
|
||||||
|
useEtapas,
|
||||||
|
useDeleteManzana,
|
||||||
|
useCreateManzana,
|
||||||
|
} from '../../../hooks/useConstruccion';
|
||||||
|
import { CreateManzanaDto } from '../../../services/construccion/manzanas.api';
|
||||||
|
import { HierarchyBreadcrumb } from '../../../components/proyectos';
|
||||||
|
|
||||||
|
export function ManzanasPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const etapaId = searchParams.get('etapaId') || '';
|
||||||
|
|
||||||
|
const { data: etapasData } = useEtapas();
|
||||||
|
const { data, isLoading, error } = useManzanas({
|
||||||
|
search: search || undefined,
|
||||||
|
etapaId: etapaId || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useDeleteManzana();
|
||||||
|
const createMutation = useCreateManzana();
|
||||||
|
|
||||||
|
const manzanas = data?.items || [];
|
||||||
|
const etapas = etapasData?.items || [];
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (formData: CreateManzanaDto) => {
|
||||||
|
await createMutation.mutateAsync(formData);
|
||||||
|
setShowModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HierarchyBreadcrumb items={[{ label: 'Manzanas' }]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Manzanas</h1>
|
||||||
|
<p className="text-gray-600">Gestion de manzanas por etapa</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Nueva Manzana
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre o codigo..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={etapaId}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
searchParams.set('etapaId', e.target.value);
|
||||||
|
} else {
|
||||||
|
searchParams.delete('etapaId');
|
||||||
|
}
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Todas las etapas</option>
|
||||||
|
{etapas.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{e.code} - {e.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">Cargando...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
|
||||||
|
) : manzanas.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">No hay manzanas</div>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Codigo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Nombre
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Etapa
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Total Lotes
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{manzanas.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{item.code}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{item.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{item.etapa?.name || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{item.totalLots}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/admin/proyectos/lotes?manzanaId=${item.id}`}
|
||||||
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||||
|
title="Ver lotes"
|
||||||
|
>
|
||||||
|
<Map className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||||
|
title="Eliminar"
|
||||||
|
onClick={() => setDeleteConfirm(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<CreateManzanaModal
|
||||||
|
etapas={etapas}
|
||||||
|
defaultEtapaId={etapaId}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
|
||||||
|
<p className="text-gray-600 mb-6">¿Esta seguro de eliminar esta manzana?</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
|
onClick={() => handleDelete(deleteConfirm)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateManzanaModalProps {
|
||||||
|
etapas: { id: string; code: string; name: string }[];
|
||||||
|
defaultEtapaId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: CreateManzanaDto) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateManzanaModal({
|
||||||
|
etapas,
|
||||||
|
defaultEtapaId,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
}: CreateManzanaModalProps) {
|
||||||
|
const [formData, setFormData] = useState<CreateManzanaDto>({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
etapaId: defaultEtapaId || '',
|
||||||
|
description: '',
|
||||||
|
totalLots: 0,
|
||||||
|
sequence: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Nueva Manzana</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Etapa *</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.etapaId}
|
||||||
|
onChange={(e) => setFormData({ ...formData, etapaId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar etapa</option>
|
||||||
|
{etapas.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{e.code} - {e.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Secuencia</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.sequence}
|
||||||
|
onChange={(e) => setFormData({ ...formData, sequence: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Total Lotes</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.totalLots}
|
||||||
|
onChange={(e) => setFormData({ ...formData, totalLots: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creando...' : 'Crear Manzana'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
509
web/src/pages/admin/proyectos/PrototiposPage.tsx
Normal file
509
web/src/pages/admin/proyectos/PrototiposPage.tsx
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Pencil, Trash2, Search, ToggleLeft, ToggleRight, Home, Building } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
usePrototipos,
|
||||||
|
useDeletePrototipo,
|
||||||
|
useCreatePrototipo,
|
||||||
|
useUpdatePrototipo,
|
||||||
|
useTogglePrototipoActive,
|
||||||
|
} from '../../../hooks/useConstruccion';
|
||||||
|
import {
|
||||||
|
Prototipo,
|
||||||
|
PrototipoType,
|
||||||
|
CreatePrototipoDto,
|
||||||
|
} from '../../../services/construccion/prototipos.api';
|
||||||
|
import { HierarchyBreadcrumb } from '../../../components/proyectos';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const typeLabels: Record<PrototipoType, string> = {
|
||||||
|
house: 'Casa',
|
||||||
|
apartment: 'Departamento',
|
||||||
|
commercial: 'Comercial',
|
||||||
|
lot: 'Terreno',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcons: Record<PrototipoType, React.ComponentType<{ className?: string }>> = {
|
||||||
|
house: Home,
|
||||||
|
apartment: Building,
|
||||||
|
commercial: Building,
|
||||||
|
lot: Building,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrototiposPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<PrototipoType | ''>('');
|
||||||
|
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState<Prototipo | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = usePrototipos({
|
||||||
|
search: search || undefined,
|
||||||
|
type: typeFilter || undefined,
|
||||||
|
isActive: activeFilter === 'all' ? undefined : activeFilter === 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useDeletePrototipo();
|
||||||
|
const createMutation = useCreatePrototipo();
|
||||||
|
const updateMutation = useUpdatePrototipo();
|
||||||
|
const toggleActiveMutation = useTogglePrototipoActive();
|
||||||
|
|
||||||
|
const prototipos = data?.items || [];
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: CreatePrototipoDto) => {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(formData);
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (id: string, isActive: boolean) => {
|
||||||
|
await toggleActiveMutation.mutateAsync({ id, isActive: !isActive });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HierarchyBreadcrumb items={[{ label: 'Prototipos' }]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Prototipos</h1>
|
||||||
|
<p className="text-gray-600">Catalogo de prototipos de vivienda</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Nuevo Prototipo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre o codigo..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value as PrototipoType | '')}
|
||||||
|
>
|
||||||
|
<option value="">Todos los tipos</option>
|
||||||
|
{Object.entries(typeLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={activeFilter}
|
||||||
|
onChange={(e) => setActiveFilter(e.target.value as 'all' | 'active' | 'inactive')}
|
||||||
|
>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
<option value="active">Activos</option>
|
||||||
|
<option value="inactive">Inactivos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">Cargando...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
|
||||||
|
) : prototipos.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500 bg-white rounded-lg shadow-sm">
|
||||||
|
No hay prototipos
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{prototipos.map((item) => {
|
||||||
|
const TypeIcon = typeIcons[item.type];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={clsx(
|
||||||
|
'bg-white rounded-lg shadow-sm overflow-hidden',
|
||||||
|
!item.isActive && 'opacity-60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Image or placeholder */}
|
||||||
|
<div className="h-48 bg-gray-200 flex items-center justify-center">
|
||||||
|
{item.renderUrl ? (
|
||||||
|
<img
|
||||||
|
src={item.renderUrl}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TypeIcon className="w-16 h-16 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{item.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{item.code}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'px-2 py-1 text-xs font-medium rounded-full',
|
||||||
|
item.isActive
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.isActive ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm mb-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Tipo:</span>{' '}
|
||||||
|
<span className="font-medium">{typeLabels[item.type]}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Area:</span>{' '}
|
||||||
|
<span className="font-medium">{item.constructionAreaM2} m2</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Recamaras:</span>{' '}
|
||||||
|
<span className="font-medium">{item.bedrooms}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Banos:</span>{' '}
|
||||||
|
<span className="font-medium">{item.bathrooms}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-lg font-bold text-blue-600 mb-4">
|
||||||
|
${item.basePrice.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
|
<button
|
||||||
|
className="flex items-center text-sm text-gray-600 hover:text-blue-600"
|
||||||
|
onClick={() => handleToggleActive(item.id, item.isActive)}
|
||||||
|
>
|
||||||
|
{item.isActive ? (
|
||||||
|
<>
|
||||||
|
<ToggleRight className="w-5 h-5 mr-1 text-green-600" />
|
||||||
|
Activo
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ToggleLeft className="w-5 h-5 mr-1" />
|
||||||
|
Inactivo
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||||
|
title="Editar"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||||
|
title="Eliminar"
|
||||||
|
onClick={() => setDeleteConfirm(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<PrototipoModal
|
||||||
|
item={editingItem}
|
||||||
|
onClose={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
|
||||||
|
<p className="text-gray-600 mb-6">¿Esta seguro de eliminar este prototipo?</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
|
onClick={() => handleDelete(deleteConfirm)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrototipoModalProps {
|
||||||
|
item: Prototipo | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: CreatePrototipoDto) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrototipoModal({ item, onClose, onSubmit, isLoading }: PrototipoModalProps) {
|
||||||
|
const [formData, setFormData] = useState<CreatePrototipoDto>({
|
||||||
|
code: item?.code || '',
|
||||||
|
name: item?.name || '',
|
||||||
|
description: item?.description || '',
|
||||||
|
type: item?.type || 'house',
|
||||||
|
constructionAreaM2: item?.constructionAreaM2 || 0,
|
||||||
|
landAreaM2: item?.landAreaM2 || 0,
|
||||||
|
bedrooms: item?.bedrooms || 0,
|
||||||
|
bathrooms: item?.bathrooms || 0,
|
||||||
|
parkingSpaces: item?.parkingSpaces || 0,
|
||||||
|
floors: item?.floors || 1,
|
||||||
|
basePrice: item?.basePrice || 0,
|
||||||
|
renderUrl: item?.renderUrl || '',
|
||||||
|
isActive: item?.isActive ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{item ? 'Editar Prototipo' : 'Nuevo Prototipo'}
|
||||||
|
</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo *</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as PrototipoType })}
|
||||||
|
>
|
||||||
|
{Object.entries(typeLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Descripcion</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Area Construccion (m2) *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.constructionAreaM2}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, constructionAreaM2: parseFloat(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Area Terreno (m2) *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.landAreaM2}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, landAreaM2: parseFloat(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Recamaras</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.bedrooms}
|
||||||
|
onChange={(e) => setFormData({ ...formData, bedrooms: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Banos</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.5"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.bathrooms}
|
||||||
|
onChange={(e) => setFormData({ ...formData, bathrooms: parseFloat(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Estacionamientos</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.parkingSpaces}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, parkingSpaces: parseInt(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Pisos</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.floors}
|
||||||
|
onChange={(e) => setFormData({ ...formData, floors: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Precio Base *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
min="0"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.basePrice}
|
||||||
|
onChange={(e) => setFormData({ ...formData, basePrice: parseFloat(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
URL de Imagen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={formData.renderUrl}
|
||||||
|
onChange={(e) => setFormData({ ...formData, renderUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isActive"
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="isActive" className="ml-2 text-sm text-gray-700">
|
||||||
|
Prototipo activo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
web/src/pages/admin/proyectos/index.ts
Normal file
6
web/src/pages/admin/proyectos/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { FraccionamientosPage } from './FraccionamientosPage';
|
||||||
|
export { FraccionamientoDetailPage } from './FraccionamientoDetailPage';
|
||||||
|
export { EtapasPage } from './EtapasPage';
|
||||||
|
export { ManzanasPage } from './ManzanasPage';
|
||||||
|
export { LotesPage } from './LotesPage';
|
||||||
|
export { PrototiposPage } from './PrototiposPage';
|
||||||
106
web/src/services/api.ts
Normal file
106
web/src/services/api.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
|
// Get tenant ID from subdomain or environment
|
||||||
|
function getTenantId(): string {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const parts = hostname.split('.');
|
||||||
|
|
||||||
|
if (parts.length >= 3 && !hostname.includes('localhost')) {
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return import.meta.env.VITE_TENANT_ID || 'default-tenant';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
const api: AxiosInstance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor
|
||||||
|
api.interceptors.request.use(
|
||||||
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
|
config.headers['x-tenant-id'] = getTenantId();
|
||||||
|
|
||||||
|
const { useAuthStore } = await import('../stores/authStore');
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { useAuthStore } = await import('../stores/authStore');
|
||||||
|
const refreshToken = useAuthStore.getState().refreshToken;
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${API_BASE_URL}/auth/refresh`,
|
||||||
|
{ refreshToken },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-tenant-id': getTenantId(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||||
|
useAuthStore.getState().setTokens(accessToken, newRefreshToken);
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
return api(originalRequest);
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
const { useAuthStore } = await import('../stores/authStore');
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Common Types
|
||||||
|
export interface ApiError {
|
||||||
|
message: string;
|
||||||
|
statusCode: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getTenantId };
|
||||||
|
export default api;
|
||||||
82
web/src/services/construccion/etapas.api.ts
Normal file
82
web/src/services/construccion/etapas.api.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import api, { PaginatedResponse, PaginationParams } from '../api';
|
||||||
|
|
||||||
|
export type EtapaStatus = 'planned' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
export interface Etapa {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
fraccionamientoId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
sequence: number;
|
||||||
|
totalLots: number;
|
||||||
|
status: EtapaStatus;
|
||||||
|
startDate?: string;
|
||||||
|
expectedEndDate?: string;
|
||||||
|
actualEndDate?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
fraccionamiento?: {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
codigo: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EtapaFilters extends PaginationParams {
|
||||||
|
fraccionamientoId?: string;
|
||||||
|
status?: EtapaStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEtapaDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
fraccionamientoId: string;
|
||||||
|
description?: string;
|
||||||
|
sequence?: number;
|
||||||
|
totalLots?: number;
|
||||||
|
status?: EtapaStatus;
|
||||||
|
startDate?: string;
|
||||||
|
expectedEndDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEtapaDto {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
sequence?: number;
|
||||||
|
totalLots?: number;
|
||||||
|
status?: EtapaStatus;
|
||||||
|
startDate?: string;
|
||||||
|
expectedEndDate?: string;
|
||||||
|
actualEndDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const etapasApi = {
|
||||||
|
list: async (filters?: EtapaFilters): Promise<PaginatedResponse<Etapa>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<Etapa>>('/etapas', {
|
||||||
|
params: filters,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: string): Promise<Etapa> => {
|
||||||
|
const response = await api.get<Etapa>(`/etapas/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateEtapaDto): Promise<Etapa> => {
|
||||||
|
const response = await api.post<Etapa>('/etapas', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdateEtapaDto): Promise<Etapa> => {
|
||||||
|
const response = await api.patch<Etapa>(`/etapas/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/etapas/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
72
web/src/services/construccion/fraccionamientos.api.ts
Normal file
72
web/src/services/construccion/fraccionamientos.api.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import api, { PaginatedResponse, PaginationParams } from '../api';
|
||||||
|
|
||||||
|
export type FraccionamientoEstado = 'activo' | 'pausado' | 'completado' | 'cancelado';
|
||||||
|
|
||||||
|
export interface Fraccionamiento {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
proyectoId: string;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion?: string;
|
||||||
|
direccion?: string;
|
||||||
|
fechaInicio?: string;
|
||||||
|
fechaFinEstimada?: string;
|
||||||
|
estado: FraccionamientoEstado;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FraccionamientoFilters extends PaginationParams {
|
||||||
|
proyectoId?: string;
|
||||||
|
estado?: FraccionamientoEstado;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFraccionamientoDto {
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
proyectoId: string;
|
||||||
|
descripcion?: string;
|
||||||
|
direccion?: string;
|
||||||
|
fechaInicio?: string;
|
||||||
|
fechaFinEstimada?: string;
|
||||||
|
estado?: FraccionamientoEstado;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFraccionamientoDto {
|
||||||
|
codigo?: string;
|
||||||
|
nombre?: string;
|
||||||
|
descripcion?: string;
|
||||||
|
direccion?: string;
|
||||||
|
fechaInicio?: string;
|
||||||
|
fechaFinEstimada?: string;
|
||||||
|
estado?: FraccionamientoEstado;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fraccionamientosApi = {
|
||||||
|
list: async (filters?: FraccionamientoFilters): Promise<PaginatedResponse<Fraccionamiento>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<Fraccionamiento>>('/fraccionamientos', {
|
||||||
|
params: filters,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: string): Promise<Fraccionamiento> => {
|
||||||
|
const response = await api.get<Fraccionamiento>(`/fraccionamientos/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateFraccionamientoDto): Promise<Fraccionamiento> => {
|
||||||
|
const response = await api.post<Fraccionamiento>('/fraccionamientos', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdateFraccionamientoDto): Promise<Fraccionamiento> => {
|
||||||
|
const response = await api.patch<Fraccionamiento>(`/fraccionamientos/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/fraccionamientos/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
5
web/src/services/construccion/index.ts
Normal file
5
web/src/services/construccion/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './fraccionamientos.api';
|
||||||
|
export * from './etapas.api';
|
||||||
|
export * from './manzanas.api';
|
||||||
|
export * from './lotes.api';
|
||||||
|
export * from './prototipos.api';
|
||||||
118
web/src/services/construccion/lotes.api.ts
Normal file
118
web/src/services/construccion/lotes.api.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import api, { PaginatedResponse, PaginationParams } from '../api';
|
||||||
|
|
||||||
|
export type LoteStatus = 'available' | 'reserved' | 'sold' | 'blocked' | 'in_construction';
|
||||||
|
|
||||||
|
export interface Lote {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
manzanaId: string;
|
||||||
|
prototipoId?: string;
|
||||||
|
code: string;
|
||||||
|
officialNumber?: string;
|
||||||
|
areaM2: number;
|
||||||
|
frontM: number;
|
||||||
|
depthM: number;
|
||||||
|
status: LoteStatus;
|
||||||
|
basePrice?: number;
|
||||||
|
finalPrice?: number;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
manzana?: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
etapaId: string;
|
||||||
|
};
|
||||||
|
prototipo?: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoteFilters extends PaginationParams {
|
||||||
|
manzanaId?: string;
|
||||||
|
prototipoId?: string;
|
||||||
|
status?: LoteStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLoteDto {
|
||||||
|
code: string;
|
||||||
|
manzanaId: string;
|
||||||
|
officialNumber?: string;
|
||||||
|
areaM2: number;
|
||||||
|
frontM: number;
|
||||||
|
depthM: number;
|
||||||
|
status?: LoteStatus;
|
||||||
|
basePrice?: number;
|
||||||
|
finalPrice?: number;
|
||||||
|
prototipoId?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLoteDto {
|
||||||
|
code?: string;
|
||||||
|
officialNumber?: string;
|
||||||
|
areaM2?: number;
|
||||||
|
frontM?: number;
|
||||||
|
depthM?: number;
|
||||||
|
basePrice?: number;
|
||||||
|
finalPrice?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoteStats {
|
||||||
|
total: number;
|
||||||
|
available: number;
|
||||||
|
reserved: number;
|
||||||
|
sold: number;
|
||||||
|
blocked: number;
|
||||||
|
inConstruction: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lotesApi = {
|
||||||
|
list: async (filters?: LoteFilters): Promise<PaginatedResponse<Lote>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<Lote>>('/lotes', {
|
||||||
|
params: filters,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: string): Promise<Lote> => {
|
||||||
|
const response = await api.get<Lote>(`/lotes/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateLoteDto): Promise<Lote> => {
|
||||||
|
const response = await api.post<Lote>('/lotes', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdateLoteDto): Promise<Lote> => {
|
||||||
|
const response = await api.patch<Lote>(`/lotes/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/lotes/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStatus: async (id: string, status: LoteStatus): Promise<Lote> => {
|
||||||
|
const response = await api.patch<Lote>(`/lotes/${id}/status`, { status });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
assignPrototipo: async (id: string, prototipoId: string): Promise<Lote> => {
|
||||||
|
const response = await api.patch<Lote>(`/lotes/${id}/prototipo`, { prototipoId });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats: async (manzanaId?: string): Promise<LoteStats> => {
|
||||||
|
const response = await api.get<LoteStats>('/lotes/stats', {
|
||||||
|
params: { manzanaId },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
69
web/src/services/construccion/manzanas.api.ts
Normal file
69
web/src/services/construccion/manzanas.api.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import api, { PaginatedResponse, PaginationParams } from '../api';
|
||||||
|
|
||||||
|
export interface Manzana {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
etapaId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
totalLots: number;
|
||||||
|
sequence: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
etapa?: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
fraccionamientoId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManzanaFilters extends PaginationParams {
|
||||||
|
etapaId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateManzanaDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
etapaId: string;
|
||||||
|
description?: string;
|
||||||
|
totalLots?: number;
|
||||||
|
sequence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateManzanaDto {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
totalLots?: number;
|
||||||
|
sequence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const manzanasApi = {
|
||||||
|
list: async (filters?: ManzanaFilters): Promise<PaginatedResponse<Manzana>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<Manzana>>('/manzanas', {
|
||||||
|
params: filters,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: string): Promise<Manzana> => {
|
||||||
|
const response = await api.get<Manzana>(`/manzanas/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateManzanaDto): Promise<Manzana> => {
|
||||||
|
const response = await api.post<Manzana>('/manzanas', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdateManzanaDto): Promise<Manzana> => {
|
||||||
|
const response = await api.patch<Manzana>(`/manzanas/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/manzanas/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
101
web/src/services/construccion/prototipos.api.ts
Normal file
101
web/src/services/construccion/prototipos.api.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import api, { PaginatedResponse, PaginationParams } from '../api';
|
||||||
|
|
||||||
|
export type PrototipoType = 'house' | 'apartment' | 'commercial' | 'lot';
|
||||||
|
|
||||||
|
export interface Prototipo {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: PrototipoType;
|
||||||
|
constructionAreaM2: number;
|
||||||
|
landAreaM2: number;
|
||||||
|
bedrooms: number;
|
||||||
|
bathrooms: number;
|
||||||
|
parkingSpaces: number;
|
||||||
|
floors: number;
|
||||||
|
basePrice: number;
|
||||||
|
features?: string[];
|
||||||
|
renderUrl?: string;
|
||||||
|
blueprintUrl?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrototipoFilters extends PaginationParams {
|
||||||
|
type?: PrototipoType;
|
||||||
|
isActive?: boolean;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePrototipoDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: PrototipoType;
|
||||||
|
constructionAreaM2: number;
|
||||||
|
landAreaM2: number;
|
||||||
|
bedrooms?: number;
|
||||||
|
bathrooms?: number;
|
||||||
|
parkingSpaces?: number;
|
||||||
|
floors?: number;
|
||||||
|
basePrice: number;
|
||||||
|
features?: string[];
|
||||||
|
renderUrl?: string;
|
||||||
|
blueprintUrl?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePrototipoDto {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
type?: PrototipoType;
|
||||||
|
constructionAreaM2?: number;
|
||||||
|
landAreaM2?: number;
|
||||||
|
bedrooms?: number;
|
||||||
|
bathrooms?: number;
|
||||||
|
parkingSpaces?: number;
|
||||||
|
floors?: number;
|
||||||
|
basePrice?: number;
|
||||||
|
features?: string[];
|
||||||
|
renderUrl?: string;
|
||||||
|
blueprintUrl?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prototiposApi = {
|
||||||
|
list: async (filters?: PrototipoFilters): Promise<PaginatedResponse<Prototipo>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<Prototipo>>('/prototipos', {
|
||||||
|
params: filters,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: string): Promise<Prototipo> => {
|
||||||
|
const response = await api.get<Prototipo>(`/prototipos/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreatePrototipoDto): Promise<Prototipo> => {
|
||||||
|
const response = await api.post<Prototipo>('/prototipos', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdatePrototipoDto): Promise<Prototipo> => {
|
||||||
|
const response = await api.patch<Prototipo>(`/prototipos/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/prototipos/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleActive: async (id: string, isActive: boolean): Promise<Prototipo> => {
|
||||||
|
const response = await api.patch<Prototipo>(`/prototipos/${id}`, { isActive });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
47
web/src/stores/authStore.ts
Normal file
47
web/src/stores/authStore.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
tenantId: string;
|
||||||
|
status: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
setTokens: (accessToken: string, refreshToken: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||||
|
setTokens: (accessToken, refreshToken) =>
|
||||||
|
set({ accessToken, refreshToken, isAuthenticated: true }),
|
||||||
|
logout: () =>
|
||||||
|
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'erp-construccion-auth',
|
||||||
|
partialize: (state) => ({
|
||||||
|
accessToken: state.accessToken,
|
||||||
|
refreshToken: state.refreshToken,
|
||||||
|
user: state.user,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user