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