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>
2383 lines
74 KiB
Markdown
2383 lines
74 KiB
Markdown
# ET-PROJ-001-FRONTEND: Especificación Frontend - Catálogo de Proyectos
|
|
|
|
## Identificación
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **ID** | ET-PROJ-001-FRONTEND |
|
|
| **Módulo** | MAI-002 Proyectos y Estructura |
|
|
| **RF Base** | RF-PROJ-001 Catálogo de Proyectos |
|
|
| **Versión** | 1.0 |
|
|
| **Estado** | En Diseño |
|
|
| **Framework** | React 18 + TypeScript |
|
|
| **UI Library** | shadcn/ui + Tailwind CSS |
|
|
| **State** | Zustand |
|
|
| **Forms** | React Hook Form + Zod |
|
|
| **Tables** | TanStack Table v8 |
|
|
| **Autor** | Requirements-Analyst |
|
|
| **Fecha** | 2025-12-06 |
|
|
|
|
---
|
|
|
|
## Descripción General
|
|
|
|
Especificación técnica del módulo frontend para el Catálogo de Proyectos de Construcción. Incluye páginas, componentes, stores, hooks y servicios para la gestión completa de proyectos inmobiliarios con soporte para fraccionamientos horizontales, conjuntos habitacionales, edificios verticales y proyectos mixtos.
|
|
|
|
### Características Principales
|
|
|
|
- CRUD completo de proyectos de construcción
|
|
- 4 tipos de proyectos (Fraccionamiento, Conjunto, Torre, Mixto)
|
|
- Gestión de ciclo de vida con 5 estados
|
|
- Filtros avanzados (tipo, estado, cliente, ubicación)
|
|
- Dashboard con métricas físicas y financieras
|
|
- Mapas interactivos con geolocalización
|
|
- Timeline de hitos y fechas críticas
|
|
- Exportación de datos (PDF, Excel)
|
|
|
|
---
|
|
|
|
## Estructura de Archivos
|
|
|
|
```
|
|
apps/frontend/src/modules/projects/
|
|
├── index.ts
|
|
├── routes.tsx
|
|
├── pages/
|
|
│ ├── ProjectsListPage.tsx
|
|
│ ├── ProjectDetailPage.tsx
|
|
│ ├── ProjectFormPage.tsx
|
|
│ ├── ProjectDashboardPage.tsx
|
|
│ └── ProjectMapPage.tsx
|
|
├── components/
|
|
│ ├── projects/
|
|
│ │ ├── ProjectList.tsx
|
|
│ │ ├── ProjectCard.tsx
|
|
│ │ ├── ProjectForm.tsx
|
|
│ │ ├── ProjectDetail.tsx
|
|
│ │ ├── ProjectFilters.tsx
|
|
│ │ ├── ProjectStatusBadge.tsx
|
|
│ │ ├── ProjectTypeBadge.tsx
|
|
│ │ ├── ProjectMetrics.tsx
|
|
│ │ ├── ProjectTimeline.tsx
|
|
│ │ ├── ProjectLocation.tsx
|
|
│ │ ├── ProjectClientInfo.tsx
|
|
│ │ ├── ProjectLegalInfo.tsx
|
|
│ │ └── ProjectStateTransition.tsx
|
|
│ └── shared/
|
|
│ ├── Map.tsx
|
|
│ ├── DateRangePicker.tsx
|
|
│ └── MetricCard.tsx
|
|
├── stores/
|
|
│ └── projects.store.ts
|
|
├── hooks/
|
|
│ ├── useProjects.ts
|
|
│ ├── useProjectMetrics.ts
|
|
│ └── useProjectStates.ts
|
|
├── services/
|
|
│ └── projects.service.ts
|
|
└── types/
|
|
└── project.types.ts
|
|
```
|
|
|
|
---
|
|
|
|
## Types (TypeScript)
|
|
|
|
### Tipos y Enums
|
|
|
|
```typescript
|
|
// types/project.types.ts
|
|
|
|
export enum ProjectType {
|
|
FRACCIONAMIENTO_HORIZONTAL = 'fraccionamiento_horizontal',
|
|
CONJUNTO_HABITACIONAL = 'conjunto_habitacional',
|
|
EDIFICIO_VERTICAL = 'edificio_vertical',
|
|
MIXTO = 'mixto',
|
|
}
|
|
|
|
export enum ProjectStatus {
|
|
LICITACION = 'licitacion',
|
|
ADJUDICADO = 'adjudicado',
|
|
EJECUCION = 'ejecucion',
|
|
ENTREGADO = 'entregado',
|
|
CERRADO = 'cerrado',
|
|
}
|
|
|
|
export enum ClientType {
|
|
PUBLICO = 'publico',
|
|
PRIVADO = 'privado',
|
|
MIXTO = 'mixto',
|
|
}
|
|
|
|
export enum ContractType {
|
|
LLAVE_EN_MANO = 'llave_en_mano',
|
|
PRECIO_ALZADO = 'precio_alzado',
|
|
ADMINISTRACION = 'administracion',
|
|
MIXTO = 'mixto',
|
|
}
|
|
|
|
export const ProjectTypeLabels: Record<ProjectType, 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
|
|
|
|
```typescript
|
|
// types/project.types.ts (continuación)
|
|
|
|
export interface Project {
|
|
id: string;
|
|
projectCode: string; // PROJ-2025-001
|
|
constructoraId: string;
|
|
|
|
// Información básica
|
|
name: string;
|
|
description?: string;
|
|
projectType: ProjectType;
|
|
status: ProjectStatus;
|
|
|
|
// Cliente
|
|
clientType: ClientType;
|
|
clientName: string;
|
|
clientRFC: string;
|
|
clientContactName?: string;
|
|
clientContactEmail?: string;
|
|
clientContactPhone?: string;
|
|
contractType: ContractType;
|
|
contractAmount: number;
|
|
|
|
// Ubicación
|
|
address: string;
|
|
state: string;
|
|
municipality: string;
|
|
postalCode: string;
|
|
latitude?: number;
|
|
longitude?: number;
|
|
totalArea: number; // m²
|
|
buildableArea: number; // m²
|
|
|
|
// Fechas
|
|
biddingDate?: Date;
|
|
awardDate?: Date;
|
|
contractStartDate: Date;
|
|
actualStartDate?: Date;
|
|
contractualDeadlineMonths: number;
|
|
plannedEndDate: Date;
|
|
actualEndDate?: Date;
|
|
deliveryDate?: Date;
|
|
closureDate?: Date;
|
|
|
|
// Información legal
|
|
buildingLicense?: string;
|
|
licenseIssueDate?: Date;
|
|
licenseExpiryDate?: Date;
|
|
environmentalImpact?: string;
|
|
landUseApproval?: string;
|
|
approvedPlanNumber?: string;
|
|
infonavitNumber?: string;
|
|
fovisssteNumber?: string;
|
|
|
|
// Métricas (calculadas)
|
|
totalUnits?: number;
|
|
deliveredUnits?: number;
|
|
unitsInProgress?: number;
|
|
totalBuiltArea?: number;
|
|
physicalProgress?: number; // %
|
|
budgetedAmount?: number;
|
|
executedAmount?: number;
|
|
financialProgress?: number; // %
|
|
daysElapsed?: number;
|
|
daysRemaining?: number;
|
|
scheduleProgress?: number; // %
|
|
|
|
// Metadata
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
createdBy?: string;
|
|
updatedBy?: string;
|
|
}
|
|
|
|
export interface CreateProjectDto {
|
|
name: string;
|
|
description?: string;
|
|
projectType: ProjectType;
|
|
|
|
// Cliente
|
|
clientType: ClientType;
|
|
clientName: string;
|
|
clientRFC: string;
|
|
clientContactName?: string;
|
|
clientContactEmail?: string;
|
|
clientContactPhone?: string;
|
|
contractType: ContractType;
|
|
contractAmount: number;
|
|
|
|
// Ubicación
|
|
address: string;
|
|
state: string;
|
|
municipality: string;
|
|
postalCode: string;
|
|
latitude?: number;
|
|
longitude?: number;
|
|
totalArea: number;
|
|
buildableArea: number;
|
|
|
|
// Fechas
|
|
biddingDate?: string;
|
|
awardDate?: string;
|
|
contractStartDate: string;
|
|
actualStartDate?: string;
|
|
contractualDeadlineMonths: number;
|
|
|
|
// Información legal
|
|
buildingLicense?: string;
|
|
licenseIssueDate?: string;
|
|
licenseExpiryDate?: string;
|
|
environmentalImpact?: string;
|
|
landUseApproval?: string;
|
|
approvedPlanNumber?: string;
|
|
infonavitNumber?: string;
|
|
fovisssteNumber?: string;
|
|
}
|
|
|
|
export interface UpdateProjectDto extends Partial<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
|
|
|
|
```typescript
|
|
// stores/projects.store.ts
|
|
import { create } from 'zustand';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
import { Project, ProjectFilters, CreateProjectDto, UpdateProjectDto, ProjectMetrics } from '../types/project.types';
|
|
import { projectsService } from '../services/projects.service';
|
|
import { PaginatedResult } from '@shared/types/pagination';
|
|
|
|
interface ProjectsState {
|
|
// Data
|
|
projects: Project[];
|
|
selectedProject: Project | null;
|
|
metrics: ProjectMetrics | null;
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
|
|
// UI State
|
|
isLoading: boolean;
|
|
isSaving: boolean;
|
|
error: string | null;
|
|
filters: ProjectFilters;
|
|
|
|
// Actions - Fetching
|
|
fetchProjects: (filters?: ProjectFilters) => Promise<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
|
|
|
|
```typescript
|
|
// services/projects.service.ts
|
|
import axios from '@shared/lib/axios';
|
|
import { Project, ProjectFilters, CreateProjectDto, UpdateProjectDto, ProjectMetrics, ProjectStatus } from '../types/project.types';
|
|
import { PaginatedResult } from '@shared/types/pagination';
|
|
|
|
const BASE_URL = '/api/v1/projects';
|
|
|
|
export const projectsService = {
|
|
/**
|
|
* Get all projects with filters and pagination
|
|
*/
|
|
async getAll(filters: ProjectFilters): Promise<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
|
|
|
|
```typescript
|
|
// types/project.validation.ts
|
|
import { z } from 'zod';
|
|
import { ProjectType, ProjectStatus, ClientType, ContractType } from './project.types';
|
|
|
|
export const projectFormSchema = z.object({
|
|
// Información básica
|
|
name: z
|
|
.string()
|
|
.min(1, 'Nombre requerido')
|
|
.max(200, 'Máximo 200 caracteres'),
|
|
description: z
|
|
.string()
|
|
.max(1000, 'Máximo 1000 caracteres')
|
|
.optional()
|
|
.or(z.literal('')),
|
|
projectType: z.nativeEnum(ProjectType, {
|
|
required_error: 'Tipo de proyecto requerido',
|
|
}),
|
|
|
|
// Cliente
|
|
clientType: z.nativeEnum(ClientType, {
|
|
required_error: 'Tipo de cliente requerido',
|
|
}),
|
|
clientName: z
|
|
.string()
|
|
.min(1, 'Nombre del cliente requerido')
|
|
.max(200, 'Máximo 200 caracteres'),
|
|
clientRFC: z
|
|
.string()
|
|
.min(12, 'RFC inválido (mínimo 12 caracteres)')
|
|
.max(13, 'RFC inválido (máximo 13 caracteres)')
|
|
.regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/, 'Formato de RFC inválido'),
|
|
clientContactName: z
|
|
.string()
|
|
.max(100, 'Máximo 100 caracteres')
|
|
.optional()
|
|
.or(z.literal('')),
|
|
clientContactEmail: z
|
|
.string()
|
|
.email('Email inválido')
|
|
.optional()
|
|
.or(z.literal('')),
|
|
clientContactPhone: z
|
|
.string()
|
|
.max(20, 'Máximo 20 caracteres')
|
|
.optional()
|
|
.or(z.literal('')),
|
|
contractType: z.nativeEnum(ContractType, {
|
|
required_error: 'Tipo de contrato requerido',
|
|
}),
|
|
contractAmount: z
|
|
.number({
|
|
required_error: 'Monto contratado requerido',
|
|
invalid_type_error: 'Debe ser un número',
|
|
})
|
|
.positive('Debe ser mayor a 0')
|
|
.max(999999999999.99, 'Monto demasiado grande'),
|
|
|
|
// Ubicación
|
|
address: z
|
|
.string()
|
|
.min(1, 'Dirección requerida')
|
|
.max(500, 'Máximo 500 caracteres'),
|
|
state: z
|
|
.string()
|
|
.min(1, 'Estado requerido')
|
|
.max(100, 'Máximo 100 caracteres'),
|
|
municipality: z
|
|
.string()
|
|
.min(1, 'Municipio requerido')
|
|
.max(100, 'Máximo 100 caracteres'),
|
|
postalCode: z
|
|
.string()
|
|
.length(5, 'Código postal debe tener 5 dígitos')
|
|
.regex(/^\d{5}$/, 'Código postal inválido'),
|
|
latitude: z
|
|
.number()
|
|
.min(-90, 'Latitud inválida')
|
|
.max(90, 'Latitud inválida')
|
|
.optional()
|
|
.nullable(),
|
|
longitude: z
|
|
.number()
|
|
.min(-180, 'Longitud inválida')
|
|
.max(180, 'Longitud inválida')
|
|
.optional()
|
|
.nullable(),
|
|
totalArea: z
|
|
.number({
|
|
required_error: 'Área total requerida',
|
|
})
|
|
.positive('Debe ser mayor a 0'),
|
|
buildableArea: z
|
|
.number({
|
|
required_error: 'Área construible requerida',
|
|
})
|
|
.positive('Debe ser mayor a 0'),
|
|
|
|
// Fechas
|
|
biddingDate: z.string().optional().or(z.literal('')),
|
|
awardDate: z.string().optional().or(z.literal('')),
|
|
contractStartDate: z.string().min(1, 'Fecha de inicio contractual requerida'),
|
|
actualStartDate: z.string().optional().or(z.literal('')),
|
|
contractualDeadlineMonths: z
|
|
.number({
|
|
required_error: 'Plazo contractual requerido',
|
|
})
|
|
.int('Debe ser un número entero')
|
|
.positive('Debe ser mayor a 0')
|
|
.max(120, 'Máximo 120 meses (10 años)'),
|
|
|
|
// Información legal
|
|
buildingLicense: z.string().max(50).optional().or(z.literal('')),
|
|
licenseIssueDate: z.string().optional().or(z.literal('')),
|
|
licenseExpiryDate: z.string().optional().or(z.literal('')),
|
|
environmentalImpact: z.string().max(50).optional().or(z.literal('')),
|
|
landUseApproval: z.string().max(50).optional().or(z.literal('')),
|
|
approvedPlanNumber: z.string().max(50).optional().or(z.literal('')),
|
|
infonavitNumber: z.string().max(50).optional().or(z.literal('')),
|
|
fovisssteNumber: z.string().max(50).optional().or(z.literal('')),
|
|
}).refine(
|
|
(data) => data.buildableArea <= data.totalArea,
|
|
{
|
|
message: 'Área construible no puede ser mayor al área total',
|
|
path: ['buildableArea'],
|
|
}
|
|
);
|
|
|
|
export type ProjectFormData = z.infer<typeof projectFormSchema>;
|
|
```
|
|
|
|
---
|
|
|
|
## Components
|
|
|
|
### ProjectList Component
|
|
|
|
```tsx
|
|
// components/projects/ProjectList.tsx
|
|
import { useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
flexRender,
|
|
getCoreRowModel,
|
|
useReactTable,
|
|
ColumnDef,
|
|
} from '@tanstack/react-table';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { MapPin, Eye, Edit } from 'lucide-react';
|
|
import { useProjectsStore } from '../../stores/projects.store';
|
|
import { Project } from '../../types/project.types';
|
|
import { ProjectStatusBadge } from './ProjectStatusBadge';
|
|
import { ProjectTypeBadge } from './ProjectTypeBadge';
|
|
import { formatCurrency, formatDate } from '@shared/lib/format';
|
|
|
|
export function ProjectList() {
|
|
const navigate = useNavigate();
|
|
const {
|
|
projects,
|
|
total,
|
|
page,
|
|
limit,
|
|
isLoading,
|
|
filters,
|
|
fetchProjects,
|
|
setFilters,
|
|
} = useProjectsStore();
|
|
|
|
useEffect(() => {
|
|
fetchProjects();
|
|
}, [filters]);
|
|
|
|
const columns: ColumnDef<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
|
|
|
|
```tsx
|
|
// components/projects/ProjectForm.tsx
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
Project,
|
|
ProjectType,
|
|
ClientType,
|
|
ContractType,
|
|
ProjectTypeLabels,
|
|
ClientTypeLabels,
|
|
ContractTypeLabels,
|
|
CreateProjectDto
|
|
} from '../../types/project.types';
|
|
import { projectFormSchema, ProjectFormData } from '../../types/project.validation';
|
|
|
|
interface ProjectFormProps {
|
|
project?: Project;
|
|
onSubmit: (data: CreateProjectDto) => Promise<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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```tsx
|
|
// 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 | - | - | [ ] |
|