[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:
Adrian Flores Cortes 2026-01-25 10:04:54 -06:00
parent fdd4559508
commit f3d91433fe
26 changed files with 3953 additions and 60 deletions

20
web/.eslintrc.cjs Normal file
View 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
View File

@ -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",

View File

@ -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",

View File

@ -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>
); );
} }

View 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>
);
}

View 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';

View File

@ -0,0 +1,2 @@
export { LoteStatusBadge, getStatusColor } from './LoteStatusBadge';
export { HierarchyBreadcrumb } from './HierarchyBreadcrumb';

1
web/src/hooks/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './useConstruccion';

View 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,
});
}

View 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>
);
}

View File

@ -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>
); );

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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;

View 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}`);
},
};

View 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}`);
},
};

View 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';

View 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;
},
};

View 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}`);
},
};

View 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;
},
};

View 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,
}),
}
)
);