# ET-PROJ-001-FRONTEND: Especificación Frontend - Catálogo de Proyectos ## Identificación | Campo | Valor | |-------|-------| | **ID** | ET-PROJ-001-FRONTEND | | **Módulo** | MAI-002 Proyectos y Estructura | | **RF Base** | RF-PROJ-001 Catálogo de Proyectos | | **Versión** | 1.0 | | **Estado** | En Diseño | | **Framework** | React 18 + TypeScript | | **UI Library** | shadcn/ui + Tailwind CSS | | **State** | Zustand | | **Forms** | React Hook Form + Zod | | **Tables** | TanStack Table v8 | | **Autor** | Requirements-Analyst | | **Fecha** | 2025-12-06 | --- ## Descripción General Especificación técnica del módulo frontend para el Catálogo de Proyectos de Construcción. Incluye páginas, componentes, stores, hooks y servicios para la gestión completa de proyectos inmobiliarios con soporte para fraccionamientos horizontales, conjuntos habitacionales, edificios verticales y proyectos mixtos. ### Características Principales - CRUD completo de proyectos de construcción - 4 tipos de proyectos (Fraccionamiento, Conjunto, Torre, Mixto) - Gestión de ciclo de vida con 5 estados - Filtros avanzados (tipo, estado, cliente, ubicación) - Dashboard con métricas físicas y financieras - Mapas interactivos con geolocalización - Timeline de hitos y fechas críticas - Exportación de datos (PDF, Excel) --- ## Estructura de Archivos ``` apps/frontend/src/modules/projects/ ├── index.ts ├── routes.tsx ├── pages/ │ ├── ProjectsListPage.tsx │ ├── ProjectDetailPage.tsx │ ├── ProjectFormPage.tsx │ ├── ProjectDashboardPage.tsx │ └── ProjectMapPage.tsx ├── components/ │ ├── projects/ │ │ ├── ProjectList.tsx │ │ ├── ProjectCard.tsx │ │ ├── ProjectForm.tsx │ │ ├── ProjectDetail.tsx │ │ ├── ProjectFilters.tsx │ │ ├── ProjectStatusBadge.tsx │ │ ├── ProjectTypeBadge.tsx │ │ ├── ProjectMetrics.tsx │ │ ├── ProjectTimeline.tsx │ │ ├── ProjectLocation.tsx │ │ ├── ProjectClientInfo.tsx │ │ ├── ProjectLegalInfo.tsx │ │ └── ProjectStateTransition.tsx │ └── shared/ │ ├── Map.tsx │ ├── DateRangePicker.tsx │ └── MetricCard.tsx ├── stores/ │ └── projects.store.ts ├── hooks/ │ ├── useProjects.ts │ ├── useProjectMetrics.ts │ └── useProjectStates.ts ├── services/ │ └── projects.service.ts └── types/ └── project.types.ts ``` --- ## Types (TypeScript) ### Tipos y Enums ```typescript // types/project.types.ts export enum ProjectType { FRACCIONAMIENTO_HORIZONTAL = 'fraccionamiento_horizontal', CONJUNTO_HABITACIONAL = 'conjunto_habitacional', EDIFICIO_VERTICAL = 'edificio_vertical', MIXTO = 'mixto', } export enum ProjectStatus { LICITACION = 'licitacion', ADJUDICADO = 'adjudicado', EJECUCION = 'ejecucion', ENTREGADO = 'entregado', CERRADO = 'cerrado', } export enum ClientType { PUBLICO = 'publico', PRIVADO = 'privado', MIXTO = 'mixto', } export enum ContractType { LLAVE_EN_MANO = 'llave_en_mano', PRECIO_ALZADO = 'precio_alzado', ADMINISTRACION = 'administracion', MIXTO = 'mixto', } export const ProjectTypeLabels: Record = { [ProjectType.FRACCIONAMIENTO_HORIZONTAL]: 'Fraccionamiento Horizontal', [ProjectType.CONJUNTO_HABITACIONAL]: 'Conjunto Habitacional', [ProjectType.EDIFICIO_VERTICAL]: 'Edificio Vertical', [ProjectType.MIXTO]: 'Proyecto Mixto', }; export const ProjectStatusLabels: Record = { [ProjectStatus.LICITACION]: 'Licitación', [ProjectStatus.ADJUDICADO]: 'Adjudicado', [ProjectStatus.EJECUCION]: 'Ejecución', [ProjectStatus.ENTREGADO]: 'Entregado', [ProjectStatus.CERRADO]: 'Cerrado', }; export const ClientTypeLabels: Record = { [ClientType.PUBLICO]: 'Público', [ClientType.PRIVADO]: 'Privado', [ClientType.MIXTO]: 'Mixto', }; export const ContractTypeLabels: Record = { [ContractType.LLAVE_EN_MANO]: 'Llave en Mano', [ContractType.PRECIO_ALZADO]: 'Precio Alzado', [ContractType.ADMINISTRACION]: 'Administración', [ContractType.MIXTO]: 'Mixto', }; ``` ### Interfaces Principales ```typescript // types/project.types.ts (continuación) export interface Project { id: string; projectCode: string; // PROJ-2025-001 constructoraId: string; // Información básica name: string; description?: string; projectType: ProjectType; status: ProjectStatus; // Cliente clientType: ClientType; clientName: string; clientRFC: string; clientContactName?: string; clientContactEmail?: string; clientContactPhone?: string; contractType: ContractType; contractAmount: number; // Ubicación address: string; state: string; municipality: string; postalCode: string; latitude?: number; longitude?: number; totalArea: number; // m² buildableArea: number; // m² // Fechas biddingDate?: Date; awardDate?: Date; contractStartDate: Date; actualStartDate?: Date; contractualDeadlineMonths: number; plannedEndDate: Date; actualEndDate?: Date; deliveryDate?: Date; closureDate?: Date; // Información legal buildingLicense?: string; licenseIssueDate?: Date; licenseExpiryDate?: Date; environmentalImpact?: string; landUseApproval?: string; approvedPlanNumber?: string; infonavitNumber?: string; fovisssteNumber?: string; // Métricas (calculadas) totalUnits?: number; deliveredUnits?: number; unitsInProgress?: number; totalBuiltArea?: number; physicalProgress?: number; // % budgetedAmount?: number; executedAmount?: number; financialProgress?: number; // % daysElapsed?: number; daysRemaining?: number; scheduleProgress?: number; // % // Metadata isActive: boolean; createdAt: string; updatedAt: string; createdBy?: string; updatedBy?: string; } export interface CreateProjectDto { name: string; description?: string; projectType: ProjectType; // Cliente clientType: ClientType; clientName: string; clientRFC: string; clientContactName?: string; clientContactEmail?: string; clientContactPhone?: string; contractType: ContractType; contractAmount: number; // Ubicación address: string; state: string; municipality: string; postalCode: string; latitude?: number; longitude?: number; totalArea: number; buildableArea: number; // Fechas biddingDate?: string; awardDate?: string; contractStartDate: string; actualStartDate?: string; contractualDeadlineMonths: number; // Información legal buildingLicense?: string; licenseIssueDate?: string; licenseExpiryDate?: string; environmentalImpact?: string; landUseApproval?: string; approvedPlanNumber?: string; infonavitNumber?: string; fovisssteNumber?: string; } export interface UpdateProjectDto extends Partial {} export interface ProjectFilters { search?: string; projectType?: ProjectType[]; status?: ProjectStatus[]; clientType?: ClientType[]; state?: string[]; municipality?: string[]; dateRangeStart?: string; dateRangeEnd?: string; minAmount?: number; maxAmount?: number; hasLicense?: boolean; page?: number; limit?: number; sortBy?: string; sortOrder?: 'asc' | 'desc'; } export interface ProjectMetrics { totalProjects: number; activeProjects: number; projectsByType: Record; projectsByStatus: Record; totalContractValue: number; totalUnits: number; avgPhysicalProgress: number; avgFinancialProgress: number; } export interface StateTransition { fromState: ProjectStatus; toState: ProjectStatus; allowedTransitions: ProjectStatus[]; requiresApproval: boolean; validationRules: string[]; } ``` --- ## Store (Zustand) ### Projects Store ```typescript // stores/projects.store.ts import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { Project, ProjectFilters, CreateProjectDto, UpdateProjectDto, ProjectMetrics } from '../types/project.types'; import { projectsService } from '../services/projects.service'; import { PaginatedResult } from '@shared/types/pagination'; interface ProjectsState { // Data projects: Project[]; selectedProject: Project | null; metrics: ProjectMetrics | null; total: number; page: number; limit: number; // UI State isLoading: boolean; isSaving: boolean; error: string | null; filters: ProjectFilters; // Actions - Fetching fetchProjects: (filters?: ProjectFilters) => Promise; fetchProject: (id: string) => Promise; fetchMetrics: () => Promise; // Actions - CRUD createProject: (dto: CreateProjectDto) => Promise; updateProject: (id: string, dto: UpdateProjectDto) => Promise; deleteProject: (id: string) => Promise; // Actions - State Transitions transitionProjectState: (id: string, toState: ProjectStatus) => Promise; // Actions - Filters setFilters: (filters: Partial) => void; resetFilters: () => void; // Actions - UI setSelectedProject: (project: Project | null) => void; clearError: () => void; } const defaultFilters: ProjectFilters = { page: 1, limit: 20, search: '', sortBy: 'createdAt', sortOrder: 'desc', }; export const useProjectsStore = create()( devtools( persist( (set, get) => ({ // Initial state projects: [], selectedProject: null, metrics: null, total: 0, page: 1, limit: 20, isLoading: false, isSaving: false, error: null, filters: defaultFilters, // Fetching actions fetchProjects: async (filters?: ProjectFilters) => { set({ isLoading: true, error: null }); try { const mergedFilters = { ...get().filters, ...filters }; const result = await projectsService.getAll(mergedFilters); set({ projects: result.data, total: result.meta.total, page: result.meta.page, limit: result.meta.limit, isLoading: false, filters: mergedFilters, }); } catch (error: any) { set({ error: error.message, isLoading: false }); } }, fetchProject: async (id: string) => { set({ isLoading: true, error: null }); try { const project = await projectsService.getById(id); set({ selectedProject: project, isLoading: false }); } catch (error: any) { set({ error: error.message, isLoading: false }); } }, fetchMetrics: async () => { set({ isLoading: true, error: null }); try { const metrics = await projectsService.getMetrics(); set({ metrics, isLoading: false }); } catch (error: any) { set({ error: error.message, isLoading: false }); } }, // CRUD actions createProject: async (dto: CreateProjectDto) => { set({ isSaving: true, error: null }); try { const project = await projectsService.create(dto); set((state) => ({ projects: [project, ...state.projects], isSaving: false, })); return project; } catch (error: any) { set({ error: error.message, isSaving: false }); throw error; } }, updateProject: async (id: string, dto: UpdateProjectDto) => { set({ isSaving: true, error: null }); try { const project = await projectsService.update(id, dto); set((state) => ({ projects: state.projects.map((p) => (p.id === id ? project : p)), selectedProject: state.selectedProject?.id === id ? project : state.selectedProject, isSaving: false, })); return project; } catch (error: any) { set({ error: error.message, isSaving: false }); throw error; } }, deleteProject: async (id: string) => { set({ isSaving: true, error: null }); try { await projectsService.delete(id); set((state) => ({ projects: state.projects.filter((p) => p.id !== id), selectedProject: state.selectedProject?.id === id ? null : state.selectedProject, isSaving: false, })); } catch (error: any) { set({ error: error.message, isSaving: false }); throw error; } }, // State transitions transitionProjectState: async (id: string, toState: ProjectStatus) => { set({ isSaving: true, error: null }); try { const project = await projectsService.transitionState(id, toState); set((state) => ({ projects: state.projects.map((p) => (p.id === id ? project : p)), selectedProject: state.selectedProject?.id === id ? project : state.selectedProject, isSaving: false, })); return project; } catch (error: any) { set({ error: error.message, isSaving: false }); throw error; } }, // Filter actions setFilters: (filters: Partial) => { set((state) => ({ filters: { ...state.filters, ...filters, page: 1 }, })); }, resetFilters: () => { set({ filters: defaultFilters }); }, // UI actions setSelectedProject: (project: Project | null) => { set({ selectedProject: project }); }, clearError: () => { set({ error: null }); }, }), { name: 'projects-store', partialize: (state) => ({ filters: state.filters, }), } ), { name: 'projects-store' } ) ); ``` --- ## Services (API) ### Projects Service ```typescript // services/projects.service.ts import axios from '@shared/lib/axios'; import { Project, ProjectFilters, CreateProjectDto, UpdateProjectDto, ProjectMetrics, ProjectStatus } from '../types/project.types'; import { PaginatedResult } from '@shared/types/pagination'; const BASE_URL = '/api/v1/projects'; export const projectsService = { /** * Get all projects with filters and pagination */ async getAll(filters: ProjectFilters): Promise> { const params = new URLSearchParams(); if (filters.search) params.append('search', filters.search); if (filters.projectType?.length) params.append('projectType', filters.projectType.join(',')); if (filters.status?.length) params.append('status', filters.status.join(',')); if (filters.clientType?.length) params.append('clientType', filters.clientType.join(',')); if (filters.state?.length) params.append('state', filters.state.join(',')); if (filters.municipality?.length) params.append('municipality', filters.municipality.join(',')); if (filters.dateRangeStart) params.append('dateRangeStart', filters.dateRangeStart); if (filters.dateRangeEnd) params.append('dateRangeEnd', filters.dateRangeEnd); if (filters.minAmount !== undefined) params.append('minAmount', filters.minAmount.toString()); if (filters.maxAmount !== undefined) params.append('maxAmount', filters.maxAmount.toString()); if (filters.hasLicense !== undefined) params.append('hasLicense', filters.hasLicense.toString()); if (filters.page) params.append('page', filters.page.toString()); if (filters.limit) params.append('limit', filters.limit.toString()); if (filters.sortBy) params.append('sortBy', filters.sortBy); if (filters.sortOrder) params.append('sortOrder', filters.sortOrder); const { data } = await axios.get>(`${BASE_URL}?${params.toString()}`); return data; }, /** * Get project by ID */ async getById(id: string): Promise { const { data } = await axios.get(`${BASE_URL}/${id}`); return data; }, /** * Create new project */ async create(dto: CreateProjectDto): Promise { const { data } = await axios.post(BASE_URL, dto); return data; }, /** * Update project */ async update(id: string, dto: UpdateProjectDto): Promise { const { data } = await axios.patch(`${BASE_URL}/${id}`, dto); return data; }, /** * Delete project */ async delete(id: string): Promise { await axios.delete(`${BASE_URL}/${id}`); }, /** * Transition project state */ async transitionState(id: string, toState: ProjectStatus): Promise { const { data } = await axios.post(`${BASE_URL}/${id}/transition`, { toState }); return data; }, /** * Get project metrics */ async getMetrics(): Promise { const { data } = await axios.get(`${BASE_URL}/metrics`); return data; }, /** * Export projects to Excel */ async exportToExcel(filters: ProjectFilters): Promise { const params = new URLSearchParams(); if (filters.search) params.append('search', filters.search); if (filters.projectType?.length) params.append('projectType', filters.projectType.join(',')); if (filters.status?.length) params.append('status', filters.status.join(',')); const { data } = await axios.get(`${BASE_URL}/export/excel?${params.toString()}`, { responseType: 'blob', }); return data; }, /** * Export project to PDF */ async exportToPdf(id: string): Promise { const { data } = await axios.get(`${BASE_URL}/${id}/export/pdf`, { responseType: 'blob', }); return data; }, }; ``` --- ## Validation (Zod) ### Project Schema ```typescript // types/project.validation.ts import { z } from 'zod'; import { ProjectType, ProjectStatus, ClientType, ContractType } from './project.types'; export const projectFormSchema = z.object({ // Información básica name: z .string() .min(1, 'Nombre requerido') .max(200, 'Máximo 200 caracteres'), description: z .string() .max(1000, 'Máximo 1000 caracteres') .optional() .or(z.literal('')), projectType: z.nativeEnum(ProjectType, { required_error: 'Tipo de proyecto requerido', }), // Cliente clientType: z.nativeEnum(ClientType, { required_error: 'Tipo de cliente requerido', }), clientName: z .string() .min(1, 'Nombre del cliente requerido') .max(200, 'Máximo 200 caracteres'), clientRFC: z .string() .min(12, 'RFC inválido (mínimo 12 caracteres)') .max(13, 'RFC inválido (máximo 13 caracteres)') .regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/, 'Formato de RFC inválido'), clientContactName: z .string() .max(100, 'Máximo 100 caracteres') .optional() .or(z.literal('')), clientContactEmail: z .string() .email('Email inválido') .optional() .or(z.literal('')), clientContactPhone: z .string() .max(20, 'Máximo 20 caracteres') .optional() .or(z.literal('')), contractType: z.nativeEnum(ContractType, { required_error: 'Tipo de contrato requerido', }), contractAmount: z .number({ required_error: 'Monto contratado requerido', invalid_type_error: 'Debe ser un número', }) .positive('Debe ser mayor a 0') .max(999999999999.99, 'Monto demasiado grande'), // Ubicación address: z .string() .min(1, 'Dirección requerida') .max(500, 'Máximo 500 caracteres'), state: z .string() .min(1, 'Estado requerido') .max(100, 'Máximo 100 caracteres'), municipality: z .string() .min(1, 'Municipio requerido') .max(100, 'Máximo 100 caracteres'), postalCode: z .string() .length(5, 'Código postal debe tener 5 dígitos') .regex(/^\d{5}$/, 'Código postal inválido'), latitude: z .number() .min(-90, 'Latitud inválida') .max(90, 'Latitud inválida') .optional() .nullable(), longitude: z .number() .min(-180, 'Longitud inválida') .max(180, 'Longitud inválida') .optional() .nullable(), totalArea: z .number({ required_error: 'Área total requerida', }) .positive('Debe ser mayor a 0'), buildableArea: z .number({ required_error: 'Área construible requerida', }) .positive('Debe ser mayor a 0'), // Fechas biddingDate: z.string().optional().or(z.literal('')), awardDate: z.string().optional().or(z.literal('')), contractStartDate: z.string().min(1, 'Fecha de inicio contractual requerida'), actualStartDate: z.string().optional().or(z.literal('')), contractualDeadlineMonths: z .number({ required_error: 'Plazo contractual requerido', }) .int('Debe ser un número entero') .positive('Debe ser mayor a 0') .max(120, 'Máximo 120 meses (10 años)'), // Información legal buildingLicense: z.string().max(50).optional().or(z.literal('')), licenseIssueDate: z.string().optional().or(z.literal('')), licenseExpiryDate: z.string().optional().or(z.literal('')), environmentalImpact: z.string().max(50).optional().or(z.literal('')), landUseApproval: z.string().max(50).optional().or(z.literal('')), approvedPlanNumber: z.string().max(50).optional().or(z.literal('')), infonavitNumber: z.string().max(50).optional().or(z.literal('')), fovisssteNumber: z.string().max(50).optional().or(z.literal('')), }).refine( (data) => data.buildableArea <= data.totalArea, { message: 'Área construible no puede ser mayor al área total', path: ['buildableArea'], } ); export type ProjectFormData = z.infer; ``` --- ## Components ### ProjectList Component ```tsx // components/projects/ProjectList.tsx import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { flexRender, getCoreRowModel, useReactTable, ColumnDef, } from '@tanstack/react-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Skeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; import { MapPin, Eye, Edit } from 'lucide-react'; import { useProjectsStore } from '../../stores/projects.store'; import { Project } from '../../types/project.types'; import { ProjectStatusBadge } from './ProjectStatusBadge'; import { ProjectTypeBadge } from './ProjectTypeBadge'; import { formatCurrency, formatDate } from '@shared/lib/format'; export function ProjectList() { const navigate = useNavigate(); const { projects, total, page, limit, isLoading, filters, fetchProjects, setFilters, } = useProjectsStore(); useEffect(() => { fetchProjects(); }, [filters]); const columns: ColumnDef[] = [ { accessorKey: 'projectCode', header: 'Código', cell: ({ row }) => (
{row.original.projectCode}
), }, { accessorKey: 'name', header: 'Proyecto', cell: ({ row }) => (
{row.original.name}
{row.original.municipality}, {row.original.state}
), }, { accessorKey: 'projectType', header: 'Tipo', cell: ({ row }) => , }, { accessorKey: 'status', header: 'Estado', cell: ({ row }) => , }, { accessorKey: 'clientName', header: 'Cliente', cell: ({ row }) => (
{row.original.clientName}
{row.original.clientRFC}
), }, { accessorKey: 'contractAmount', header: 'Monto', cell: ({ row }) => (
{formatCurrency(row.original.contractAmount)}
), }, { accessorKey: 'physicalProgress', header: 'Avance Físico', cell: ({ row }) => { const progress = row.original.physicalProgress ?? 0; return (
{progress.toFixed(1)}%
); }, }, { accessorKey: 'contractStartDate', header: 'Inicio', cell: ({ row }) => (
{formatDate(row.original.contractStartDate)}
), }, { id: 'actions', header: 'Acciones', cell: ({ row }) => (
), }, ]; const table = useReactTable({ data: projects, columns, getCoreRowModel: getCoreRowModel(), manualPagination: true, pageCount: Math.ceil(total / limit), }); if (isLoading) { return (
{[...Array(5)].map((_, i) => ( ))}
); } return (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} ))} ))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( navigate(`/projects/${row.original.id}`)} > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} )) ) : ( No se encontraron proyectos. )}
Mostrando {projects.length} de {total} proyectos
); } ``` ### ProjectForm Component ```tsx // components/projects/ProjectForm.tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Project, ProjectType, ClientType, ContractType, ProjectTypeLabels, ClientTypeLabels, ContractTypeLabels, CreateProjectDto } from '../../types/project.types'; import { projectFormSchema, ProjectFormData } from '../../types/project.validation'; interface ProjectFormProps { project?: Project; onSubmit: (data: CreateProjectDto) => Promise; onCancel: () => void; isLoading?: boolean; } export function ProjectForm({ project, onSubmit, onCancel, isLoading }: ProjectFormProps) { const form = useForm({ resolver: zodResolver(projectFormSchema), defaultValues: { name: project?.name ?? '', description: project?.description ?? '', projectType: project?.projectType ?? ProjectType.FRACCIONAMIENTO_HORIZONTAL, clientType: project?.clientType ?? ClientType.PUBLICO, clientName: project?.clientName ?? '', clientRFC: project?.clientRFC ?? '', clientContactName: project?.clientContactName ?? '', clientContactEmail: project?.clientContactEmail ?? '', clientContactPhone: project?.clientContactPhone ?? '', contractType: project?.contractType ?? ContractType.LLAVE_EN_MANO, contractAmount: project?.contractAmount ?? 0, address: project?.address ?? '', state: project?.state ?? '', municipality: project?.municipality ?? '', postalCode: project?.postalCode ?? '', latitude: project?.latitude, longitude: project?.longitude, totalArea: project?.totalArea ?? 0, buildableArea: project?.buildableArea ?? 0, biddingDate: project?.biddingDate ? new Date(project.biddingDate).toISOString().split('T')[0] : '', awardDate: project?.awardDate ? new Date(project.awardDate).toISOString().split('T')[0] : '', contractStartDate: project?.contractStartDate ? new Date(project.contractStartDate).toISOString().split('T')[0] : '', actualStartDate: project?.actualStartDate ? new Date(project.actualStartDate).toISOString().split('T')[0] : '', contractualDeadlineMonths: project?.contractualDeadlineMonths ?? 24, buildingLicense: project?.buildingLicense ?? '', licenseIssueDate: project?.licenseIssueDate ? new Date(project.licenseIssueDate).toISOString().split('T')[0] : '', licenseExpiryDate: project?.licenseExpiryDate ? new Date(project.licenseExpiryDate).toISOString().split('T')[0] : '', environmentalImpact: project?.environmentalImpact ?? '', landUseApproval: project?.landUseApproval ?? '', approvedPlanNumber: project?.approvedPlanNumber ?? '', infonavitNumber: project?.infonavitNumber ?? '', fovisssteNumber: project?.fovisssteNumber ?? '', }, }); const handleSubmit = async (data: ProjectFormData) => { const dto: CreateProjectDto = { ...data, // Convertir strings vacíos a undefined description: data.description || undefined, clientContactName: data.clientContactName || undefined, clientContactEmail: data.clientContactEmail || undefined, clientContactPhone: data.clientContactPhone || undefined, biddingDate: data.biddingDate || undefined, awardDate: data.awardDate || undefined, actualStartDate: data.actualStartDate || undefined, buildingLicense: data.buildingLicense || undefined, licenseIssueDate: data.licenseIssueDate || undefined, licenseExpiryDate: data.licenseExpiryDate || undefined, environmentalImpact: data.environmentalImpact || undefined, landUseApproval: data.landUseApproval || undefined, approvedPlanNumber: data.approvedPlanNumber || undefined, infonavitNumber: data.infonavitNumber || undefined, fovisssteNumber: data.fovisssteNumber || undefined, }; await onSubmit(dto); }; return (
General Cliente Ubicación Legal {/* Tab: General */} Información General
( Nombre del Proyecto * )} /> ( Tipo de Proyecto * )} />
( Descripción