From f3d91433feba551e1a3a762826fd26b39f50ba63 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 10:04:54 -0600 Subject: [PATCH] [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 --- web/.eslintrc.cjs | 20 + web/package-lock.json | 69 ++- web/package.json | 24 +- web/src/App.tsx | 71 ++- .../proyectos/HierarchyBreadcrumb.tsx | 36 ++ .../components/proyectos/LoteStatusBadge.tsx | 64 ++ web/src/components/proyectos/index.ts | 2 + web/src/hooks/index.ts | 1 + web/src/hooks/useConstruccion.ts | 397 +++++++++++++ web/src/layouts/AdminLayout.tsx | 155 +++++ web/src/main.tsx | 45 +- web/src/pages/admin/proyectos/EtapasPage.tsx | 383 ++++++++++++ .../proyectos/FraccionamientoDetailPage.tsx | 384 ++++++++++++ .../admin/proyectos/FraccionamientosPage.tsx | 384 ++++++++++++ web/src/pages/admin/proyectos/LotesPage.tsx | 547 ++++++++++++++++++ .../pages/admin/proyectos/ManzanasPage.tsx | 316 ++++++++++ .../pages/admin/proyectos/PrototiposPage.tsx | 509 ++++++++++++++++ web/src/pages/admin/proyectos/index.ts | 6 + web/src/services/api.ts | 106 ++++ web/src/services/construccion/etapas.api.ts | 82 +++ .../construccion/fraccionamientos.api.ts | 72 +++ web/src/services/construccion/index.ts | 5 + web/src/services/construccion/lotes.api.ts | 118 ++++ web/src/services/construccion/manzanas.api.ts | 69 +++ .../services/construccion/prototipos.api.ts | 101 ++++ web/src/stores/authStore.ts | 47 ++ 26 files changed, 3953 insertions(+), 60 deletions(-) create mode 100644 web/.eslintrc.cjs create mode 100644 web/src/components/proyectos/HierarchyBreadcrumb.tsx create mode 100644 web/src/components/proyectos/LoteStatusBadge.tsx create mode 100644 web/src/components/proyectos/index.ts create mode 100644 web/src/hooks/index.ts create mode 100644 web/src/hooks/useConstruccion.ts create mode 100644 web/src/layouts/AdminLayout.tsx create mode 100644 web/src/pages/admin/proyectos/EtapasPage.tsx create mode 100644 web/src/pages/admin/proyectos/FraccionamientoDetailPage.tsx create mode 100644 web/src/pages/admin/proyectos/FraccionamientosPage.tsx create mode 100644 web/src/pages/admin/proyectos/LotesPage.tsx create mode 100644 web/src/pages/admin/proyectos/ManzanasPage.tsx create mode 100644 web/src/pages/admin/proyectos/PrototiposPage.tsx create mode 100644 web/src/pages/admin/proyectos/index.ts create mode 100644 web/src/services/api.ts create mode 100644 web/src/services/construccion/etapas.api.ts create mode 100644 web/src/services/construccion/fraccionamientos.api.ts create mode 100644 web/src/services/construccion/index.ts create mode 100644 web/src/services/construccion/lotes.api.ts create mode 100644 web/src/services/construccion/manzanas.api.ts create mode 100644 web/src/services/construccion/prototipos.api.ts create mode 100644 web/src/stores/authStore.ts diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 0000000..df21d6f --- /dev/null +++ b/web/.eslintrc.cjs @@ -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: '^_' }], + }, +}; diff --git a/web/package-lock.json b/web/package-lock.json index 2fcf9a5..1fb1591 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 1027ac2..d12a906 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.tsx b/web/src/App.tsx index 754d64e..9bbf979 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 (
- {/* Ruta principal */} - } /> + {/* Ruta principal - redirect to admin */} + } /> {/* Portal Admin */} - Admin Portal (TODO)
} /> + }> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Portal Supervisor */} - Supervisor Portal (TODO)} /> + Supervisor Portal (TODO)} /> {/* Portal Obra */} - Obra Portal (TODO)} /> + Obra Portal (TODO)} /> + + {/* Auth routes placeholder */} + } /> {/* 404 */} } /> @@ -37,22 +53,21 @@ function App() { ); } -/** - * Página de inicio temporal - */ -function HomePage() { +function LoginPlaceholder() { return ( -
-

🏗️ Sistema Administración de Obra

-

MVP - INFONAVIT

- -

- Versión: 1.0.0 | Entorno: {import.meta.env.MODE} -

+
+
+

Login

+

+ Pagina de login placeholder. Por ahora accede directamente a /admin. +

+ + Ir al Admin + +
); } diff --git a/web/src/components/proyectos/HierarchyBreadcrumb.tsx b/web/src/components/proyectos/HierarchyBreadcrumb.tsx new file mode 100644 index 0000000..5957dfa --- /dev/null +++ b/web/src/components/proyectos/HierarchyBreadcrumb.tsx @@ -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 ( + + ); +} diff --git a/web/src/components/proyectos/LoteStatusBadge.tsx b/web/src/components/proyectos/LoteStatusBadge.tsx new file mode 100644 index 0000000..78354f6 --- /dev/null +++ b/web/src/components/proyectos/LoteStatusBadge.tsx @@ -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 = { + 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 ( + + {config.label} + + ); +} + +// Status color utility - exported from separate constant to avoid Fast Refresh warning +const statusColors: Record = { + available: '#22c55e', + reserved: '#eab308', + sold: '#3b82f6', + blocked: '#6b7280', + in_construction: '#f97316', +}; + +export const getStatusColor = (status: LoteStatus): string => + statusColors[status] || '#6b7280'; diff --git a/web/src/components/proyectos/index.ts b/web/src/components/proyectos/index.ts new file mode 100644 index 0000000..1d3388b --- /dev/null +++ b/web/src/components/proyectos/index.ts @@ -0,0 +1,2 @@ +export { LoteStatusBadge, getStatusColor } from './LoteStatusBadge'; +export { HierarchyBreadcrumb } from './HierarchyBreadcrumb'; diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts new file mode 100644 index 0000000..0537b19 --- /dev/null +++ b/web/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useConstruccion'; diff --git a/web/src/hooks/useConstruccion.ts b/web/src/hooks/useConstruccion.ts new file mode 100644 index 0000000..def6760 --- /dev/null +++ b/web/src/hooks/useConstruccion.ts @@ -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) => { + 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, + }); +} diff --git a/web/src/layouts/AdminLayout.tsx b/web/src/layouts/AdminLayout.tsx new file mode 100644 index 0000000..8177453 --- /dev/null +++ b/web/src/layouts/AdminLayout.tsx @@ -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 ( +
+ {/* Mobile sidebar backdrop */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Main content */} +
+ {/* Top bar */} +
+
+ + +
+ + {/* User menu */} +
+ + + {userMenuOpen && ( +
+ +
+ )} +
+
+
+ + {/* Page content */} +
+ +
+
+
+ ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index c02b96f..08b1658 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( - - , + + + + + ); diff --git a/web/src/pages/admin/proyectos/EtapasPage.tsx b/web/src/pages/admin/proyectos/EtapasPage.tsx new file mode 100644 index 0000000..bc40831 --- /dev/null +++ b/web/src/pages/admin/proyectos/EtapasPage.tsx @@ -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 = { + planned: 'Planeada', + in_progress: 'En Progreso', + completed: 'Completada', + cancelled: 'Cancelada', +}; + +const statusColors: Record = { + 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(''); + const [showModal, setShowModal] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(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 ( +
+ + +
+
+

Etapas

+

Gestion de etapas de construccion

+
+ +
+ + {/* Filters */} +
+
+
+ + setSearch(e.target.value)} + /> +
+ + +
+
+ + {/* Table */} +
+ {isLoading ? ( +
Cargando...
+ ) : error ? ( +
Error al cargar los datos
+ ) : etapas.length === 0 ? ( +
No hay etapas
+ ) : ( + + + + + + + + + + + + + {etapas.map((item) => ( + + + + + + + + + ))} + +
+ Codigo + + Nombre + + Fraccionamiento + + Total Lotes + + Estado + + Acciones +
+ {item.code} + + {item.name} + + {item.fraccionamiento?.nombre || '-'} + + {item.totalLots} + + + {statusLabels[item.status]} + + +
+ + + + +
+
+ )} +
+ + {/* Modal */} + {showModal && ( + setShowModal(false)} + onSubmit={handleCreate} + isLoading={createMutation.isPending} + /> + )} + + {/* Delete Confirmation */} + {deleteConfirm && ( +
+
+

Confirmar eliminacion

+

+ ¿Esta seguro de eliminar esta etapa? +

+
+ + +
+
+
+ )} +
+ ); +} + +interface CreateEtapaModalProps { + fraccionamientos: { id: string; nombre: string }[]; + defaultFraccionamientoId: string; + onClose: () => void; + onSubmit: (data: CreateEtapaDto) => Promise; + isLoading: boolean; +} + +function CreateEtapaModal({ + fraccionamientos, + defaultFraccionamientoId, + onClose, + onSubmit, + isLoading, +}: CreateEtapaModalProps) { + const [formData, setFormData] = useState({ + code: '', + name: '', + fraccionamientoId: defaultFraccionamientoId || '', + description: '', + sequence: 1, + totalLots: 0, + status: 'planned', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit(formData); + }; + + return ( +
+
+

Nueva Etapa

+
+
+ + +
+
+
+ + setFormData({ ...formData, code: e.target.value })} + /> +
+
+ + setFormData({ ...formData, sequence: parseInt(e.target.value) })} + /> +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+
+ + setFormData({ ...formData, totalLots: parseInt(e.target.value) })} + /> +
+
+ + +
+
+
+ + +
+
+
+
+ ); +} diff --git a/web/src/pages/admin/proyectos/FraccionamientoDetailPage.tsx b/web/src/pages/admin/proyectos/FraccionamientoDetailPage.tsx new file mode 100644 index 0000000..21b36bc --- /dev/null +++ b/web/src/pages/admin/proyectos/FraccionamientoDetailPage.tsx @@ -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 = { + 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 = { + activo: 'Activo', + pausado: 'Pausado', + completado: 'Completado', + cancelado: 'Cancelado', +}; + +const etapaStatusLabels: Record = { + planned: 'Planeada', + in_progress: 'En Progreso', + completed: 'Completada', + cancelled: 'Cancelada', +}; + +const etapaStatusColors: Record = { + 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(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
Cargando...
; + } + + if (!fraccionamiento) { + return
Fraccionamiento no encontrado
; + } + + return ( +
+ + + {/* Header */} +
+
+
+
+

{fraccionamiento.nombre}

+ + {estadoLabels[fraccionamiento.estado]} + +
+

Codigo: {fraccionamiento.codigo}

+
+
+ + + Volver + + +
+
+ + {/* Info Grid */} +
+
+ +

{fraccionamiento.descripcion || '-'}

+
+
+ +

{fraccionamiento.direccion || '-'}

+
+
+ +

+ {fraccionamiento.fechaInicio + ? new Date(fraccionamiento.fechaInicio).toLocaleDateString() + : '-'} +

+
+
+ +

+ {fraccionamiento.fechaFinEstimada + ? new Date(fraccionamiento.fechaFinEstimada).toLocaleDateString() + : '-'} +

+
+
+
+ + {/* Etapas Section */} +
+
+
+ +

Etapas

+ ({etapas.length}) +
+ +
+ + {etapas.length === 0 ? ( +

No hay etapas registradas

+ ) : ( + + + + + + + + + + + + {etapas.map((etapa) => ( + + + + + + + + ))} + +
+ Codigo + + Nombre + + Total Lotes + + Estado + + Acciones +
{etapa.code}{etapa.name}{etapa.totalLots} + + {etapaStatusLabels[etapa.status]} + + +
+ + + + +
+
+ )} +
+ + {/* Etapa Modal */} + {showEtapaModal && ( + setShowEtapaModal(false)} + onSubmit={handleCreateEtapa} + isLoading={createEtapaMutation.isPending} + /> + )} + + {/* Delete Confirmation */} + {deleteConfirm && ( +
+
+

Confirmar eliminacion

+

+ ¿Esta seguro de eliminar esta etapa? +

+
+ + +
+
+
+ )} +
+ ); +} + +interface EtapaModalProps { + fraccionamientoId: string; + onClose: () => void; + onSubmit: (data: CreateEtapaDto) => Promise; + isLoading: boolean; +} + +function EtapaModal({ fraccionamientoId, onClose, onSubmit, isLoading }: EtapaModalProps) { + const [formData, setFormData] = useState({ + code: '', + name: '', + fraccionamientoId, + description: '', + sequence: 1, + totalLots: 0, + status: 'planned', + startDate: '', + expectedEndDate: '', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit(formData); + }; + + return ( +
+
+

Nueva Etapa

+
+
+
+ + setFormData({ ...formData, code: e.target.value })} + /> +
+
+ + setFormData({ ...formData, sequence: parseInt(e.target.value) })} + /> +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+ +