erp-construccion/docs/02-definicion-modulos/MAI-002-proyectos-estructura/especificaciones/ET-PROJ-001-frontend.md
rckrdmrd 7f422e51db
Some checks failed
CI Pipeline / Lint & Type Check (push) Has been cancelled
CI Pipeline / Validate SSOT Constants (push) Has been cancelled
CI Pipeline / Backend Tests (push) Has been cancelled
CI Pipeline / Frontend Tests (push) Has been cancelled
CI Pipeline / Build (push) Has been cancelled
CI Pipeline / Docker Build (push) Has been cancelled
feat: Documentation and orchestration updates
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:35:28 -06:00

74 KiB

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

// 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, string> = {
  [ProjectType.FRACCIONAMIENTO_HORIZONTAL]: 'Fraccionamiento Horizontal',
  [ProjectType.CONJUNTO_HABITACIONAL]: 'Conjunto Habitacional',
  [ProjectType.EDIFICIO_VERTICAL]: 'Edificio Vertical',
  [ProjectType.MIXTO]: 'Proyecto Mixto',
};

export const ProjectStatusLabels: Record<ProjectStatus, string> = {
  [ProjectStatus.LICITACION]: 'Licitación',
  [ProjectStatus.ADJUDICADO]: 'Adjudicado',
  [ProjectStatus.EJECUCION]: 'Ejecución',
  [ProjectStatus.ENTREGADO]: 'Entregado',
  [ProjectStatus.CERRADO]: 'Cerrado',
};

export const ClientTypeLabels: Record<ClientType, string> = {
  [ClientType.PUBLICO]: 'Público',
  [ClientType.PRIVADO]: 'Privado',
  [ClientType.MIXTO]: 'Mixto',
};

export const ContractTypeLabels: Record<ContractType, string> = {
  [ContractType.LLAVE_EN_MANO]: 'Llave en Mano',
  [ContractType.PRECIO_ALZADO]: 'Precio Alzado',
  [ContractType.ADMINISTRACION]: 'Administración',
  [ContractType.MIXTO]: 'Mixto',
};

Interfaces Principales

// 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<CreateProjectDto> {}

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<ProjectType, number>;
  projectsByStatus: Record<ProjectStatus, number>;
  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

// 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<void>;
  fetchProject: (id: string) => Promise<void>;
  fetchMetrics: () => Promise<void>;

  // Actions - CRUD
  createProject: (dto: CreateProjectDto) => Promise<Project>;
  updateProject: (id: string, dto: UpdateProjectDto) => Promise<Project>;
  deleteProject: (id: string) => Promise<void>;

  // Actions - State Transitions
  transitionProjectState: (id: string, toState: ProjectStatus) => Promise<Project>;

  // Actions - Filters
  setFilters: (filters: Partial<ProjectFilters>) => 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<ProjectsState>()(
  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<ProjectFilters>) => {
          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

// 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<PaginatedResult<Project>> {
    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<PaginatedResult<Project>>(`${BASE_URL}?${params.toString()}`);
    return data;
  },

  /**
   * Get project by ID
   */
  async getById(id: string): Promise<Project> {
    const { data } = await axios.get<Project>(`${BASE_URL}/${id}`);
    return data;
  },

  /**
   * Create new project
   */
  async create(dto: CreateProjectDto): Promise<Project> {
    const { data } = await axios.post<Project>(BASE_URL, dto);
    return data;
  },

  /**
   * Update project
   */
  async update(id: string, dto: UpdateProjectDto): Promise<Project> {
    const { data } = await axios.patch<Project>(`${BASE_URL}/${id}`, dto);
    return data;
  },

  /**
   * Delete project
   */
  async delete(id: string): Promise<void> {
    await axios.delete(`${BASE_URL}/${id}`);
  },

  /**
   * Transition project state
   */
  async transitionState(id: string, toState: ProjectStatus): Promise<Project> {
    const { data } = await axios.post<Project>(`${BASE_URL}/${id}/transition`, { toState });
    return data;
  },

  /**
   * Get project metrics
   */
  async getMetrics(): Promise<ProjectMetrics> {
    const { data } = await axios.get<ProjectMetrics>(`${BASE_URL}/metrics`);
    return data;
  },

  /**
   * Export projects to Excel
   */
  async exportToExcel(filters: ProjectFilters): Promise<Blob> {
    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<Blob> {
    const { data } = await axios.get(`${BASE_URL}/${id}/export/pdf`, {
      responseType: 'blob',
    });
    return data;
  },
};

Validation (Zod)

Project Schema

// 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<typeof projectFormSchema>;

Components

ProjectList Component

// 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<Project>[] = [
    {
      accessorKey: 'projectCode',
      header: 'Código',
      cell: ({ row }) => (
        <div className="font-mono text-sm">{row.original.projectCode}</div>
      ),
    },
    {
      accessorKey: 'name',
      header: 'Proyecto',
      cell: ({ row }) => (
        <div>
          <div className="font-medium">{row.original.name}</div>
          <div className="flex items-center gap-1 text-sm text-muted-foreground">
            <MapPin className="h-3 w-3" />
            {row.original.municipality}, {row.original.state}
          </div>
        </div>
      ),
    },
    {
      accessorKey: 'projectType',
      header: 'Tipo',
      cell: ({ row }) => <ProjectTypeBadge type={row.original.projectType} />,
    },
    {
      accessorKey: 'status',
      header: 'Estado',
      cell: ({ row }) => <ProjectStatusBadge status={row.original.status} />,
    },
    {
      accessorKey: 'clientName',
      header: 'Cliente',
      cell: ({ row }) => (
        <div>
          <div className="font-medium">{row.original.clientName}</div>
          <div className="text-sm text-muted-foreground">{row.original.clientRFC}</div>
        </div>
      ),
    },
    {
      accessorKey: 'contractAmount',
      header: 'Monto',
      cell: ({ row }) => (
        <div className="text-right font-medium">
          {formatCurrency(row.original.contractAmount)}
        </div>
      ),
    },
    {
      accessorKey: 'physicalProgress',
      header: 'Avance Físico',
      cell: ({ row }) => {
        const progress = row.original.physicalProgress ?? 0;
        return (
          <div className="space-y-1">
            <Progress value={progress} className="h-2" />
            <div className="text-xs text-muted-foreground text-center">
              {progress.toFixed(1)}%
            </div>
          </div>
        );
      },
    },
    {
      accessorKey: 'contractStartDate',
      header: 'Inicio',
      cell: ({ row }) => (
        <div className="text-sm">{formatDate(row.original.contractStartDate)}</div>
      ),
    },
    {
      id: 'actions',
      header: 'Acciones',
      cell: ({ row }) => (
        <div className="flex gap-2">
          <Button
            variant="ghost"
            size="sm"
            onClick={(e) => {
              e.stopPropagation();
              navigate(`/projects/${row.original.id}`);
            }}
          >
            <Eye className="h-4 w-4" />
          </Button>
          <Button
            variant="ghost"
            size="sm"
            onClick={(e) => {
              e.stopPropagation();
              navigate(`/projects/${row.original.id}/edit`);
            }}
          >
            <Edit className="h-4 w-4" />
          </Button>
        </div>
      ),
    },
  ];

  const table = useReactTable({
    data: projects,
    columns,
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true,
    pageCount: Math.ceil(total / limit),
  });

  if (isLoading) {
    return (
      <div className="space-y-2">
        {[...Array(5)].map((_, i) => (
          <Skeleton key={i} className="h-16 w-full" />
        ))}
      </div>
    );
  }

  return (
    <div className="space-y-4">
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows?.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow
                  key={row.id}
                  className="cursor-pointer hover:bg-muted/50"
                  onClick={() => navigate(`/projects/${row.original.id}`)}
                >
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={columns.length} className="h-24 text-center">
                  No se encontraron proyectos.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>

      <div className="flex items-center justify-between">
        <div className="text-sm text-muted-foreground">
          Mostrando {projects.length} de {total} proyectos
        </div>
        <div className="flex gap-2">
          <Button
            variant="outline"
            size="sm"
            onClick={() => setFilters({ page: page - 1 })}
            disabled={page === 1}
          >
            Anterior
          </Button>
          <Button
            variant="outline"
            size="sm"
            onClick={() => setFilters({ page: page + 1 })}
            disabled={page >= Math.ceil(total / limit)}
          >
            Siguiente
          </Button>
        </div>
      </div>
    </div>
  );
}

ProjectForm Component

// 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<void>;
  onCancel: () => void;
  isLoading?: boolean;
}

export function ProjectForm({ project, onSubmit, onCancel, isLoading }: ProjectFormProps) {
  const form = useForm<ProjectFormData>({
    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 (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
        <Tabs defaultValue="general" className="w-full">
          <TabsList className="grid w-full grid-cols-4">
            <TabsTrigger value="general">General</TabsTrigger>
            <TabsTrigger value="client">Cliente</TabsTrigger>
            <TabsTrigger value="location">Ubicación</TabsTrigger>
            <TabsTrigger value="legal">Legal</TabsTrigger>
          </TabsList>

          {/* Tab: General */}
          <TabsContent value="general" className="space-y-4">
            <Card>
              <CardHeader>
                <CardTitle>Información General</CardTitle>
              </CardHeader>
              <CardContent className="space-y-4">
                <div className="grid grid-cols-2 gap-4">
                  <FormField
                    control={form.control}
                    name="name"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Nombre del Proyecto *</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="Fraccionamiento Villas del Sol" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="projectType"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Tipo de Proyecto *</FormLabel>
                        <Select onValueChange={field.onChange} defaultValue={field.value}>
                          <FormControl>
                            <SelectTrigger>
                              <SelectValue placeholder="Selecciona tipo" />
                            </SelectTrigger>
                          </FormControl>
                          <SelectContent>
                            {Object.values(ProjectType).map((type) => (
                              <SelectItem key={type} value={type}>
                                {ProjectTypeLabels[type]}
                              </SelectItem>
                            ))}
                          </SelectContent>
                        </Select>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>

                <FormField
                  control={form.control}
                  name="description"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Descripción</FormLabel>
                      <FormControl>
                        <Textarea
                          {...field}
                          placeholder="Descripción general del proyecto..."
                          rows={4}
                        />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />

                <div className="grid grid-cols-3 gap-4">
                  <FormField
                    control={form.control}
                    name="contractStartDate"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Fecha de Inicio Contractual *</FormLabel>
                        <FormControl>
                          <Input {...field} type="date" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="contractualDeadlineMonths"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Plazo (meses) *</FormLabel>
                        <FormControl>
                          <Input
                            {...field}
                            type="number"
                            min={1}
                            onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
                          />
                        </FormControl>
                        <FormDescription>
                          Plazo contractual en meses
                        </FormDescription>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="actualStartDate"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Fecha de Inicio Real</FormLabel>
                        <FormControl>
                          <Input {...field} type="date" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>

                <div className="grid grid-cols-2 gap-4">
                  <FormField
                    control={form.control}
                    name="biddingDate"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Fecha de Licitación</FormLabel>
                        <FormControl>
                          <Input {...field} type="date" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="awardDate"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Fecha de Adjudicación</FormLabel>
                        <FormControl>
                          <Input {...field} type="date" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>
              </CardContent>
            </Card>
          </TabsContent>

          {/* Tab: Cliente */}
          <TabsContent value="client" className="space-y-4">
            <Card>
              <CardHeader>
                <CardTitle>Información del Cliente</CardTitle>
              </CardHeader>
              <CardContent className="space-y-4">
                <div className="grid grid-cols-2 gap-4">
                  <FormField
                    control={form.control}
                    name="clientType"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Tipo de Cliente *</FormLabel>
                        <Select onValueChange={field.onChange} defaultValue={field.value}>
                          <FormControl>
                            <SelectTrigger>
                              <SelectValue placeholder="Selecciona tipo" />
                            </SelectTrigger>
                          </FormControl>
                          <SelectContent>
                            {Object.values(ClientType).map((type) => (
                              <SelectItem key={type} value={type}>
                                {ClientTypeLabels[type]}
                              </SelectItem>
                            ))}
                          </SelectContent>
                        </Select>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="clientName"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Nombre del Cliente *</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="INFONAVIT Estatal" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>

                <div className="grid grid-cols-2 gap-4">
                  <FormField
                    control={form.control}
                    name="clientRFC"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>RFC del Cliente *</FormLabel>
                        <FormControl>
                          <Input
                            {...field}
                            placeholder="ABC123456789"
                            maxLength={13}
                            className="uppercase"
                            onChange={(e) => field.onChange(e.target.value.toUpperCase())}
                          />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="contractType"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Tipo de Contrato *</FormLabel>
                        <Select onValueChange={field.onChange} defaultValue={field.value}>
                          <FormControl>
                            <SelectTrigger>
                              <SelectValue placeholder="Selecciona tipo" />
                            </SelectTrigger>
                          </FormControl>
                          <SelectContent>
                            {Object.values(ContractType).map((type) => (
                              <SelectItem key={type} value={type}>
                                {ContractTypeLabels[type]}
                              </SelectItem>
                            ))}
                          </SelectContent>
                        </Select>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>

                <FormField
                  control={form.control}
                  name="contractAmount"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Monto Contratado (MXN) *</FormLabel>
                      <FormControl>
                        <Input
                          {...field}
                          type="number"
                          min={0}
                          step="0.01"
                          placeholder="125000000.00"
                          onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
                        />
                      </FormControl>
                      <FormDescription>
                        Monto total del contrato en pesos mexicanos
                      </FormDescription>
                      <FormMessage />
                    </FormItem>
                  )}
                />

                <div className="grid grid-cols-3 gap-4">
                  <FormField
                    control={form.control}
                    name="clientContactName"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Contacto Principal</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="Ing. Roberto Martínez" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="clientContactEmail"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Email del Contacto</FormLabel>
                        <FormControl>
                          <Input {...field} type="email" placeholder="contacto@cliente.com" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="clientContactPhone"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Teléfono del Contacto</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="+52 55 1234 5678" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>
              </CardContent>
            </Card>
          </TabsContent>

          {/* Tab: Ubicación */}
          <TabsContent value="location" className="space-y-4">
            <Card>
              <CardHeader>
                <CardTitle>Ubicación del Proyecto</CardTitle>
              </CardHeader>
              <CardContent className="space-y-4">
                <FormField
                  control={form.control}
                  name="address"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Dirección Completa *</FormLabel>
                      <FormControl>
                        <Input {...field} placeholder="Carretera Federal 200 Km 45" />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />

                <div className="grid grid-cols-3 gap-4">
                  <FormField
                    control={form.control}
                    name="state"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Estado *</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="Jalisco" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="municipality"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Municipio *</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="San Juan del Río" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="postalCode"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Código Postal *</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="76800" maxLength={5} />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>

                <div className="grid grid-cols-2 gap-4">
                  <FormField
                    control={form.control}
                    name="latitude"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Latitud</FormLabel>
                        <FormControl>
                          <Input
                            {...field}
                            type="number"
                            step="0.000001"
                            placeholder="19.432608"
                            onChange={(e) => field.onChange(parseFloat(e.target.value) || undefined)}
                          />
                        </FormControl>
                        <FormDescription>Coordenada GPS</FormDescription>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="longitude"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Longitud</FormLabel>
                        <FormControl>
                          <Input
                            {...field}
                            type="number"
                            step="0.000001"
                            placeholder="-99.133209"
                            onChange={(e) => field.onChange(parseFloat(e.target.value) || undefined)}
                          />
                        </FormControl>
                        <FormDescription>Coordenada GPS</FormDescription>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>

                <div className="grid grid-cols-2 gap-4">
                  <FormField
                    control={form.control}
                    name="totalArea"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Superficie Total (m²) *</FormLabel>
                        <FormControl>
                          <Input
                            {...field}
                            type="number"
                            min={0}
                            step="0.01"
                            placeholder="150000.00"
                            onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
                          />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="buildableArea"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Superficie Construible (m²) *</FormLabel>
                        <FormControl>
                          <Input
                            {...field}
                            type="number"
                            min={0}
                            step="0.01"
                            placeholder="120000.00"
                            onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
                          />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>
              </CardContent>
            </Card>
          </TabsContent>

          {/* Tab: Legal */}
          <TabsContent value="legal" className="space-y-4">
            <Card>
              <CardHeader>
                <CardTitle>Información Legal y Permisos</CardTitle>
              </CardHeader>
              <CardContent className="space-y-4">
                <div className="grid grid-cols-2 gap-4">
                  <FormField
                    control={form.control}
                    name="buildingLicense"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Licencia de Construcción</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="LIC-2024-SJR-0456" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="licenseIssueDate"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Fecha de Emisión</FormLabel>
                        <FormControl>
                          <Input {...field} type="date" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>

                <FormField
                  control={form.control}
                  name="licenseExpiryDate"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Fecha de Vencimiento</FormLabel>
                      <FormControl>
                        <Input {...field} type="date" />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />

                <div className="grid grid-cols-2 gap-4">
                  <FormField
                    control={form.control}
                    name="environmentalImpact"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Manifestación de Impacto Ambiental</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="MIA-2024-045" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="landUseApproval"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Uso de Suelo Aprobado</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="H4 - Habitacional densidad media" />
                        </FormControl>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>

                <FormField
                  control={form.control}
                  name="approvedPlanNumber"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Número de Plano Autorizado</FormLabel>
                      <FormControl>
                        <Input {...field} placeholder="PLANO-SJR-2024-145" />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />

                <div className="grid grid-cols-2 gap-4">
                  <FormField
                    control={form.control}
                    name="infonavitNumber"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Número INFONAVIT</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="INF-2024-JL-0123" />
                        </FormControl>
                        <FormDescription>Solo si aplica</FormDescription>
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="fovisssteNumber"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Número FOVISSSTE</FormLabel>
                        <FormControl>
                          <Input {...field} placeholder="FOV-2024-JL-0123" />
                        </FormControl>
                        <FormDescription>Solo si aplica</FormDescription>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>
              </CardContent>
            </Card>
          </TabsContent>
        </Tabs>

        <div className="flex justify-end gap-2">
          <Button type="button" variant="outline" onClick={onCancel}>
            Cancelar
          </Button>
          <Button type="submit" disabled={isLoading}>
            {isLoading ? 'Guardando...' : project ? 'Actualizar Proyecto' : 'Crear Proyecto'}
          </Button>
        </div>
      </form>
    </Form>
  );
}

ProjectCard Component

// components/projects/ProjectCard.tsx
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { MapPin, Calendar, DollarSign, Eye } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Project } from '../../types/project.types';
import { ProjectStatusBadge } from './ProjectStatusBadge';
import { ProjectTypeBadge } from './ProjectTypeBadge';
import { formatCurrency, formatDate } from '@shared/lib/format';

interface ProjectCardProps {
  project: Project;
}

export function ProjectCard({ project }: ProjectCardProps) {
  const navigate = useNavigate();

  return (
    <Card className="hover:shadow-lg transition-shadow cursor-pointer" onClick={() => navigate(`/projects/${project.id}`)}>
      <CardHeader>
        <div className="flex items-start justify-between">
          <div className="space-y-1">
            <CardTitle className="text-lg">{project.name}</CardTitle>
            <p className="text-sm text-muted-foreground font-mono">{project.projectCode}</p>
          </div>
          <div className="flex gap-2">
            <ProjectTypeBadge type={project.projectType} />
            <ProjectStatusBadge status={project.status} />
          </div>
        </div>
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="space-y-2">
          <div className="flex items-center gap-2 text-sm">
            <MapPin className="h-4 w-4 text-muted-foreground" />
            <span>{project.municipality}, {project.state}</span>
          </div>
          <div className="flex items-center gap-2 text-sm">
            <Calendar className="h-4 w-4 text-muted-foreground" />
            <span>Inicio: {formatDate(project.contractStartDate)}</span>
          </div>
          <div className="flex items-center gap-2 text-sm">
            <DollarSign className="h-4 w-4 text-muted-foreground" />
            <span>{formatCurrency(project.contractAmount)}</span>
          </div>
        </div>

        <div className="space-y-2">
          <div className="flex items-center justify-between text-sm">
            <span className="text-muted-foreground">Avance Físico</span>
            <span className="font-medium">{project.physicalProgress?.toFixed(1) ?? 0}%</span>
          </div>
          <Progress value={project.physicalProgress ?? 0} className="h-2" />
        </div>

        {project.totalUnits && (
          <div className="grid grid-cols-3 gap-2 text-center">
            <div className="space-y-1">
              <p className="text-2xl font-bold">{project.totalUnits}</p>
              <p className="text-xs text-muted-foreground">Total</p>
            </div>
            <div className="space-y-1">
              <p className="text-2xl font-bold text-green-600">{project.deliveredUnits ?? 0}</p>
              <p className="text-xs text-muted-foreground">Entregadas</p>
            </div>
            <div className="space-y-1">
              <p className="text-2xl font-bold text-blue-600">{project.unitsInProgress ?? 0}</p>
              <p className="text-xs text-muted-foreground">En Proceso</p>
            </div>
          </div>
        )}
      </CardContent>
      <CardFooter>
        <Button
          variant="outline"
          className="w-full"
          onClick={(e) => {
            e.stopPropagation();
            navigate(`/projects/${project.id}`);
          }}
        >
          <Eye className="h-4 w-4 mr-2" />
          Ver Detalles
        </Button>
      </CardFooter>
    </Card>
  );
}

ProjectStatusBadge Component

// components/projects/ProjectStatusBadge.tsx
import { Badge } from '@/components/ui/badge';
import { ProjectStatus, ProjectStatusLabels } from '../../types/project.types';
import { FileText, CheckCircle, Hammer, Package, Lock } from 'lucide-react';

interface ProjectStatusBadgeProps {
  status: ProjectStatus;
  showIcon?: boolean;
}

const statusConfig: Record<ProjectStatus, { variant: any; icon: any; color: string }> = {
  [ProjectStatus.LICITACION]: {
    variant: 'secondary',
    icon: FileText,
    color: 'text-gray-600',
  },
  [ProjectStatus.ADJUDICADO]: {
    variant: 'default',
    icon: CheckCircle,
    color: 'text-green-600',
  },
  [ProjectStatus.EJECUCION]: {
    variant: 'default',
    icon: Hammer,
    color: 'text-blue-600',
  },
  [ProjectStatus.ENTREGADO]: {
    variant: 'default',
    icon: Package,
    color: 'text-purple-600',
  },
  [ProjectStatus.CERRADO]: {
    variant: 'outline',
    icon: Lock,
    color: 'text-gray-400',
  },
};

export function ProjectStatusBadge({ status, showIcon = true }: ProjectStatusBadgeProps) {
  const config = statusConfig[status];
  const Icon = config.icon;

  return (
    <Badge variant={config.variant} className={config.color}>
      {showIcon && <Icon className="h-3 w-3 mr-1" />}
      {ProjectStatusLabels[status]}
    </Badge>
  );
}

ProjectTypeBadge Component

// components/projects/ProjectTypeBadge.tsx
import { Badge } from '@/components/ui/badge';
import { ProjectType, ProjectTypeLabels } from '../../types/project.types';
import { Home, Building2, Building, Grid3x3 } from 'lucide-react';

interface ProjectTypeBadgeProps {
  type: ProjectType;
  showIcon?: boolean;
}

const typeConfig: Record<ProjectType, { icon: any; color: string }> = {
  [ProjectType.FRACCIONAMIENTO_HORIZONTAL]: {
    icon: Grid3x3,
    color: 'bg-green-100 text-green-700 hover:bg-green-100',
  },
  [ProjectType.CONJUNTO_HABITACIONAL]: {
    icon: Home,
    color: 'bg-blue-100 text-blue-700 hover:bg-blue-100',
  },
  [ProjectType.EDIFICIO_VERTICAL]: {
    icon: Building,
    color: 'bg-purple-100 text-purple-700 hover:bg-purple-100',
  },
  [ProjectType.MIXTO]: {
    icon: Building2,
    color: 'bg-orange-100 text-orange-700 hover:bg-orange-100',
  },
};

export function ProjectTypeBadge({ type, showIcon = true }: ProjectTypeBadgeProps) {
  const config = typeConfig[type];
  const Icon = config.icon;

  return (
    <Badge variant="secondary" className={config.color}>
      {showIcon && <Icon className="h-3 w-3 mr-1" />}
      {ProjectTypeLabels[type]}
    </Badge>
  );
}

Pages

ProjectsListPage

// pages/ProjectsListPage.tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Download, LayoutGrid, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ProjectList } from '../components/projects/ProjectList';
import { ProjectCard } from '../components/projects/ProjectCard';
import { ProjectFilters } from '../components/projects/ProjectFilters';
import { useProjectsStore } from '../stores/projects.store';

export function ProjectsListPage() {
  const navigate = useNavigate();
  const { projects, filters, setFilters } = useProjectsStore();
  const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');

  const handleExport = async () => {
    // TODO: Implementar exportación
    console.log('Exportar proyectos');
  };

  return (
    <div className="container mx-auto py-6 space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold">Proyectos</h1>
          <p className="text-muted-foreground">
            Gestiona el catálogo de proyectos de construcción
          </p>
        </div>
        <div className="flex gap-2">
          <Button variant="outline" onClick={handleExport}>
            <Download className="mr-2 h-4 w-4" />
            Exportar
          </Button>
          <Button onClick={() => navigate('/projects/new')}>
            <Plus className="mr-2 h-4 w-4" />
            Nuevo Proyecto
          </Button>
        </div>
      </div>

      <Card>
        <CardContent className="pt-6">
          <ProjectFilters filters={filters} onFiltersChange={setFilters} />
        </CardContent>
      </Card>

      <div className="flex items-center justify-between">
        <h2 className="text-xl font-semibold">Resultados</h2>
        <div className="flex gap-1">
          <Button
            variant={viewMode === 'list' ? 'default' : 'outline'}
            size="sm"
            onClick={() => setViewMode('list')}
          >
            <List className="h-4 w-4" />
          </Button>
          <Button
            variant={viewMode === 'grid' ? 'default' : 'outline'}
            size="sm"
            onClick={() => setViewMode('grid')}
          >
            <LayoutGrid className="h-4 w-4" />
          </Button>
        </div>
      </div>

      {viewMode === 'list' ? (
        <ProjectList />
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {projects.map((project) => (
            <ProjectCard key={project.id} project={project} />
          ))}
        </div>
      )}
    </div>
  );
}

ProjectFormPage

// pages/ProjectFormPage.tsx
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ProjectForm } from '../components/projects/ProjectForm';
import { useProjectsStore } from '../stores/projects.store';
import { CreateProjectDto } from '../types/project.types';
import { toast } from '@/components/ui/use-toast';

export function ProjectFormPage() {
  const navigate = useNavigate();
  const { id } = useParams<{ id: string }>();
  const { selectedProject, fetchProject, createProject, updateProject, isSaving } = useProjectsStore();

  const isEditMode = !!id;

  useEffect(() => {
    if (isEditMode && id) {
      fetchProject(id);
    }
  }, [id, isEditMode]);

  const handleSubmit = async (data: CreateProjectDto) => {
    try {
      if (isEditMode && id) {
        await updateProject(id, data);
        toast({
          title: 'Proyecto actualizado',
          description: 'El proyecto se ha actualizado correctamente.',
        });
      } else {
        await createProject(data);
        toast({
          title: 'Proyecto creado',
          description: 'El proyecto se ha creado correctamente.',
        });
      }
      navigate('/projects');
    } catch (error: any) {
      toast({
        title: 'Error',
        description: error.message,
        variant: 'destructive',
      });
    }
  };

  const handleCancel = () => {
    navigate('/projects');
  };

  return (
    <div className="container mx-auto py-6 space-y-6">
      <div className="flex items-center gap-4">
        <Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
          <ArrowLeft className="h-4 w-4" />
        </Button>
        <div>
          <h1 className="text-3xl font-bold">
            {isEditMode ? 'Editar Proyecto' : 'Nuevo Proyecto'}
          </h1>
          <p className="text-muted-foreground">
            {isEditMode
              ? 'Actualiza la información del proyecto'
              : 'Crea un nuevo proyecto de construcción'}
          </p>
        </div>
      </div>

      <Card>
        <CardHeader>
          <CardTitle>Información del Proyecto</CardTitle>
        </CardHeader>
        <CardContent>
          <ProjectForm
            project={isEditMode ? selectedProject ?? undefined : undefined}
            onSubmit={handleSubmit}
            onCancel={handleCancel}
            isLoading={isSaving}
          />
        </CardContent>
      </Card>
    </div>
  );
}

ProjectDetailPage

// pages/ProjectDetailPage.tsx
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Edit, Trash, FileDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import { ProjectDetail } from '../components/projects/ProjectDetail';
import { ProjectMetrics } from '../components/projects/ProjectMetrics';
import { ProjectTimeline } from '../components/projects/ProjectTimeline';
import { ProjectLocation } from '../components/projects/ProjectLocation';
import { useProjectsStore } from '../stores/projects.store';
import { toast } from '@/components/ui/use-toast';

export function ProjectDetailPage() {
  const navigate = useNavigate();
  const { id } = useParams<{ id: string }>();
  const { selectedProject, isLoading, fetchProject, deleteProject } = useProjectsStore();

  useEffect(() => {
    if (id) {
      fetchProject(id);
    }
  }, [id]);

  const handleDelete = async () => {
    if (!id) return;

    if (confirm('¿Estás seguro de eliminar este proyecto?')) {
      try {
        await deleteProject(id);
        toast({
          title: 'Proyecto eliminado',
          description: 'El proyecto se ha eliminado correctamente.',
        });
        navigate('/projects');
      } catch (error: any) {
        toast({
          title: 'Error',
          description: error.message,
          variant: 'destructive',
        });
      }
    }
  };

  if (isLoading) {
    return (
      <div className="container mx-auto py-6 space-y-6">
        <Skeleton className="h-12 w-full" />
        <Skeleton className="h-96 w-full" />
      </div>
    );
  }

  if (!selectedProject) {
    return (
      <div className="container mx-auto py-6">
        <Card>
          <CardContent className="py-12 text-center">
            <p className="text-muted-foreground">Proyecto no encontrado</p>
          </CardContent>
        </Card>
      </div>
    );
  }

  return (
    <div className="container mx-auto py-6 space-y-6">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-4">
          <Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
            <ArrowLeft className="h-4 w-4" />
          </Button>
          <div>
            <h1 className="text-3xl font-bold">{selectedProject.name}</h1>
            <p className="text-muted-foreground font-mono">{selectedProject.projectCode}</p>
          </div>
        </div>
        <div className="flex gap-2">
          <Button variant="outline">
            <FileDown className="mr-2 h-4 w-4" />
            Exportar PDF
          </Button>
          <Button variant="outline" onClick={() => navigate(`/projects/${id}/edit`)}>
            <Edit className="mr-2 h-4 w-4" />
            Editar
          </Button>
          <Button variant="destructive" onClick={handleDelete}>
            <Trash className="mr-2 h-4 w-4" />
            Eliminar
          </Button>
        </div>
      </div>

      <Tabs defaultValue="general" className="w-full">
        <TabsList>
          <TabsTrigger value="general">General</TabsTrigger>
          <TabsTrigger value="metrics">Métricas</TabsTrigger>
          <TabsTrigger value="timeline">Timeline</TabsTrigger>
          <TabsTrigger value="location">Ubicación</TabsTrigger>
        </TabsList>

        <TabsContent value="general" className="space-y-4">
          <ProjectDetail project={selectedProject} />
        </TabsContent>

        <TabsContent value="metrics">
          <ProjectMetrics project={selectedProject} />
        </TabsContent>

        <TabsContent value="timeline">
          <ProjectTimeline project={selectedProject} />
        </TabsContent>

        <TabsContent value="location">
          <ProjectLocation project={selectedProject} />
        </TabsContent>
      </Tabs>
    </div>
  );
}

Custom Hooks

useProjects Hook

// hooks/useProjects.ts
import { useEffect } from 'react';
import { useProjectsStore } from '../stores/projects.store';
import { ProjectFilters } from '../types/project.types';

export function useProjects(filters?: ProjectFilters) {
  const store = useProjectsStore();

  useEffect(() => {
    if (filters) {
      store.setFilters(filters);
    }
    store.fetchProjects(filters);
  }, []);

  return {
    projects: store.projects,
    total: store.total,
    isLoading: store.isLoading,
    error: store.error,
    fetchProjects: store.fetchProjects,
    refetch: () => store.fetchProjects(store.filters),
  };
}

useProjectMetrics Hook

// hooks/useProjectMetrics.ts
import { useEffect } from 'react';
import { useProjectsStore } from '../stores/projects.store';

export function useProjectMetrics() {
  const { metrics, isLoading, error, fetchMetrics } = useProjectsStore();

  useEffect(() => {
    fetchMetrics();
  }, []);

  return {
    metrics,
    isLoading,
    error,
    refetch: fetchMetrics,
  };
}

Routes

// routes.tsx
import { lazy } from 'react';
import { RouteObject } from 'react-router-dom';

const ProjectsListPage = lazy(() => import('./pages/ProjectsListPage'));
const ProjectDetailPage = lazy(() => import('./pages/ProjectDetailPage'));
const ProjectFormPage = lazy(() => import('./pages/ProjectFormPage'));
const ProjectDashboardPage = lazy(() => import('./pages/ProjectDashboardPage'));

export const projectsRoutes: RouteObject[] = [
  {
    path: 'projects',
    children: [
      {
        index: true,
        element: <ProjectsListPage />,
      },
      {
        path: 'new',
        element: <ProjectFormPage />,
      },
      {
        path: ':id',
        element: <ProjectDetailPage />,
      },
      {
        path: ':id/edit',
        element: <ProjectFormPage />,
      },
      {
        path: 'dashboard',
        element: <ProjectDashboardPage />,
      },
    ],
  },
];

Historial de Cambios

Versión Fecha Autor Cambios
1.0 2025-12-06 Requirements-Analyst Creación inicial del documento

Aprobaciones

Rol Nombre Fecha Firma
Frontend Lead - - [ ]
Tech Lead - - [ ]
Product Owner - - [ ]