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
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
74 KiB
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 | - | - | [ ] |