From 55261598a240be4584cbab3a987825dfa28dfa82 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Wed, 4 Feb 2026 11:36:21 -0600 Subject: [PATCH] [FIX] fix: Resolve TypeScript errors for successful build Fixes: - Add teal, cyan, slate colors to StatusColor type and StatusBadge - Create StatsCard component with color prop for backward compatibility - Add label/required props to FormGroup component - Fix Pagination to accept both currentPage and page props - Fix unused imports in quality and contracts pages - Add missing Plus, Trash2, User icon imports in contracts pages - Remove duplicate formatDate function in ContratoDetailPage New components: - StatsCard, StatsCardGrid for statistics display Build: Success (npm run build passes) Dev: Success (npm run dev starts on port 3020) Co-Authored-By: Claude Opus 4.5 --- web/src/components/common/FormField.tsx | 26 +- web/src/components/common/Pagination.tsx | 15 +- web/src/components/common/StatsCard.tsx | 183 ++++ web/src/components/common/StatusBadge.tsx | 3 + web/src/components/common/index.ts | 5 + web/src/components/quality/ChecklistForm.tsx | 347 +++++++ .../quality/InspectionResultsForm.tsx | 363 ++++++++ web/src/components/quality/index.ts | 6 + web/src/hooks/useContracts.ts | 451 +++++++++ web/src/hooks/useQuality.ts | 647 +++++++++++++ .../pages/admin/calidad/ChecklistsPage.tsx | 312 +++++++ .../pages/admin/calidad/InspeccionesPage.tsx | 514 +++++++++++ .../admin/calidad/NoConformidadesPage.tsx | 414 +++++++++ web/src/pages/admin/calidad/TicketsPage.tsx | 327 +++++++ .../admin/contratos/ContratoDetailPage.tsx | 852 ++++++++++++++++++ .../pages/admin/contratos/ContratosPage.tsx | 337 +++++++ .../admin/contratos/SubcontratistasPage.tsx | 520 +++++++++++ web/src/pages/admin/contratos/index.ts | 7 + web/src/services/contracts/contracts.api.ts | 204 +++++ web/src/services/contracts/index.ts | 5 + web/src/services/quality.ts | 324 +++++++ web/src/types/common.types.ts | 5 +- web/src/types/contracts.types.ts | 337 +++++++ web/src/types/quality.types.ts | 504 +++++++++++ 24 files changed, 6701 insertions(+), 7 deletions(-) create mode 100644 web/src/components/common/StatsCard.tsx create mode 100644 web/src/components/quality/ChecklistForm.tsx create mode 100644 web/src/components/quality/InspectionResultsForm.tsx create mode 100644 web/src/components/quality/index.ts create mode 100644 web/src/hooks/useContracts.ts create mode 100644 web/src/hooks/useQuality.ts create mode 100644 web/src/pages/admin/calidad/ChecklistsPage.tsx create mode 100644 web/src/pages/admin/calidad/InspeccionesPage.tsx create mode 100644 web/src/pages/admin/calidad/NoConformidadesPage.tsx create mode 100644 web/src/pages/admin/calidad/TicketsPage.tsx create mode 100644 web/src/pages/admin/contratos/ContratoDetailPage.tsx create mode 100644 web/src/pages/admin/contratos/ContratosPage.tsx create mode 100644 web/src/pages/admin/contratos/SubcontratistasPage.tsx create mode 100644 web/src/pages/admin/contratos/index.ts create mode 100644 web/src/services/contracts/contracts.api.ts create mode 100644 web/src/services/contracts/index.ts create mode 100644 web/src/services/quality.ts create mode 100644 web/src/types/contracts.types.ts create mode 100644 web/src/types/quality.types.ts diff --git a/web/src/components/common/FormField.tsx b/web/src/components/common/FormField.tsx index 4c92ae5..c67fcea 100644 --- a/web/src/components/common/FormField.tsx +++ b/web/src/components/common/FormField.tsx @@ -200,13 +200,19 @@ export const CheckboxField = forwardRef( CheckboxField.displayName = 'CheckboxField'; // Form Group (for horizontal layouts) -interface FormGroupProps { +export interface FormGroupProps { children: React.ReactNode; cols?: 1 | 2 | 3 | 4; + /** Optional label for the group */ + label?: string; + /** Mark as required */ + required?: boolean; + /** Error message */ + error?: string; className?: string; } -export function FormGroup({ children, cols = 2, className }: FormGroupProps) { +export function FormGroup({ children, cols = 2, label, required, error, className }: FormGroupProps) { const colClasses = { 1: 'grid-cols-1', 2: 'grid-cols-1 sm:grid-cols-2', @@ -214,6 +220,22 @@ export function FormGroup({ children, cols = 2, className }: FormGroupProps) { 4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4', }; + // If label is provided, wrap in a fieldset-like structure + if (label) { + return ( +
+ +
1 ? colClasses[cols] : '')}> + {children} +
+ {error &&

{error}

} +
+ ); + } + return (
{children} diff --git a/web/src/components/common/Pagination.tsx b/web/src/components/common/Pagination.tsx index d63acba..ef5b47e 100644 --- a/web/src/components/common/Pagination.tsx +++ b/web/src/components/common/Pagination.tsx @@ -7,7 +7,9 @@ import clsx from 'clsx'; export interface PaginationProps { /** Current page (1-indexed) */ - currentPage: number; + currentPage?: number; + /** Alias for currentPage (backward compatibility) */ + page?: number; /** Total number of pages */ totalPages: number; /** Total number of items */ @@ -47,7 +49,8 @@ const iconSizes = { }; export function Pagination({ - currentPage, + currentPage: currentPageProp, + page, totalPages, totalItems, pageSize = 10, @@ -61,6 +64,8 @@ export function Pagination({ className, disabled = false, }: PaginationProps) { + // Support both currentPage and page props (page is alias for backward compatibility) + const currentPage = currentPageProp ?? page ?? 1; // Generate page numbers to display const getPageNumbers = (): (number | 'ellipsis')[] => { const pages: (number | 'ellipsis')[] = []; @@ -236,12 +241,14 @@ export function Pagination({ * Simple pagination (just prev/next) */ export function SimplePagination({ - currentPage, + currentPage: currentPageProp, + page, totalPages, onPageChange, disabled = false, className, -}: Pick) { +}: Pick) { + const currentPage = currentPageProp ?? page ?? 1; return (
+
+ + {items.length === 0 ? ( +
+

No hay items agregados

+ +
+ ) : ( +
+ {items.map((item, index) => ( +
+
+
+ + {index + 1} +
+ +
+
+ + handleItemChange(item.tempId, 'category', e.target.value) + } + placeholder="Categoria" + required + /> +
+ + handleItemChange(item.tempId, 'description', e.target.value) + } + placeholder="Descripcion del item" + required + /> +
+
+ + + handleItemChange(item.tempId, 'acceptanceCriteria', e.target.value) + } + placeholder="Criterio de aceptacion (opcional)" + /> + +
+ + + + + +
+
+ + +
+
+ ))} +
+ )} +
+ + + + + + + + + ); +} diff --git a/web/src/components/quality/InspectionResultsForm.tsx b/web/src/components/quality/InspectionResultsForm.tsx new file mode 100644 index 0000000..ff3e986 --- /dev/null +++ b/web/src/components/quality/InspectionResultsForm.tsx @@ -0,0 +1,363 @@ +/** + * InspectionResultsForm - Formulario para capturar resultados de inspeccion + */ + +import { useState, useEffect } from 'react'; +import { Check, X, Minus, Camera, AlertTriangle, MessageSquare } from 'lucide-react'; +import type { + Inspection, + InspectionResult, + ChecklistItem, + InspectionResultStatus, + CreateInspectionResultDto, +} from '../../types/quality.types'; +import { INSPECTION_RESULT_STATUS_OPTIONS } from '../../types/quality.types'; +import { Modal, ModalFooter, TextareaField, StatusBadgeFromOptions } from '../common'; + +interface InspectionResultsFormProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (results: CreateInspectionResultDto[]) => void; + inspection: Inspection | null; + checklistItems: ChecklistItem[]; + existingResults?: InspectionResult[]; + isLoading?: boolean; +} + +interface ResultItem { + checklistItemId: string; + item: ChecklistItem; + result: InspectionResultStatus; + observations: string; + photoUrl: string; +} + +export function InspectionResultsForm({ + isOpen, + onClose, + onSubmit, + inspection, + checklistItems, + existingResults = [], + isLoading = false, +}: InspectionResultsFormProps) { + const [results, setResults] = useState([]); + const [expandedItem, setExpandedItem] = useState(null); + + useEffect(() => { + if (isOpen && checklistItems.length > 0) { + const initialResults: ResultItem[] = checklistItems + .filter((item) => item.isActive) + .sort((a, b) => a.sequenceNumber - b.sequenceNumber) + .map((item) => { + const existing = existingResults.find((r) => r.checklistItemId === item.id); + return { + checklistItemId: item.id, + item, + result: existing?.result || 'pending', + observations: existing?.observations || '', + photoUrl: existing?.photoUrl || '', + }; + }); + setResults(initialResults); + } + }, [isOpen, checklistItems, existingResults]); + + const handleResultChange = (itemId: string, result: InspectionResultStatus) => { + setResults( + results.map((r) => + r.checklistItemId === itemId ? { ...r, result } : r + ) + ); + }; + + const handleObservationsChange = (itemId: string, observations: string) => { + setResults( + results.map((r) => + r.checklistItemId === itemId ? { ...r, observations } : r + ) + ); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const data: CreateInspectionResultDto[] = results.map((r) => ({ + checklistItemId: r.checklistItemId, + result: r.result, + observations: r.observations || undefined, + photoUrl: r.photoUrl || undefined, + })); + + onSubmit(data); + }; + + // Helper function for result icons (used in summary view) + const _getResultIcon = (result: InspectionResultStatus) => { + switch (result) { + case 'passed': + return ; + case 'failed': + return ; + case 'not_applicable': + return ; + default: + return
; + } + }; + void _getResultIcon; // Suppress unused warning - reserved for future use + + const getResultButtonClasses = ( + itemResult: InspectionResultStatus, + buttonResult: InspectionResultStatus + ) => { + const isSelected = itemResult === buttonResult; + const base = 'p-2 rounded-lg transition-all border-2'; + + if (buttonResult === 'passed') { + return `${base} ${ + isSelected + ? 'bg-green-100 border-green-500 text-green-700 dark:bg-green-900/30 dark:border-green-500 dark:text-green-400' + : 'border-gray-200 text-gray-400 hover:border-green-300 hover:text-green-500 dark:border-gray-600 dark:hover:border-green-500' + }`; + } + if (buttonResult === 'failed') { + return `${base} ${ + isSelected + ? 'bg-red-100 border-red-500 text-red-700 dark:bg-red-900/30 dark:border-red-500 dark:text-red-400' + : 'border-gray-200 text-gray-400 hover:border-red-300 hover:text-red-500 dark:border-gray-600 dark:hover:border-red-500' + }`; + } + if (buttonResult === 'not_applicable') { + return `${base} ${ + isSelected + ? 'bg-gray-100 border-gray-500 text-gray-700 dark:bg-gray-700 dark:border-gray-400 dark:text-gray-300' + : 'border-gray-200 text-gray-400 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400' + }`; + } + + return base; + }; + + const pendingCount = results.filter((r) => r.result === 'pending').length; + const passedCount = results.filter((r) => r.result === 'passed').length; + const failedCount = results.filter((r) => r.result === 'failed').length; + const naCount = results.filter((r) => r.result === 'not_applicable').length; + + // Group by category + const groupedResults = results.reduce((acc, result) => { + const category = result.item.category || 'Sin categoria'; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(result); + return acc; + }, {} as Record); + + return ( + +
+ {/* Progress Summary */} +
+
+ + Progreso de la inspeccion + + + {results.length - pendingCount} / {results.length} items evaluados + +
+
+
+
+
+ + + {passedCount} aprobados + + + + {failedCount} fallidos + + + + {naCount} N/A + + + {pendingCount} pendientes + +
+
+ + {/* Results List */} +
+ {Object.entries(groupedResults).map(([category, categoryResults]) => ( +
+

+ {category} +

+
+ {categoryResults.map((result) => ( +
+
+
+ {/* Sequence */} +
+ {result.item.sequenceNumber} +
+ + {/* Content */} +
+
+
+

+ {result.item.description} +

+ {result.item.acceptanceCriteria && ( +

+ Criterio: {result.item.acceptanceCriteria} +

+ )} +
+ {result.item.isCritical && ( + + + Critico + + )} + {result.item.requiresPhoto && ( + + + Foto requerida + + )} +
+
+ + {/* Result Buttons */} +
+ + + + +
+
+ + {/* Observations (expandable) */} + {expandedItem === result.checklistItemId && ( +
+ + handleObservationsChange( + result.checklistItemId, + e.target.value + ) + } + placeholder="Agregar observaciones..." + rows={2} + /> +
+ )} + + {/* Show status badge */} + {result.result !== 'pending' && ( +
+ +
+ )} +
+
+
+
+ ))} +
+
+ ))} +
+ + + + + + + + ); +} diff --git a/web/src/components/quality/index.ts b/web/src/components/quality/index.ts new file mode 100644 index 0000000..6492860 --- /dev/null +++ b/web/src/components/quality/index.ts @@ -0,0 +1,6 @@ +/** + * Quality Components Index + */ + +export { ChecklistForm } from './ChecklistForm'; +export { InspectionResultsForm } from './InspectionResultsForm'; diff --git a/web/src/hooks/useContracts.ts b/web/src/hooks/useContracts.ts new file mode 100644 index 0000000..07466b7 --- /dev/null +++ b/web/src/hooks/useContracts.ts @@ -0,0 +1,451 @@ +/** + * useContracts Hook - Contratos, Subcontratistas, Partidas, Addendas + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import toast from 'react-hot-toast'; +import type { ApiError } from '../services/api'; +import { + contractsApi, + subcontractorsApi, + contractPartidasApi, + contractAddendumsApi, +} from '../services/contracts'; +import type { + ContractFilters, + CreateContractDto, + UpdateContractDto, + SubcontractorFilters, + CreateSubcontractorDto, + UpdateSubcontractorDto, + CreateContractPartidaDto, + UpdateContractPartidaDto, + CreateAddendumDto, + UpdateAddendumDto, +} from '../types/contracts.types'; + +// ============================================================================ +// QUERY KEYS +// ============================================================================ + +export const contractsKeys = { + // Contracts + contracts: { + all: ['contracts'] as const, + list: (filters?: ContractFilters) => [...contractsKeys.contracts.all, 'list', filters] as const, + detail: (id: string) => [...contractsKeys.contracts.all, 'detail', id] as const, + stats: () => [...contractsKeys.contracts.all, 'stats'] as const, + }, + // Subcontractors + subcontractors: { + all: ['subcontractors'] as const, + list: (filters?: SubcontractorFilters) => [...contractsKeys.subcontractors.all, 'list', filters] as const, + detail: (id: string) => [...contractsKeys.subcontractors.all, 'detail', id] as const, + }, + // Contract Partidas + partidas: { + all: ['contract-partidas'] as const, + list: (contractId: string) => [...contractsKeys.partidas.all, 'list', contractId] as const, + }, + // Contract Addendums + addendums: { + all: ['contract-addendums'] as const, + list: (contractId: string) => [...contractsKeys.addendums.all, 'list', contractId] as const, + detail: (contractId: string, addendumId: string) => [...contractsKeys.addendums.all, 'detail', contractId, addendumId] as const, + }, +}; + +// ============================================================================ +// ERROR HANDLER +// ============================================================================ + +const handleError = (error: AxiosError) => { + const message = error.response?.data?.message || 'Ha ocurrido un error'; + toast.error(message); +}; + +// ============================================================================ +// CONTRACTS HOOKS +// ============================================================================ + +export function useContracts(filters?: ContractFilters) { + return useQuery({ + queryKey: contractsKeys.contracts.list(filters), + queryFn: () => contractsApi.list(filters), + }); +} + +export function useContract(id: string) { + return useQuery({ + queryKey: contractsKeys.contracts.detail(id), + queryFn: () => contractsApi.get(id), + enabled: !!id, + }); +} + +export function useContractStats() { + return useQuery({ + queryKey: contractsKeys.contracts.stats(), + queryFn: () => contractsApi.stats(), + }); +} + +export function useCreateContract() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateContractDto) => contractsApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all }); + toast.success('Contrato creado exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateContract() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateContractDto }) => + contractsApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) }); + toast.success('Contrato actualizado'); + }, + onError: handleError, + }); +} + +export function useDeleteContract() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => contractsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all }); + toast.success('Contrato eliminado'); + }, + onError: handleError, + }); +} + +export function useSubmitContract() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => contractsApi.submit(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) }); + toast.success('Contrato enviado a revision'); + }, + onError: handleError, + }); +} + +export function useApproveContract() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => contractsApi.approve(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) }); + toast.success('Contrato aprobado'); + }, + onError: handleError, + }); +} + +export function useActivateContract() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => contractsApi.activate(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) }); + toast.success('Contrato activado'); + }, + onError: handleError, + }); +} + +export function useCompleteContract() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => contractsApi.complete(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) }); + toast.success('Contrato completado'); + }, + onError: handleError, + }); +} + +export function useTerminateContract() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + contractsApi.terminate(id, reason), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) }); + toast.success('Contrato terminado'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// SUBCONTRACTORS HOOKS +// ============================================================================ + +export function useSubcontractors(filters?: SubcontractorFilters) { + return useQuery({ + queryKey: contractsKeys.subcontractors.list(filters), + queryFn: () => subcontractorsApi.list(filters), + }); +} + +export function useSubcontractor(id: string) { + return useQuery({ + queryKey: contractsKeys.subcontractors.detail(id), + queryFn: () => subcontractorsApi.get(id), + enabled: !!id, + }); +} + +export function useCreateSubcontractor() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateSubcontractorDto) => subcontractorsApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all }); + toast.success('Subcontratista creado exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateSubcontractor() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateSubcontractorDto }) => + subcontractorsApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all }); + queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.detail(id) }); + toast.success('Subcontratista actualizado'); + }, + onError: handleError, + }); +} + +export function useDeleteSubcontractor() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => subcontractorsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all }); + toast.success('Subcontratista eliminado'); + }, + onError: handleError, + }); +} + +export function useActivateSubcontractor() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => subcontractorsApi.activate(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all }); + queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.detail(id) }); + toast.success('Subcontratista activado'); + }, + onError: handleError, + }); +} + +export function useDeactivateSubcontractor() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => subcontractorsApi.deactivate(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all }); + queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.detail(id) }); + toast.success('Subcontratista desactivado'); + }, + onError: handleError, + }); +} + +export function useBlacklistSubcontractor() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + subcontractorsApi.blacklist(id, reason), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all }); + queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.detail(id) }); + toast.success('Subcontratista agregado a lista negra'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// CONTRACT PARTIDAS HOOKS +// ============================================================================ + +export function useContractPartidas(contractId: string) { + return useQuery({ + queryKey: contractsKeys.partidas.list(contractId), + queryFn: () => contractPartidasApi.list(contractId), + enabled: !!contractId, + }); +} + +export function useCreateContractPartida() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ contractId, data }: { contractId: string; data: CreateContractPartidaDto }) => + contractPartidasApi.create(contractId, data), + onSuccess: (_, { contractId }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.partidas.list(contractId) }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(contractId) }); + toast.success('Partida agregada'); + }, + onError: handleError, + }); +} + +export function useUpdateContractPartida() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ contractId, partidaId, data }: { contractId: string; partidaId: string; data: UpdateContractPartidaDto }) => + contractPartidasApi.update(contractId, partidaId, data), + onSuccess: (_, { contractId }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.partidas.list(contractId) }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(contractId) }); + toast.success('Partida actualizada'); + }, + onError: handleError, + }); +} + +export function useDeleteContractPartida() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ contractId, partidaId }: { contractId: string; partidaId: string }) => + contractPartidasApi.delete(contractId, partidaId), + onSuccess: (_, { contractId }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.partidas.list(contractId) }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(contractId) }); + toast.success('Partida eliminada'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// CONTRACT ADDENDUMS HOOKS +// ============================================================================ + +export function useContractAddendums(contractId: string) { + return useQuery({ + queryKey: contractsKeys.addendums.list(contractId), + queryFn: () => contractAddendumsApi.list(contractId), + enabled: !!contractId, + }); +} + +export function useContractAddendum(contractId: string, addendumId: string) { + return useQuery({ + queryKey: contractsKeys.addendums.detail(contractId, addendumId), + queryFn: () => contractAddendumsApi.get(contractId, addendumId), + enabled: !!contractId && !!addendumId, + }); +} + +export function useCreateContractAddendum() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ contractId, data }: { contractId: string; data: CreateAddendumDto }) => + contractAddendumsApi.create(contractId, data), + onSuccess: (_, { contractId }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(contractId) }); + toast.success('Addenda creada'); + }, + onError: handleError, + }); +} + +export function useUpdateContractAddendum() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ contractId, addendumId, data }: { contractId: string; addendumId: string; data: UpdateAddendumDto }) => + contractAddendumsApi.update(contractId, addendumId, data), + onSuccess: (_, { contractId, addendumId }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) }); + queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.detail(contractId, addendumId) }); + toast.success('Addenda actualizada'); + }, + onError: handleError, + }); +} + +export function useDeleteContractAddendum() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ contractId, addendumId }: { contractId: string; addendumId: string }) => + contractAddendumsApi.delete(contractId, addendumId), + onSuccess: (_, { contractId }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) }); + toast.success('Addenda eliminada'); + }, + onError: handleError, + }); +} + +export function useSubmitAddendum() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ contractId, addendumId }: { contractId: string; addendumId: string }) => + contractAddendumsApi.submit(contractId, addendumId), + onSuccess: (_, { contractId, addendumId }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) }); + queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.detail(contractId, addendumId) }); + toast.success('Addenda enviada a revision'); + }, + onError: handleError, + }); +} + +export function useApproveAddendum() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ contractId, addendumId }: { contractId: string; addendumId: string }) => + contractAddendumsApi.approve(contractId, addendumId), + onSuccess: (_, { contractId, addendumId }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) }); + queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.detail(contractId, addendumId) }); + queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(contractId) }); + toast.success('Addenda aprobada'); + }, + onError: handleError, + }); +} + +export function useRejectAddendum() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ contractId, addendumId, reason }: { contractId: string; addendumId: string; reason: string }) => + contractAddendumsApi.reject(contractId, addendumId, reason), + onSuccess: (_, { contractId, addendumId }) => { + queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) }); + queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.detail(contractId, addendumId) }); + toast.success('Addenda rechazada'); + }, + onError: handleError, + }); +} diff --git a/web/src/hooks/useQuality.ts b/web/src/hooks/useQuality.ts new file mode 100644 index 0000000..0b35643 --- /dev/null +++ b/web/src/hooks/useQuality.ts @@ -0,0 +1,647 @@ +/** + * useQuality Hook - Calidad, Inspecciones, No Conformidades, Tickets Postventa + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import toast from 'react-hot-toast'; +import type { ApiError } from '../services/api'; +import { + checklistsApi, + inspectionsApi, + ticketsApi, + nonConformitiesApi, + correctiveActionsApi, +} from '../services/quality'; +import type { + ChecklistFilters, + CreateChecklistDto, + UpdateChecklistDto, + InspectionFilters, + CreateInspectionDto, + UpdateInspectionDto, + SaveInspectionResultsDto, + TicketFilters, + CreateTicketDto, + UpdateTicketDto, + AssignTicketDto, + ResolveTicketDto, + RateTicketDto, + NonConformityFilters, + CreateNonConformityDto, + UpdateNonConformityDto, + CloseNonConformityDto, + CreateCorrectiveActionDto, + UpdateCorrectiveActionDto, + CompleteCorrectiveActionDto, +} from '../types/quality.types'; + +// ============================================================================ +// QUERY KEYS +// ============================================================================ + +export const qualityKeys = { + // Checklists + checklists: { + all: ['quality', 'checklists'] as const, + list: (filters?: ChecklistFilters) => [...qualityKeys.checklists.all, 'list', filters] as const, + detail: (id: string) => [...qualityKeys.checklists.all, 'detail', id] as const, + }, + // Inspections + inspections: { + all: ['quality', 'inspections'] as const, + list: (filters?: InspectionFilters) => [...qualityKeys.inspections.all, 'list', filters] as const, + detail: (id: string) => [...qualityKeys.inspections.all, 'detail', id] as const, + results: (id: string) => [...qualityKeys.inspections.all, 'results', id] as const, + stats: (filters?: InspectionFilters) => [...qualityKeys.inspections.all, 'stats', filters] as const, + }, + // Tickets + tickets: { + all: ['quality', 'tickets'] as const, + list: (filters?: TicketFilters) => [...qualityKeys.tickets.all, 'list', filters] as const, + detail: (id: string) => [...qualityKeys.tickets.all, 'detail', id] as const, + stats: (filters?: TicketFilters) => [...qualityKeys.tickets.all, 'stats', filters] as const, + }, + // Non-Conformities + nonConformities: { + all: ['quality', 'non-conformities'] as const, + list: (filters?: NonConformityFilters) => [...qualityKeys.nonConformities.all, 'list', filters] as const, + detail: (id: string) => [...qualityKeys.nonConformities.all, 'detail', id] as const, + stats: (filters?: NonConformityFilters) => [...qualityKeys.nonConformities.all, 'stats', filters] as const, + actions: (ncId: string) => [...qualityKeys.nonConformities.all, 'actions', ncId] as const, + }, +}; + +// ============================================================================ +// ERROR HANDLER +// ============================================================================ + +const handleError = (error: AxiosError) => { + const message = error.response?.data?.message || 'Ha ocurrido un error'; + toast.error(message); +}; + +// ============================================================================ +// CHECKLISTS HOOKS +// ============================================================================ + +export function useChecklists(filters?: ChecklistFilters) { + return useQuery({ + queryKey: qualityKeys.checklists.list(filters), + queryFn: () => checklistsApi.list(filters), + }); +} + +export function useChecklist(id: string) { + return useQuery({ + queryKey: qualityKeys.checklists.detail(id), + queryFn: () => checklistsApi.get(id), + enabled: !!id, + }); +} + +export function useCreateChecklist() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateChecklistDto) => checklistsApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qualityKeys.checklists.all }); + toast.success('Checklist creado exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateChecklist() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateChecklistDto }) => + checklistsApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.checklists.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.checklists.detail(id) }); + toast.success('Checklist actualizado'); + }, + onError: handleError, + }); +} + +export function useDeleteChecklist() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => checklistsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qualityKeys.checklists.all }); + toast.success('Checklist eliminado'); + }, + onError: handleError, + }); +} + +export function useDuplicateChecklist() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => checklistsApi.duplicate(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qualityKeys.checklists.all }); + toast.success('Checklist duplicado'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// INSPECTIONS HOOKS +// ============================================================================ + +export function useInspections(filters?: InspectionFilters) { + return useQuery({ + queryKey: qualityKeys.inspections.list(filters), + queryFn: () => inspectionsApi.list(filters), + }); +} + +export function useInspection(id: string) { + return useQuery({ + queryKey: qualityKeys.inspections.detail(id), + queryFn: () => inspectionsApi.get(id), + enabled: !!id, + }); +} + +export function useInspectionResults(inspectionId: string) { + return useQuery({ + queryKey: qualityKeys.inspections.results(inspectionId), + queryFn: () => inspectionsApi.getResults(inspectionId), + enabled: !!inspectionId, + }); +} + +export function useInspectionStats(filters?: InspectionFilters) { + return useQuery({ + queryKey: qualityKeys.inspections.stats(filters), + queryFn: () => inspectionsApi.stats(filters), + }); +} + +export function useCreateInspection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateInspectionDto) => inspectionsApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all }); + toast.success('Inspeccion creada exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateInspection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateInspectionDto }) => + inspectionsApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(id) }); + toast.success('Inspeccion actualizada'); + }, + onError: handleError, + }); +} + +export function useDeleteInspection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => inspectionsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all }); + toast.success('Inspeccion eliminada'); + }, + onError: handleError, + }); +} + +export function useSaveInspectionResults() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ inspectionId, data }: { inspectionId: string; data: SaveInspectionResultsDto }) => + inspectionsApi.saveResults(inspectionId, data), + onSuccess: (_, { inspectionId }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.results(inspectionId) }); + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(inspectionId) }); + toast.success('Resultados guardados'); + }, + onError: handleError, + }); +} + +export function useStartInspection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => inspectionsApi.start(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(id) }); + toast.success('Inspeccion iniciada'); + }, + onError: handleError, + }); +} + +export function useCompleteInspection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => inspectionsApi.complete(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(id) }); + toast.success('Inspeccion completada'); + }, + onError: handleError, + }); +} + +export function useApproveInspection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => inspectionsApi.approve(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(id) }); + toast.success('Inspeccion aprobada'); + }, + onError: handleError, + }); +} + +export function useRejectInspection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + inspectionsApi.reject(id, reason), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(id) }); + toast.success('Inspeccion rechazada'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// TICKETS HOOKS +// ============================================================================ + +export function useTickets(filters?: TicketFilters) { + return useQuery({ + queryKey: qualityKeys.tickets.list(filters), + queryFn: () => ticketsApi.list(filters), + }); +} + +export function useTicket(id: string) { + return useQuery({ + queryKey: qualityKeys.tickets.detail(id), + queryFn: () => ticketsApi.get(id), + enabled: !!id, + }); +} + +export function useTicketStats(filters?: TicketFilters) { + return useQuery({ + queryKey: qualityKeys.tickets.stats(filters), + queryFn: () => ticketsApi.stats(filters), + }); +} + +export function useCreateTicket() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateTicketDto) => ticketsApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all }); + toast.success('Ticket creado exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateTicket() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateTicketDto }) => + ticketsApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) }); + toast.success('Ticket actualizado'); + }, + onError: handleError, + }); +} + +export function useDeleteTicket() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => ticketsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all }); + toast.success('Ticket eliminado'); + }, + onError: handleError, + }); +} + +export function useAssignTicket() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: AssignTicketDto }) => + ticketsApi.assign(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) }); + toast.success('Ticket asignado'); + }, + onError: handleError, + }); +} + +export function useStartTicketWork() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => ticketsApi.startWork(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) }); + toast.success('Trabajo iniciado'); + }, + onError: handleError, + }); +} + +export function useResolveTicket() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: ResolveTicketDto }) => + ticketsApi.resolve(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) }); + toast.success('Ticket resuelto'); + }, + onError: handleError, + }); +} + +export function useCloseTicket() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => ticketsApi.close(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) }); + toast.success('Ticket cerrado'); + }, + onError: handleError, + }); +} + +export function useCancelTicket() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, reason }: { id: string; reason?: string }) => + ticketsApi.cancel(id, reason), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) }); + toast.success('Ticket cancelado'); + }, + onError: handleError, + }); +} + +export function useRateTicket() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: RateTicketDto }) => + ticketsApi.rate(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) }); + toast.success('Calificacion registrada'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// NON-CONFORMITIES HOOKS +// ============================================================================ + +export function useNonConformities(filters?: NonConformityFilters) { + return useQuery({ + queryKey: qualityKeys.nonConformities.list(filters), + queryFn: () => nonConformitiesApi.list(filters), + }); +} + +export function useNonConformity(id: string) { + return useQuery({ + queryKey: qualityKeys.nonConformities.detail(id), + queryFn: () => nonConformitiesApi.get(id), + enabled: !!id, + }); +} + +export function useNonConformityStats(filters?: NonConformityFilters) { + return useQuery({ + queryKey: qualityKeys.nonConformities.stats(filters), + queryFn: () => nonConformitiesApi.stats(filters), + }); +} + +export function useCreateNonConformity() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateNonConformityDto) => nonConformitiesApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all }); + toast.success('No conformidad creada exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateNonConformity() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateNonConformityDto }) => + nonConformitiesApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(id) }); + toast.success('No conformidad actualizada'); + }, + onError: handleError, + }); +} + +export function useDeleteNonConformity() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => nonConformitiesApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all }); + toast.success('No conformidad eliminada'); + }, + onError: handleError, + }); +} + +export function useStartNonConformityProgress() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => nonConformitiesApi.startProgress(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(id) }); + toast.success('No conformidad en progreso'); + }, + onError: handleError, + }); +} + +export function useCloseNonConformity() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: CloseNonConformityDto }) => + nonConformitiesApi.close(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(id) }); + toast.success('No conformidad cerrada'); + }, + onError: handleError, + }); +} + +export function useVerifyNonConformity() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => nonConformitiesApi.verify(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(id) }); + toast.success('No conformidad verificada'); + }, + onError: handleError, + }); +} + +export function useReopenNonConformity() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + nonConformitiesApi.reopen(id, reason), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all }); + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(id) }); + toast.success('No conformidad reabierta'); + }, + onError: handleError, + }); +} + +// ============================================================================ +// CORRECTIVE ACTIONS HOOKS +// ============================================================================ + +export function useCorrectiveActions(nonConformityId: string) { + return useQuery({ + queryKey: qualityKeys.nonConformities.actions(nonConformityId), + queryFn: () => correctiveActionsApi.list(nonConformityId), + enabled: !!nonConformityId, + }); +} + +export function useCreateCorrectiveAction() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ ncId, data }: { ncId: string; data: CreateCorrectiveActionDto }) => + correctiveActionsApi.create(ncId, data), + onSuccess: (_, { ncId }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) }); + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(ncId) }); + toast.success('Accion correctiva creada'); + }, + onError: handleError, + }); +} + +export function useUpdateCorrectiveAction() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ ncId, actionId, data }: { ncId: string; actionId: string; data: UpdateCorrectiveActionDto }) => + correctiveActionsApi.update(ncId, actionId, data), + onSuccess: (_, { ncId }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) }); + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(ncId) }); + toast.success('Accion correctiva actualizada'); + }, + onError: handleError, + }); +} + +export function useDeleteCorrectiveAction() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ ncId, actionId }: { ncId: string; actionId: string }) => + correctiveActionsApi.delete(ncId, actionId), + onSuccess: (_, { ncId }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) }); + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(ncId) }); + toast.success('Accion correctiva eliminada'); + }, + onError: handleError, + }); +} + +export function useStartCorrectiveAction() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ ncId, actionId }: { ncId: string; actionId: string }) => + correctiveActionsApi.start(ncId, actionId), + onSuccess: (_, { ncId }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) }); + toast.success('Accion iniciada'); + }, + onError: handleError, + }); +} + +export function useCompleteCorrectiveAction() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ ncId, actionId, data }: { ncId: string; actionId: string; data: CompleteCorrectiveActionDto }) => + correctiveActionsApi.complete(ncId, actionId, data), + onSuccess: (_, { ncId }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) }); + toast.success('Accion completada'); + }, + onError: handleError, + }); +} + +export function useVerifyCorrectiveAction() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ ncId, actionId, effective }: { ncId: string; actionId: string; effective: boolean }) => + correctiveActionsApi.verify(ncId, actionId, effective), + onSuccess: (_, { ncId }) => { + queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) }); + toast.success('Accion verificada'); + }, + onError: handleError, + }); +} diff --git a/web/src/pages/admin/calidad/ChecklistsPage.tsx b/web/src/pages/admin/calidad/ChecklistsPage.tsx new file mode 100644 index 0000000..715c717 --- /dev/null +++ b/web/src/pages/admin/calidad/ChecklistsPage.tsx @@ -0,0 +1,312 @@ +/** + * ChecklistsPage - Lista de plantillas de checklist + */ + +import { useState } from 'react'; +import { + Plus, + Eye, + Pencil, + Trash2, + Copy, + ClipboardList, + CheckCircle2, + AlertTriangle, +} from 'lucide-react'; +import { + useChecklists, + useDeleteChecklist, + useCreateChecklist, + useUpdateChecklist, + useDuplicateChecklist, +} from '../../../hooks/useQuality'; +import type { + Checklist, + ChecklistStage, + CreateChecklistDto, +} from '../../../types/quality.types'; +import { CHECKLIST_STAGE_OPTIONS } from '../../../types/quality.types'; +import { + PageHeader, + DataTable, + SearchInput, + SelectField, + StatusBadgeFromOptions, + ConfirmDialog, + LoadingOverlay, +} from '../../../components/common'; +import type { DataTableColumn } from '../../../components/common'; +import { ChecklistForm } from '../../../components/quality'; + +export function ChecklistsPage() { + const [search, setSearch] = useState(''); + const [stageFilter, setStageFilter] = useState(''); + const [showForm, setShowForm] = useState(false); + const [editingChecklist, setEditingChecklist] = useState(null); + const [deleteId, setDeleteId] = useState(null); + const [duplicateId, setDuplicateId] = useState(null); + + const { data, isLoading, error } = useChecklists({ + stage: stageFilter || undefined, + }); + + const createMutation = useCreateChecklist(); + const updateMutation = useUpdateChecklist(); + const deleteMutation = useDeleteChecklist(); + const duplicateMutation = useDuplicateChecklist(); + + const handleCreate = () => { + setEditingChecklist(null); + setShowForm(true); + }; + + const handleEdit = (checklist: Checklist) => { + setEditingChecklist(checklist); + setShowForm(true); + }; + + const handleSubmit = async (data: CreateChecklistDto) => { + if (editingChecklist) { + await updateMutation.mutateAsync({ id: editingChecklist.id, data }); + } else { + await createMutation.mutateAsync(data); + } + setShowForm(false); + setEditingChecklist(null); + }; + + const handleDelete = async () => { + if (deleteId) { + await deleteMutation.mutateAsync(deleteId); + setDeleteId(null); + } + }; + + const handleDuplicate = async () => { + if (duplicateId) { + await duplicateMutation.mutateAsync(duplicateId); + setDuplicateId(null); + } + }; + + // Filter by search + const items = (data?.items || []).filter( + (item) => + !search || + item.code.toLowerCase().includes(search.toLowerCase()) || + item.name.toLowerCase().includes(search.toLowerCase()) + ); + + const columns: DataTableColumn[] = [ + { + key: 'code', + header: 'Codigo', + render: (item) => ( + + {item.code} + + ), + }, + { + key: 'name', + header: 'Nombre', + render: (item) => ( +
+

{item.name}

+ {item.description && ( +

+ {item.description} +

+ )} +
+ ), + }, + { + key: 'stage', + header: 'Etapa', + render: (item) => ( + + ), + }, + { + key: 'items', + header: 'Items', + align: 'center', + render: (item) => { + const totalItems = item.items?.length || 0; + const criticalItems = item.items?.filter((i) => i.isCritical).length || 0; + return ( +
+ {totalItems} + {criticalItems > 0 && ( + + + {criticalItems} + + )} +
+ ); + }, + }, + { + key: 'status', + header: 'Estado', + render: (item) => ( + + {item.isActive ? ( + <> + + Activo + + ) : ( + 'Inactivo' + )} + + ), + }, + { + key: 'version', + header: 'Version', + align: 'center', + render: (item) => ( + v{item.version} + ), + }, + { + key: 'actions', + header: 'Acciones', + align: 'right', + render: (item) => ( +
+ + + + +
+ ), + }, + ]; + + if (isLoading) { + return ; + } + + return ( +
+ + + Nuevo Checklist + + } + /> + + {/* Filters */} +
+
+ + ({ value: o.value, label: o.label })), + ]} + value={stageFilter} + onChange={(e) => setStageFilter(e.target.value as ChecklistStage | '')} + /> +
+
+ + , + title: 'No hay checklists', + description: 'Crea tu primer checklist para comenzar.', + }} + /> + + {/* Create/Edit Form */} + { + setShowForm(false); + setEditingChecklist(null); + }} + onSubmit={handleSubmit} + initialData={editingChecklist} + isLoading={createMutation.isPending || updateMutation.isPending} + /> + + {/* Delete Confirmation */} + setDeleteId(null)} + onConfirm={handleDelete} + title="Eliminar Checklist" + message="Esta seguro de eliminar este checklist? Esta accion no se puede deshacer." + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteMutation.isPending} + /> + + {/* Duplicate Confirmation */} + setDuplicateId(null)} + onConfirm={handleDuplicate} + title="Duplicar Checklist" + message="Se creara una copia de este checklist con todos sus items." + confirmLabel="Duplicar" + variant="info" + isLoading={duplicateMutation.isPending} + /> +
+ ); +} diff --git a/web/src/pages/admin/calidad/InspeccionesPage.tsx b/web/src/pages/admin/calidad/InspeccionesPage.tsx new file mode 100644 index 0000000..354f997 --- /dev/null +++ b/web/src/pages/admin/calidad/InspeccionesPage.tsx @@ -0,0 +1,514 @@ +/** + * InspeccionesPage - Lista de inspecciones de calidad + */ + +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + Plus, + Eye, + Trash2, + ClipboardCheck, + Play, + CheckCircle, + XCircle, + User, + MapPin, + Calendar, +} from 'lucide-react'; +import { + useInspections, + useInspectionStats, + useDeleteInspection, + useCreateInspection, + useStartInspection, +} from '../../../hooks/useQuality'; +import type { + Inspection, + InspectionStatus, + CreateInspectionDto, +} from '../../../types/quality.types'; +import { INSPECTION_STATUS_OPTIONS } from '../../../types/quality.types'; +import { + PageHeader, + DataTable, + SearchInput, + SelectField, + StatusBadgeFromOptions, + ConfirmDialog, + LoadingOverlay, + Modal, + ModalFooter, + FormGroup, + TextInput, + TextareaField, +} from '../../../components/common'; +import type { DataTableColumn } from '../../../components/common'; + +export function InspeccionesPage() { + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [showCreateForm, setShowCreateForm] = useState(false); + const [deleteId, setDeleteId] = useState(null); + const [startId, setStartId] = useState(null); + + // Create form state + const [createForm, setCreateForm] = useState({ + checklistId: '', + loteId: '', + inspectorId: '', + inspectionDate: new Date().toISOString().split('T')[0], + notes: '', + }); + + const { data, isLoading, error } = useInspections({ + status: statusFilter || undefined, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + }); + + const { data: stats } = useInspectionStats(); + + const createMutation = useCreateInspection(); + const deleteMutation = useDeleteInspection(); + const startMutation = useStartInspection(); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + const data: CreateInspectionDto = { + checklistId: createForm.checklistId, + loteId: createForm.loteId, + inspectorId: createForm.inspectorId, + inspectionDate: createForm.inspectionDate, + notes: createForm.notes || undefined, + }; + await createMutation.mutateAsync(data); + setShowCreateForm(false); + setCreateForm({ + checklistId: '', + loteId: '', + inspectorId: '', + inspectionDate: new Date().toISOString().split('T')[0], + notes: '', + }); + }; + + const handleDelete = async () => { + if (deleteId) { + await deleteMutation.mutateAsync(deleteId); + setDeleteId(null); + } + }; + + const handleStart = async () => { + if (startId) { + await startMutation.mutateAsync(startId); + setStartId(null); + } + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('es-MX', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + }; + + // Filter by search + const items = (data?.items || []).filter( + (item) => + !search || + item.inspectionNumber.toLowerCase().includes(search.toLowerCase()) + ); + + const columns: DataTableColumn[] = [ + { + key: 'number', + header: 'Inspeccion', + render: (item) => ( +
+ + {item.inspectionNumber} + +

+ + {formatDate(item.inspectionDate)} +

+
+ ), + }, + { + key: 'checklist', + header: 'Checklist', + render: (item) => ( + + {item.checklist?.name || 'N/A'} + + ), + }, + { + key: 'lote', + header: 'Lote', + render: (item) => ( + + + {item.loteId.substring(0, 8)}... + + ), + }, + { + key: 'inspector', + header: 'Inspector', + render: (item) => ( + + + {item.inspectorId.substring(0, 8)}... + + ), + }, + { + key: 'progress', + header: 'Progreso', + render: (item) => { + const total = item.totalItems || 1; + const passed = item.passedItems || 0; + const failed = item.failedItems || 0; + const evaluated = passed + failed; + const percent = Math.round((evaluated / total) * 100); + + return ( +
+
+ {evaluated}/{total} + {percent}% +
+
+
+
+
+ ); + }, + }, + { + key: 'passRate', + header: 'Tasa Aprobacion', + align: 'center', + render: (item) => { + const rate = item.passRate || 0; + const colorClass = + rate >= 90 + ? 'text-green-600' + : rate >= 70 + ? 'text-yellow-600' + : 'text-red-600'; + + return ( + + {rate.toFixed(1)}% + + ); + }, + }, + { + key: 'status', + header: 'Estado', + render: (item) => ( + + ), + }, + { + key: 'actions', + header: 'Acciones', + align: 'right', + render: (item) => ( +
+ + + + + {item.status === 'pending' && ( + + )} + + {item.status === 'pending' && ( + + )} +
+ ), + }, + ]; + + if (isLoading) { + return ; + } + + return ( +
+ setShowCreateForm(true)} + className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" + > + + Nueva Inspeccion + + } + /> + + {/* Stats Cards */} + {stats && ( +
+ + + + + } + /> + } + /> +
+ )} + + {/* Filters */} +
+
+ + ({ + value: o.value, + label: o.label, + })), + ]} + value={statusFilter} + onChange={(e) => setStatusFilter(e.target.value as InspectionStatus | '')} + /> +
+ setDateFrom(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + placeholder="Desde" + /> + setDateTo(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + placeholder="Hasta" + /> +
+
+
+ + , + title: 'No hay inspecciones', + description: 'Crea tu primera inspeccion para comenzar.', + }} + /> + + {/* Create Form Modal */} + setShowCreateForm(false)} + title="Nueva Inspeccion" + > +
+
+ + + setCreateForm({ ...createForm, checklistId: e.target.value }) + } + placeholder="ID del checklist" + required + /> + + + + + setCreateForm({ ...createForm, loteId: e.target.value }) + } + placeholder="ID del lote" + required + /> + + + + + setCreateForm({ ...createForm, inspectorId: e.target.value }) + } + placeholder="ID del inspector" + required + /> + + + + + setCreateForm({ ...createForm, inspectionDate: e.target.value }) + } + required + /> + + + + + setCreateForm({ ...createForm, notes: e.target.value }) + } + placeholder="Notas adicionales..." + rows={2} + /> + +
+ + + + + +
+
+ + {/* Delete Confirmation */} + setDeleteId(null)} + onConfirm={handleDelete} + title="Eliminar Inspeccion" + message="Esta seguro de eliminar esta inspeccion? Esta accion no se puede deshacer." + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteMutation.isPending} + /> + + {/* Start Confirmation */} + setStartId(null)} + onConfirm={handleStart} + title="Iniciar Inspeccion" + message="Desea iniciar esta inspeccion? El estado cambiara a 'En Progreso'." + confirmLabel="Iniciar" + variant="info" + isLoading={startMutation.isPending} + /> +
+ ); +} + +// ============================================================================ +// STATS CARD COMPONENT +// ============================================================================ + +interface StatsCardProps { + label: string; + value: number; + color: 'blue' | 'green' | 'yellow' | 'red' | 'gray'; + icon?: React.ReactNode; +} + +function StatsCard({ label, value, color, icon }: StatsCardProps) { + const colorClasses = { + blue: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + green: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/30 dark:text-green-400', + yellow: 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', + red: 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-400', + gray: 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300', + }; + + return ( +
+
+

{label}

+ {icon} +
+

{value}

+
+ ); +} diff --git a/web/src/pages/admin/calidad/NoConformidadesPage.tsx b/web/src/pages/admin/calidad/NoConformidadesPage.tsx new file mode 100644 index 0000000..2e8c677 --- /dev/null +++ b/web/src/pages/admin/calidad/NoConformidadesPage.tsx @@ -0,0 +1,414 @@ +/** + * NoConformidadesPage - Lista de no conformidades + */ + +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Plus, + Eye, + Trash2, + AlertOctagon, + Play, + CheckCircle, +} from 'lucide-react'; +import { + useNonConformities, + useDeleteNonConformity, + useNonConformityStats, + useStartNonConformityProgress, + useCloseNonConformity, + useVerifyNonConformity, +} from '../../../hooks/useQuality'; +import type { + NCSeverity, + NCStatus, + NonConformityFilters, +} from '../../../types/quality.types'; +import { + NC_SEVERITY_OPTIONS, + NC_STATUS_OPTIONS, +} from '../../../types/quality.types'; +import { + PageHeader, + PageHeaderAction, + SearchInput, + SelectField, + StatusBadgeFromOptions, + ConfirmDialog, + LoadingOverlay, + EmptyState, + Pagination, + StatsCard, + Modal, + ModalFooter, + TextareaField, +} from '../../../components/common'; + +export function NoConformidadesPage() { + const navigate = useNavigate(); + const [search, setSearch] = useState(''); + const [severityFilter, setSeverityFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [page, setPage] = useState(1); + const [deleteId, setDeleteId] = useState(null); + const [closeId, setCloseId] = useState(null); + const [closeNotes, setCloseNotes] = useState(''); + + const filters: NonConformityFilters = { + search: search || undefined, + severity: severityFilter || undefined, + status: statusFilter || undefined, + page, + limit: 10, + }; + + const { data, isLoading, error } = useNonConformities(filters); + const { data: stats } = useNonConformityStats(); + const deleteMutation = useDeleteNonConformity(); + const startProgressMutation = useStartNonConformityProgress(); + const closeMutation = useCloseNonConformity(); + const verifyMutation = useVerifyNonConformity(); + + const items = data?.items || []; + const total = data?.total || 0; + const totalPages = Math.ceil(total / 10); + + const handleDelete = async () => { + if (deleteId) { + await deleteMutation.mutateAsync(deleteId); + setDeleteId(null); + } + }; + + const handleStartProgress = async (id: string) => { + await startProgressMutation.mutateAsync(id); + }; + + const handleClose = async () => { + if (closeId && closeNotes) { + await closeMutation.mutateAsync({ id: closeId, data: { closureNotes: closeNotes } }); + setCloseId(null); + setCloseNotes(''); + } + }; + + const handleVerify = async (id: string) => { + await verifyMutation.mutateAsync(id); + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + + return ( +
+ navigate('/admin/calidad/no-conformidades/nueva')}> + + Nueva NC + + } + /> + + {/* Stats */} + {stats && ( +
+ } + color="blue" + /> + } + color="red" + /> + } + color="yellow" + /> + } + color="green" + /> + } + color="red" + /> +
+ )} + + {/* Filters */} +
+
+ + ({ value: o.value, label: o.label })), + ]} + value={severityFilter} + onChange={(e) => setSeverityFilter(e.target.value as NCSeverity | '')} + /> + ({ value: o.value, label: o.label })), + ]} + value={statusFilter} + onChange={(e) => setStatusFilter(e.target.value as NCStatus | '')} + /> +
+
+ + {/* Table */} +
+ {items.length === 0 ? ( + } + title="No hay no conformidades" + description="No se encontraron no conformidades con los filtros seleccionados." + /> + ) : ( + <> +
+ + + + + + + + + + + + + + {items.map((nc) => ( + + + + + + + + + + ))} + +
+ NC + + Severidad + + Estado + + Fecha Deteccion + + Fecha Limite + + Acciones Corr. + + Acciones +
+
+ +
+
+ {nc.ncNumber} +
+
+ {nc.description} +
+
+
+
+ + + + + {formatDate(nc.detectionDate)} + + {nc.dueDate ? ( + + {formatDate(nc.dueDate)} + + ) : '-'} + + + {nc.correctiveActions?.length || 0} + + +
+ + + {nc.status === 'open' && ( + + )} + + {nc.status === 'in_progress' && ( + + )} + + {nc.status === 'closed' && ( + + )} + + {nc.status === 'open' && ( + + )} +
+
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + )} +
+ + {/* Delete Confirmation */} + setDeleteId(null)} + onConfirm={handleDelete} + title="Eliminar No Conformidad" + message="Esta seguro de eliminar esta no conformidad? Esta accion no se puede deshacer." + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteMutation.isPending} + /> + + {/* Close Modal */} + {closeId && ( + { + setCloseId(null); + setCloseNotes(''); + }} + title="Cerrar No Conformidad" + size="md" + footer={ + + + + + } + > + setCloseNotes(e.target.value)} + placeholder="Describa como se resolvio la no conformidad..." + rows={4} + /> + + )} +
+ ); +} diff --git a/web/src/pages/admin/calidad/TicketsPage.tsx b/web/src/pages/admin/calidad/TicketsPage.tsx new file mode 100644 index 0000000..96ef9f8 --- /dev/null +++ b/web/src/pages/admin/calidad/TicketsPage.tsx @@ -0,0 +1,327 @@ +/** + * TicketsPage - Lista de tickets de postventa + */ + +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Plus, + Eye, + Trash2, + Ticket, + Clock, + AlertTriangle, + User, +} from 'lucide-react'; +import { + useTickets, + useDeleteTicket, + useTicketStats, +} from '../../../hooks/useQuality'; +import type { + TicketCategory, + TicketPriority, + TicketStatus, + TicketFilters, +} from '../../../types/quality.types'; +import { + TICKET_CATEGORY_OPTIONS, + TICKET_PRIORITY_OPTIONS, + TICKET_STATUS_OPTIONS, +} from '../../../types/quality.types'; +import { + PageHeader, + PageHeaderAction, + SearchInput, + SelectField, + StatusBadgeFromOptions, + ConfirmDialog, + LoadingOverlay, + EmptyState, + Pagination, + StatsCard, +} from '../../../components/common'; + +export function TicketsPage() { + const navigate = useNavigate(); + const [search, setSearch] = useState(''); + const [categoryFilter, setCategoryFilter] = useState(''); + const [priorityFilter, setPriorityFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [page, setPage] = useState(1); + const [deleteId, setDeleteId] = useState(null); + + const filters: TicketFilters = { + search: search || undefined, + category: categoryFilter || undefined, + priority: priorityFilter || undefined, + status: statusFilter || undefined, + page, + limit: 10, + }; + + const { data, isLoading, error } = useTickets(filters); + const { data: stats } = useTicketStats(); + const deleteMutation = useDeleteTicket(); + + const tickets = data?.items || []; + const total = data?.total || 0; + const totalPages = Math.ceil(total / 10); + + const handleDelete = async () => { + if (deleteId) { + await deleteMutation.mutateAsync(deleteId); + setDeleteId(null); + } + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + + return ( +
+ navigate('/admin/calidad/tickets/nuevo')}> + + Nuevo Ticket + + } + /> + + {/* Stats */} + {stats && ( +
+ } + color="blue" + /> + } + color="yellow" + /> + } + color="blue" + /> + } + color="green" + /> + } + color="red" + /> + } + color="purple" + /> +
+ )} + + {/* Filters */} +
+
+ + ({ value: o.value, label: o.label })), + ]} + value={categoryFilter} + onChange={(e) => setCategoryFilter(e.target.value as TicketCategory | '')} + /> + ({ value: o.value, label: o.label })), + ]} + value={priorityFilter} + onChange={(e) => setPriorityFilter(e.target.value as TicketPriority | '')} + /> + ({ value: o.value, label: o.label })), + ]} + value={statusFilter} + onChange={(e) => setStatusFilter(e.target.value as TicketStatus | '')} + /> +
+
+ + {/* Table */} +
+ {tickets.length === 0 ? ( + } + title="No hay tickets" + description="No se encontraron tickets con los filtros seleccionados." + /> + ) : ( + <> +
+ + + + + + + + + + + + + + {tickets.map((ticket) => ( + + + + + + + + + + ))} + +
+ Ticket + + Categoria + + Prioridad + + Estado + + SLA + + Creado + + Acciones +
+
+ +
+
+ {ticket.ticketNumber} +
+
+ {ticket.title} +
+
+
+
+ + + + + + + {ticket.slaBreached ? ( + + + Incumplido + + ) : ( + + + {ticket.slaHours}h + + )} + + {formatDate(ticket.createdAt)} + +
+ + +
+
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + )} +
+ + {/* Delete Confirmation */} + setDeleteId(null)} + onConfirm={handleDelete} + title="Eliminar Ticket" + message="Esta seguro de eliminar este ticket? Esta accion no se puede deshacer." + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteMutation.isPending} + /> +
+ ); +} diff --git a/web/src/pages/admin/contratos/ContratoDetailPage.tsx b/web/src/pages/admin/contratos/ContratoDetailPage.tsx new file mode 100644 index 0000000..bc2eda7 --- /dev/null +++ b/web/src/pages/admin/contratos/ContratoDetailPage.tsx @@ -0,0 +1,852 @@ +/** + * ContratoDetailPage - Detalle del contrato con tabs + */ + +import { useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + ArrowLeft, + FileText, + Building2, + Calendar, + DollarSign, + Pencil, + Send, + CheckCircle, + PlayCircle, + XCircle, + ListTodo, + FilePlus, + Clock, + Plus, + Trash2, +} from 'lucide-react'; +import { + useContract, + useContractPartidas, + useContractAddendums, + useSubmitContract, + useApproveContract, + useActivateContract, + useCompleteContract, + useTerminateContract, + useDeleteContractPartida, + useDeleteContractAddendum, +} from '../../../hooks/useContracts'; +import type { Contract, ContractPartida, ContractAddendum } from '../../../types/contracts.types'; +import { + CONTRACT_TYPE_OPTIONS, + CONTRACT_STATUS_OPTIONS, + ADDENDUM_TYPE_OPTIONS, + ADDENDUM_STATUS_OPTIONS, +} from '../../../types/contracts.types'; +import { + StatusBadgeFromOptions, + LoadingOverlay, + EmptyState, + ConfirmDialog, + Modal, + ModalFooter, + TextareaField, +} from '../../../components/common'; +import { ContractForm } from '../../../components/contracts/ContractForm'; +import { AddendaModal } from '../../../components/contracts/AddendaModal'; +import { PartidaModal } from '../../../components/contracts/PartidaModal'; + +type TabType = 'info' | 'partidas' | 'addendas'; + +export function ContratoDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState('info'); + const [showEditModal, setShowEditModal] = useState(false); + const [showAddendaModal, setShowAddendaModal] = useState(false); + const [showPartidaModal, setShowPartidaModal] = useState(false); + const [editingAddenda, setEditingAddenda] = useState(null); + const [editingPartida, setEditingPartida] = useState(null); + const [deletePartidaId, setDeletePartidaId] = useState(null); + const [deleteAddendaId, setDeleteAddendaId] = useState(null); + const [showTerminateModal, setShowTerminateModal] = useState(false); + const [terminateReason, setTerminateReason] = useState(''); + + const { data: contract, isLoading, error } = useContract(id || ''); + const { data: partidas, isLoading: loadingPartidas } = useContractPartidas(id || ''); + const { data: addendums, isLoading: loadingAddendums } = useContractAddendums(id || ''); + + const submitMutation = useSubmitContract(); + const approveMutation = useApproveContract(); + const activateMutation = useActivateContract(); + const completeMutation = useCompleteContract(); + const terminateMutation = useTerminateContract(); + const deletePartidaMutation = useDeleteContractPartida(); + const deleteAddendaMutation = useDeleteContractAddendum(); + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + const handleSubmit = async () => { + if (id) await submitMutation.mutateAsync(id); + }; + + const handleApprove = async () => { + if (id) await approveMutation.mutateAsync(id); + }; + + const handleActivate = async () => { + if (id) await activateMutation.mutateAsync(id); + }; + + const handleComplete = async () => { + if (id) await completeMutation.mutateAsync(id); + }; + + const handleTerminate = async () => { + if (id && terminateReason) { + await terminateMutation.mutateAsync({ id, reason: terminateReason }); + setShowTerminateModal(false); + setTerminateReason(''); + } + }; + + const handleDeletePartida = async () => { + if (id && deletePartidaId) { + await deletePartidaMutation.mutateAsync({ contractId: id, partidaId: deletePartidaId }); + setDeletePartidaId(null); + } + }; + + const handleDeleteAddenda = async () => { + if (id && deleteAddendaId) { + await deleteAddendaMutation.mutateAsync({ contractId: id, addendumId: deleteAddendaId }); + setDeleteAddendaId(null); + } + }; + + if (isLoading) { + return ; + } + + if (error || !contract) { + return ( + + ); + } + + const tabs = [ + { id: 'info', label: 'Informacion General', icon: FileText }, + { id: 'partidas', label: 'Partidas', icon: ListTodo, count: partidas?.length }, + { id: 'addendas', label: 'Addendas', icon: FilePlus, count: addendums?.length }, + ]; + + const canSubmit = contract.status === 'draft'; + const canApprove = contract.status === 'review'; + const canActivate = contract.status === 'approved'; + const canComplete = contract.status === 'active'; + const canTerminate = ['active', 'approved'].includes(contract.status); + + return ( +
+ {/* Header */} +
+ + +
+
+
+ +
+
+

+ {contract.contractNumber} +

+

{contract.name}

+
+ + +
+
+
+ +
+ + + {canSubmit && ( + + )} + + {canApprove && ( + + )} + + {canActivate && ( + + )} + + {canComplete && ( + + )} + + {canTerminate && ( + + )} +
+
+
+ + {/* Stats Cards */} +
+
+
+
+

Monto Contrato

+

+ {formatCurrency(contract.contractAmount)} +

+
+ +
+
+
+
+
+

Facturado

+

+ {formatCurrency(contract.invoicedAmount)} +

+
+ +
+
+
+
+
+

Pagado

+

+ {formatCurrency(contract.paidAmount)} +

+
+ +
+
+
+
+
+

Avance

+

+ {contract.progressPercentage}% +

+
+
+ + + + +
+
+
+
+ + {/* Tabs */} +
+
+ +
+ +
+ {activeTab === 'info' && } + {activeTab === 'partidas' && ( + { + setEditingPartida(null); + setShowPartidaModal(true); + }} + onEdit={(p) => { + setEditingPartida(p); + setShowPartidaModal(true); + }} + onDelete={setDeletePartidaId} + /> + )} + {activeTab === 'addendas' && ( + { + setEditingAddenda(null); + setShowAddendaModal(true); + }} + onEdit={(a) => { + setEditingAddenda(a); + setShowAddendaModal(true); + }} + onDelete={setDeleteAddendaId} + /> + )} +
+
+ + {/* Modals */} + {showEditModal && ( + setShowEditModal(false)} + /> + )} + + {showAddendaModal && ( + { + setShowAddendaModal(false); + setEditingAddenda(null); + }} + /> + )} + + {showPartidaModal && ( + { + setShowPartidaModal(false); + setEditingPartida(null); + }} + /> + )} + + {/* Terminate Modal */} + {showTerminateModal && ( + setShowTerminateModal(false)} + title="Terminar Contrato" + size="md" + footer={ + + + + + } + > + setTerminateReason(e.target.value)} + placeholder="Describa la razon por la cual se termina el contrato..." + rows={4} + /> + + )} + + {/* Delete Confirmations */} + setDeletePartidaId(null)} + onConfirm={handleDeletePartida} + title="Eliminar Partida" + message="Esta seguro de eliminar esta partida del contrato?" + confirmLabel="Eliminar" + variant="danger" + isLoading={deletePartidaMutation.isPending} + /> + + setDeleteAddendaId(null)} + onConfirm={handleDeleteAddenda} + title="Eliminar Addenda" + message="Esta seguro de eliminar esta addenda?" + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteAddendaMutation.isPending} + /> +
+ ); +} + +// ============================================================================ +// CONTRACT INFO TAB +// ============================================================================ + +function ContractInfoTab({ contract }: { contract: Contract }) { + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + return ( +
+ {/* General Info */} +
+

+ + Datos Generales +

+
+ + + {contract.description && ( + + )} + + + +
+
+ + {/* Client/Subcontractor Info */} +
+

+ + {contract.contractType === 'client' ? 'Datos del Cliente' : 'Datos del Subcontratista'} +

+
+ {contract.contractType === 'client' ? ( + <> + + + + + ) : ( + <> + + + + + )} +
+
+ + {/* Dates */} +
+

+ + Vigencia +

+
+ + + {contract.signedAt && ( + + )} +
+
+ + {/* Payment Terms */} +
+

+ + Condiciones de Pago +

+
+

+ {contract.paymentTerms || 'Sin condiciones especificas'} +

+
+
+ + {/* Audit Info */} +
+

+ + Historial +

+
+ + + {contract.submittedAt && ( + + )} + {contract.approvedAt && ( + + )} + {contract.terminatedAt && ( + <> + + + + )} +
+
+ + {/* Notes */} + {contract.notes && ( +
+

+ Notas +

+
+

+ {contract.notes} +

+
+
+ )} +
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ============================================================================ +// PARTIDAS TAB +// ============================================================================ + +interface PartidasTabProps { + partidas: ContractPartida[]; + isLoading: boolean; + onAdd: () => void; + onEdit: (partida: ContractPartida) => void; + onDelete: (id: string) => void; +} + +function PartidasTab({ partidas, isLoading, onAdd, onEdit, onDelete }: PartidasTabProps) { + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + if (isLoading) { + return
Cargando partidas...
; + } + + const total = partidas.reduce((sum, p) => sum + (p.totalAmount || p.quantity * p.unitPrice), 0); + + return ( +
+
+

+ Partidas del Contrato +

+ +
+ + {partidas.length === 0 ? ( + } + title="Sin partidas" + description="Agrega las partidas del contrato." + /> + ) : ( +
+ + + + + + + + + + + + {partidas.map((partida) => ( + + + + + + + + ))} + + + + + + + + +
+ Concepto + + Cantidad + + P.U. + + Total + + Acciones +
+
+ {partida.conceptoCode || 'N/A'} +
+
+ {partida.conceptoDescription || '-'} +
+
+ {partida.quantity.toLocaleString()} {partida.unit || ''} + + {formatCurrency(partida.unitPrice)} + + {formatCurrency(partida.totalAmount || partida.quantity * partida.unitPrice)} + +
+ + +
+
+ Total: + + {formatCurrency(total)} +
+
+ )} +
+ ); +} + +// ============================================================================ +// ADDENDAS TAB +// ============================================================================ + +interface AddendasTabProps { + addendums: ContractAddendum[]; + isLoading: boolean; + onAdd: () => void; + onEdit: (addendum: ContractAddendum) => void; + onDelete: (id: string) => void; +} + +function AddendasTab({ addendums, isLoading, onAdd, onEdit, onDelete }: AddendasTabProps) { + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + if (isLoading) { + return
Cargando addendas...
; + } + + return ( +
+
+

+ Addendas del Contrato +

+ +
+ + {addendums.length === 0 ? ( + } + title="Sin addendas" + description="No hay addendas registradas para este contrato." + /> + ) : ( +
+ {addendums.map((addendum) => ( +
+
+
+
+ + {addendum.addendumNumber} + + + +
+

+ {addendum.title} +

+

+ {addendum.description} +

+
+ Vigencia: {formatDate(addendum.effectiveDate)} + {addendum.amountChange !== 0 && ( + 0 ? 'text-green-600' : 'text-red-600'}> + {addendum.amountChange > 0 ? '+' : ''}{formatCurrency(addendum.amountChange)} + + )} + {addendum.newEndDate && ( + Nueva fecha fin: {formatDate(addendum.newEndDate)} + )} +
+
+
+ + +
+
+
+ ))} +
+ )} +
+ ); +} + diff --git a/web/src/pages/admin/contratos/ContratosPage.tsx b/web/src/pages/admin/contratos/ContratosPage.tsx new file mode 100644 index 0000000..403b9bb --- /dev/null +++ b/web/src/pages/admin/contratos/ContratosPage.tsx @@ -0,0 +1,337 @@ +/** + * ContratosPage - Lista de contratos + */ + +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Plus, Eye, Pencil, Trash2, FileText, Building2, Calendar, DollarSign } from 'lucide-react'; +import { + useContracts, + useDeleteContract, +} from '../../../hooks/useContracts'; +import { useSubcontractors } from '../../../hooks/useContracts'; +import type { + Contract, + ContractType, + ContractStatus, + ContractFilters, +} from '../../../types/contracts.types'; +import { + CONTRACT_TYPE_OPTIONS, + CONTRACT_STATUS_OPTIONS, +} from '../../../types/contracts.types'; +import { + PageHeader, + PageHeaderAction, + SearchInput, + SelectField, + StatusBadgeFromOptions, + ConfirmDialog, + LoadingOverlay, + EmptyState, + Pagination, +} from '../../../components/common'; +import { ContractForm } from '../../../components/contracts/ContractForm'; + +export function ContratosPage() { + const navigate = useNavigate(); + const [search, setSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [subcontractorFilter, setSubcontractorFilter] = useState(''); + const [page, setPage] = useState(1); + const [showModal, setShowModal] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [deleteId, setDeleteId] = useState(null); + + const filters: ContractFilters = { + search: search || undefined, + contractType: typeFilter || undefined, + status: statusFilter || undefined, + subcontractorId: subcontractorFilter || undefined, + page, + limit: 10, + }; + + const { data, isLoading, error } = useContracts(filters); + const { data: subcontractorsData } = useSubcontractors({ status: 'active' }); + const deleteMutation = useDeleteContract(); + + const contracts = data?.items || []; + const total = data?.total || 0; + const totalPages = Math.ceil(total / 10); + + const handleDelete = async () => { + if (deleteId) { + await deleteMutation.mutateAsync(deleteId); + setDeleteId(null); + } + }; + + const handleView = (id: string) => { + navigate(`/admin/contratos/${id}`); + }; + + const handleEdit = (contract: Contract) => { + setEditingItem(contract); + setShowModal(true); + }; + + const handleCreate = () => { + setEditingItem(null); + setShowModal(true); + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + + return ( +
+ + + Nuevo Contrato + + } + /> + + {/* Filters */} +
+
+ + ({ value: o.value, label: o.label })), + ]} + value={typeFilter} + onChange={(e) => setTypeFilter(e.target.value as ContractType | '')} + /> + ({ value: o.value, label: o.label })), + ]} + value={statusFilter} + onChange={(e) => setStatusFilter(e.target.value as ContractStatus | '')} + /> + ({ + value: s.id, + label: s.businessName, + })), + ]} + value={subcontractorFilter} + onChange={(e) => setSubcontractorFilter(e.target.value)} + /> +
+
+ + {/* Table */} +
+ {contracts.length === 0 ? ( + } + title="No hay contratos" + description="Crea el primer contrato para comenzar." + /> + ) : ( + <> +
+ + + + + + + + + + + + + + + {contracts.map((contract) => ( + + + + + + + + + + + ))} + +
+ Contrato + + Tipo + + Cliente/Subcontratista + + Vigencia + + Monto + + Avance + + Estado + + Acciones +
+
+ +
+
+ {contract.contractNumber} +
+
+ {contract.name} +
+
+
+
+ + +
+ + + {contract.contractType === 'client' + ? contract.clientName + : contract.subcontractor?.businessName || '-'} + +
+
+
+ + {formatDate(contract.startDate)} - {formatDate(contract.endDate)} +
+
+
+ + + {formatCurrency(contract.contractAmount)} + +
+
+
+
+
+
+ + {contract.progressPercentage}% + +
+
+ + +
+ + + +
+
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + )} +
+ + {/* Modal */} + {showModal && ( + { + setShowModal(false); + setEditingItem(null); + }} + /> + )} + + {/* Delete Confirmation */} + setDeleteId(null)} + onConfirm={handleDelete} + title="Confirmar eliminacion" + message="Esta seguro de eliminar este contrato? Esta accion no se puede deshacer." + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteMutation.isPending} + /> +
+ ); +} diff --git a/web/src/pages/admin/contratos/SubcontratistasPage.tsx b/web/src/pages/admin/contratos/SubcontratistasPage.tsx new file mode 100644 index 0000000..30782b9 --- /dev/null +++ b/web/src/pages/admin/contratos/SubcontratistasPage.tsx @@ -0,0 +1,520 @@ +/** + * SubcontratistasPage - Lista de subcontratistas + */ + +import { useState } from 'react'; +import { Plus, Pencil, Trash2, Users, Star, AlertTriangle, FileText, Phone, Mail } from 'lucide-react'; +import { + useSubcontractors, + useCreateSubcontractor, + useUpdateSubcontractor, + useDeleteSubcontractor, + useActivateSubcontractor, + useDeactivateSubcontractor, +} from '../../../hooks/useContracts'; +import type { + Subcontractor, + SubcontractorStatus, + SubcontractorSpecialty, + SubcontractorFilters, + CreateSubcontractorDto, +} from '../../../types/contracts.types'; +import { + SUBCONTRACTOR_STATUS_OPTIONS, + SUBCONTRACTOR_SPECIALTY_OPTIONS, +} from '../../../types/contracts.types'; +import { + PageHeader, + PageHeaderAction, + SearchInput, + SelectField, + StatusBadgeFromOptions, + ConfirmDialog, + Modal, + ModalFooter, + TextInput, + TextareaField, + FormGroup, + LoadingOverlay, + EmptyState, + Pagination, +} from '../../../components/common'; + +export function SubcontratistasPage() { + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [specialtyFilter, setSpecialtyFilter] = useState(''); + const [page, setPage] = useState(1); + const [showModal, setShowModal] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [deleteId, setDeleteId] = useState(null); + + const filters: SubcontractorFilters = { + search: search || undefined, + status: statusFilter || undefined, + primarySpecialty: specialtyFilter || undefined, + page, + limit: 10, + }; + + const { data, isLoading, error } = useSubcontractors(filters); + const createMutation = useCreateSubcontractor(); + const updateMutation = useUpdateSubcontractor(); + const deleteMutation = useDeleteSubcontractor(); + const activateMutation = useActivateSubcontractor(); + const deactivateMutation = useDeactivateSubcontractor(); + + const subcontractors = data?.items || []; + const total = data?.total || 0; + const totalPages = Math.ceil(total / 10); + + const handleDelete = async () => { + if (deleteId) { + await deleteMutation.mutateAsync(deleteId); + setDeleteId(null); + } + }; + + const handleToggleStatus = async (sub: Subcontractor) => { + if (sub.status === 'active') { + await deactivateMutation.mutateAsync(sub.id); + } else { + await activateMutation.mutateAsync(sub.id); + } + }; + + const handleSubmit = async (formData: CreateSubcontractorDto) => { + if (editingItem) { + await updateMutation.mutateAsync({ id: editingItem.id, data: formData }); + } else { + await createMutation.mutateAsync(formData); + } + setShowModal(false); + setEditingItem(null); + }; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + + return ( +
+ { setEditingItem(null); setShowModal(true); }}> + + Nuevo Subcontratista + + } + /> + + {/* Filters */} +
+
+ + ({ value: o.value, label: o.label })), + ]} + value={statusFilter} + onChange={(e) => setStatusFilter(e.target.value as SubcontractorStatus | '')} + /> + ({ value: o.value, label: o.label })), + ]} + value={specialtyFilter} + onChange={(e) => setSpecialtyFilter(e.target.value as SubcontractorSpecialty | '')} + /> +
+
+ + {/* Table */} +
+ {subcontractors.length === 0 ? ( + } + title="No hay subcontratistas" + description="Registra el primer subcontratista para comenzar." + /> + ) : ( + <> +
+ + + + + + + + + + + + + + {subcontractors.map((sub) => ( + + + + + + + + + + ))} + +
+ Subcontratista + + Especialidad + + Contacto + + Contratos + + Calificacion + + Estado + + Acciones +
+
+
+ +
+
+
+ {sub.businessName} +
+
+ {sub.code} - RFC: {sub.rfc} +
+
+
+
+ + +
+ {sub.contactName || '-'} +
+
+ {sub.phone && ( + + + {sub.phone} + + )} + {sub.email && ( + + + {sub.email} + + )} +
+
+
+ + + {sub.completedContracts}/{sub.totalContracts} + +
+
+
+ + + {sub.averageRating.toFixed(1)} + + {sub.totalIncidents > 0 && ( + + + {sub.totalIncidents} + + )} +
+
+ + +
+ + +
+
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + )} +
+ + {/* Modal */} + {showModal && ( + { setShowModal(false); setEditingItem(null); }} + onSubmit={handleSubmit} + isLoading={createMutation.isPending || updateMutation.isPending} + /> + )} + + {/* Delete Confirmation */} + setDeleteId(null)} + onConfirm={handleDelete} + title="Confirmar eliminacion" + message="Esta seguro de eliminar este subcontratista? Esta accion no se puede deshacer." + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteMutation.isPending} + /> +
+ ); +} + +// ============================================================================ +// SUBCONTRACTOR MODAL +// ============================================================================ + +interface SubcontractorModalProps { + item: Subcontractor | null; + onClose: () => void; + onSubmit: (data: CreateSubcontractorDto) => Promise; + isLoading: boolean; +} + +function SubcontractorModal({ item, onClose, onSubmit, isLoading }: SubcontractorModalProps) { + const [formData, setFormData] = useState({ + code: item?.code || '', + businessName: item?.businessName || '', + tradeName: item?.tradeName || '', + rfc: item?.rfc || '', + address: item?.address || '', + phone: item?.phone || '', + email: item?.email || '', + contactName: item?.contactName || '', + contactPhone: item?.contactPhone || '', + primarySpecialty: item?.primarySpecialty || 'otros', + secondarySpecialties: item?.secondarySpecialties || [], + bankName: item?.bankName || '', + bankAccount: item?.bankAccount || '', + clabe: item?.clabe || '', + notes: item?.notes || '', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit(formData); + }; + + const update = (field: K, value: CreateSubcontractorDto[K]) => { + setFormData({ ...formData, [field]: value }); + }; + + return ( + + + + + } + > +
+ {/* Basic Info */} +
+

+ Datos Generales +

+ + update('code', e.target.value)} + placeholder="SUB-001" + /> + update('rfc', e.target.value.toUpperCase())} + placeholder="XAXX010101000" + maxLength={13} + /> + + update('businessName', e.target.value)} + placeholder="Constructora ABC S.A. de C.V." + className="mt-4" + /> + update('tradeName', e.target.value)} + placeholder="Constructora ABC" + className="mt-4" + /> + ({ value: o.value, label: o.label }))} + value={formData.primarySpecialty} + onChange={(e) => update('primarySpecialty', e.target.value as SubcontractorSpecialty)} + className="mt-4" + /> +
+ + {/* Contact Info */} +
+

+ Contacto +

+ + update('contactName', e.target.value)} + placeholder="Juan Perez" + /> + update('contactPhone', e.target.value)} + placeholder="55 1234 5678" + /> + + + update('phone', e.target.value)} + placeholder="55 9876 5432" + /> + update('email', e.target.value)} + placeholder="contacto@constructora.com" + /> + + update('address', e.target.value)} + placeholder="Calle, numero, colonia, ciudad, CP" + rows={2} + className="mt-4" + /> +
+ + {/* Bank Info */} +
+

+ Datos Bancarios +

+ + update('bankName', e.target.value)} + placeholder="BBVA" + /> + update('bankAccount', e.target.value)} + placeholder="0123456789" + /> + update('clabe', e.target.value)} + placeholder="012345678901234567" + maxLength={18} + /> + +
+ + {/* Notes */} + update('notes', e.target.value)} + placeholder="Notas adicionales sobre el subcontratista..." + rows={3} + /> + +
+ ); +} diff --git a/web/src/pages/admin/contratos/index.ts b/web/src/pages/admin/contratos/index.ts new file mode 100644 index 0000000..42920be --- /dev/null +++ b/web/src/pages/admin/contratos/index.ts @@ -0,0 +1,7 @@ +/** + * Contratos Pages Index + */ + +export { ContratosPage } from './ContratosPage'; +export { ContratoDetailPage } from './ContratoDetailPage'; +export { SubcontratistasPage } from './SubcontratistasPage'; diff --git a/web/src/services/contracts/contracts.api.ts b/web/src/services/contracts/contracts.api.ts new file mode 100644 index 0000000..df447cb --- /dev/null +++ b/web/src/services/contracts/contracts.api.ts @@ -0,0 +1,204 @@ +/** + * Contracts API - Contratos y Subcontratistas + */ + +import api from '../api'; +import type { PaginatedResponse } from '../../types/api.types'; +import type { + Contract, + ContractFilters, + CreateContractDto, + UpdateContractDto, + ContractStats, + Subcontractor, + SubcontractorFilters, + CreateSubcontractorDto, + UpdateSubcontractorDto, + ContractPartida, + CreateContractPartidaDto, + UpdateContractPartidaDto, + ContractAddendum, + CreateAddendumDto, + UpdateAddendumDto, +} from '../../types/contracts.types'; + +// ============================================================================ +// CONTRACTS API +// ============================================================================ + +export const contractsApi = { + list: async (filters?: ContractFilters): Promise> => { + const response = await api.get>('/contracts', { + params: filters, + }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/contracts/${id}`); + return response.data; + }, + + create: async (data: CreateContractDto): Promise => { + const response = await api.post('/contracts', data); + return response.data; + }, + + update: async (id: string, data: UpdateContractDto): Promise => { + const response = await api.put(`/contracts/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/contracts/${id}`); + }, + + stats: async (): Promise => { + const response = await api.get('/contracts/stats'); + return response.data; + }, + + // Workflow actions + submit: async (id: string): Promise => { + const response = await api.post(`/contracts/${id}/submit`); + return response.data; + }, + + approve: async (id: string): Promise => { + const response = await api.post(`/contracts/${id}/approve`); + return response.data; + }, + + activate: async (id: string): Promise => { + const response = await api.post(`/contracts/${id}/activate`); + return response.data; + }, + + complete: async (id: string): Promise => { + const response = await api.post(`/contracts/${id}/complete`); + return response.data; + }, + + terminate: async (id: string, reason: string): Promise => { + const response = await api.post(`/contracts/${id}/terminate`, { reason }); + return response.data; + }, +}; + +// ============================================================================ +// SUBCONTRACTORS API +// ============================================================================ + +export const subcontractorsApi = { + list: async (filters?: SubcontractorFilters): Promise> => { + const response = await api.get>('/subcontractors', { + params: filters, + }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/subcontractors/${id}`); + return response.data; + }, + + create: async (data: CreateSubcontractorDto): Promise => { + const response = await api.post('/subcontractors', data); + return response.data; + }, + + update: async (id: string, data: UpdateSubcontractorDto): Promise => { + const response = await api.put(`/subcontractors/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/subcontractors/${id}`); + }, + + activate: async (id: string): Promise => { + const response = await api.post(`/subcontractors/${id}/activate`); + return response.data; + }, + + deactivate: async (id: string): Promise => { + const response = await api.post(`/subcontractors/${id}/deactivate`); + return response.data; + }, + + blacklist: async (id: string, reason: string): Promise => { + const response = await api.post(`/subcontractors/${id}/blacklist`, { reason }); + return response.data; + }, +}; + +// ============================================================================ +// CONTRACT PARTIDAS API +// ============================================================================ + +export const contractPartidasApi = { + list: async (contractId: string): Promise => { + const response = await api.get(`/contracts/${contractId}/partidas`); + return response.data; + }, + + create: async (contractId: string, data: CreateContractPartidaDto): Promise => { + const response = await api.post(`/contracts/${contractId}/partidas`, data); + return response.data; + }, + + update: async (contractId: string, partidaId: string, data: UpdateContractPartidaDto): Promise => { + const response = await api.put(`/contracts/${contractId}/partidas/${partidaId}`, data); + return response.data; + }, + + delete: async (contractId: string, partidaId: string): Promise => { + await api.delete(`/contracts/${contractId}/partidas/${partidaId}`); + }, +}; + +// ============================================================================ +// CONTRACT ADDENDUMS API +// ============================================================================ + +export const contractAddendumsApi = { + list: async (contractId: string): Promise => { + const response = await api.get(`/contracts/${contractId}/addendums`); + return response.data; + }, + + get: async (contractId: string, addendumId: string): Promise => { + const response = await api.get(`/contracts/${contractId}/addendums/${addendumId}`); + return response.data; + }, + + create: async (contractId: string, data: CreateAddendumDto): Promise => { + const response = await api.post(`/contracts/${contractId}/addendums`, data); + return response.data; + }, + + update: async (contractId: string, addendumId: string, data: UpdateAddendumDto): Promise => { + const response = await api.put(`/contracts/${contractId}/addendums/${addendumId}`, data); + return response.data; + }, + + delete: async (contractId: string, addendumId: string): Promise => { + await api.delete(`/contracts/${contractId}/addendums/${addendumId}`); + }, + + // Workflow actions + submit: async (contractId: string, addendumId: string): Promise => { + const response = await api.post(`/contracts/${contractId}/addendums/${addendumId}/submit`); + return response.data; + }, + + approve: async (contractId: string, addendumId: string): Promise => { + const response = await api.post(`/contracts/${contractId}/addendums/${addendumId}/approve`); + return response.data; + }, + + reject: async (contractId: string, addendumId: string, reason: string): Promise => { + const response = await api.post(`/contracts/${contractId}/addendums/${addendumId}/reject`, { reason }); + return response.data; + }, +}; diff --git a/web/src/services/contracts/index.ts b/web/src/services/contracts/index.ts new file mode 100644 index 0000000..4f4c50a --- /dev/null +++ b/web/src/services/contracts/index.ts @@ -0,0 +1,5 @@ +/** + * Contracts Services Index + */ + +export * from './contracts.api'; diff --git a/web/src/services/quality.ts b/web/src/services/quality.ts new file mode 100644 index 0000000..f763c9f --- /dev/null +++ b/web/src/services/quality.ts @@ -0,0 +1,324 @@ +/** + * Quality API Service - Calidad, Inspecciones, No Conformidades, Tickets + */ + +import api from './api'; +import type { PaginatedResponse } from '../types/api.types'; +import type { + Checklist, + ChecklistFilters, + CreateChecklistDto, + UpdateChecklistDto, + Inspection, + InspectionFilters, + CreateInspectionDto, + UpdateInspectionDto, + InspectionResult, + SaveInspectionResultsDto, + InspectionStats, + PostSaleTicket, + TicketFilters, + CreateTicketDto, + UpdateTicketDto, + AssignTicketDto, + ResolveTicketDto, + RateTicketDto, + TicketStats, + NonConformity, + NonConformityFilters, + CreateNonConformityDto, + UpdateNonConformityDto, + CloseNonConformityDto, + CorrectiveAction, + CreateCorrectiveActionDto, + UpdateCorrectiveActionDto, + CompleteCorrectiveActionDto, + NonConformityStats, +} from '../types/quality.types'; + +// ============================================================================ +// CHECKLISTS API +// ============================================================================ + +export const checklistsApi = { + list: async (filters?: ChecklistFilters): Promise> => { + const response = await api.get>('/quality/checklists', { + params: filters, + }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/quality/checklists/${id}`); + return response.data; + }, + + create: async (data: CreateChecklistDto): Promise => { + const response = await api.post('/quality/checklists', data); + return response.data; + }, + + update: async (id: string, data: UpdateChecklistDto): Promise => { + const response = await api.put(`/quality/checklists/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/quality/checklists/${id}`); + }, + + duplicate: async (id: string): Promise => { + const response = await api.post(`/quality/checklists/${id}/duplicate`); + return response.data; + }, +}; + +// ============================================================================ +// INSPECTIONS API +// ============================================================================ + +export const inspectionsApi = { + list: async (filters?: InspectionFilters): Promise> => { + const response = await api.get>('/quality/inspections', { + params: filters, + }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/quality/inspections/${id}`); + return response.data; + }, + + create: async (data: CreateInspectionDto): Promise => { + const response = await api.post('/quality/inspections', data); + return response.data; + }, + + update: async (id: string, data: UpdateInspectionDto): Promise => { + const response = await api.put(`/quality/inspections/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/quality/inspections/${id}`); + }, + + // Results + getResults: async (inspectionId: string): Promise => { + const response = await api.get(`/quality/inspections/${inspectionId}/results`); + return response.data; + }, + + saveResults: async (inspectionId: string, data: SaveInspectionResultsDto): Promise => { + const response = await api.post(`/quality/inspections/${inspectionId}/results`, data); + return response.data; + }, + + // Workflow + start: async (id: string): Promise => { + const response = await api.post(`/quality/inspections/${id}/start`); + return response.data; + }, + + complete: async (id: string): Promise => { + const response = await api.post(`/quality/inspections/${id}/complete`); + return response.data; + }, + + approve: async (id: string): Promise => { + const response = await api.post(`/quality/inspections/${id}/approve`); + return response.data; + }, + + reject: async (id: string, reason: string): Promise => { + const response = await api.post(`/quality/inspections/${id}/reject`, { reason }); + return response.data; + }, + + // Stats + stats: async (filters?: InspectionFilters): Promise => { + const response = await api.get('/quality/inspections/stats', { + params: filters, + }); + return response.data; + }, +}; + +// ============================================================================ +// POST-SALE TICKETS API +// ============================================================================ + +export const ticketsApi = { + list: async (filters?: TicketFilters): Promise> => { + const response = await api.get>('/quality/tickets', { + params: filters, + }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/quality/tickets/${id}`); + return response.data; + }, + + create: async (data: CreateTicketDto): Promise => { + const response = await api.post('/quality/tickets', data); + return response.data; + }, + + update: async (id: string, data: UpdateTicketDto): Promise => { + const response = await api.put(`/quality/tickets/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/quality/tickets/${id}`); + }, + + // Workflow + assign: async (id: string, data: AssignTicketDto): Promise => { + const response = await api.post(`/quality/tickets/${id}/assign`, data); + return response.data; + }, + + startWork: async (id: string): Promise => { + const response = await api.post(`/quality/tickets/${id}/start`); + return response.data; + }, + + resolve: async (id: string, data: ResolveTicketDto): Promise => { + const response = await api.post(`/quality/tickets/${id}/resolve`, data); + return response.data; + }, + + close: async (id: string): Promise => { + const response = await api.post(`/quality/tickets/${id}/close`); + return response.data; + }, + + cancel: async (id: string, reason?: string): Promise => { + const response = await api.post(`/quality/tickets/${id}/cancel`, { reason }); + return response.data; + }, + + rate: async (id: string, data: RateTicketDto): Promise => { + const response = await api.post(`/quality/tickets/${id}/rate`, data); + return response.data; + }, + + // Stats + stats: async (filters?: TicketFilters): Promise => { + const response = await api.get('/quality/tickets/stats', { + params: filters, + }); + return response.data; + }, +}; + +// ============================================================================ +// NON-CONFORMITIES API +// ============================================================================ + +export const nonConformitiesApi = { + list: async (filters?: NonConformityFilters): Promise> => { + const response = await api.get>('/quality/non-conformities', { + params: filters, + }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/quality/non-conformities/${id}`); + return response.data; + }, + + create: async (data: CreateNonConformityDto): Promise => { + const response = await api.post('/quality/non-conformities', data); + return response.data; + }, + + update: async (id: string, data: UpdateNonConformityDto): Promise => { + const response = await api.put(`/quality/non-conformities/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/quality/non-conformities/${id}`); + }, + + // Workflow + startProgress: async (id: string): Promise => { + const response = await api.post(`/quality/non-conformities/${id}/start`); + return response.data; + }, + + close: async (id: string, data: CloseNonConformityDto): Promise => { + const response = await api.post(`/quality/non-conformities/${id}/close`, data); + return response.data; + }, + + verify: async (id: string): Promise => { + const response = await api.post(`/quality/non-conformities/${id}/verify`); + return response.data; + }, + + reopen: async (id: string, reason: string): Promise => { + const response = await api.post(`/quality/non-conformities/${id}/reopen`, { reason }); + return response.data; + }, + + // Stats + stats: async (filters?: NonConformityFilters): Promise => { + const response = await api.get('/quality/non-conformities/stats', { + params: filters, + }); + return response.data; + }, +}; + +// ============================================================================ +// CORRECTIVE ACTIONS API +// ============================================================================ + +export const correctiveActionsApi = { + list: async (nonConformityId: string): Promise => { + const response = await api.get(`/quality/non-conformities/${nonConformityId}/actions`); + return response.data; + }, + + get: async (nonConformityId: string, actionId: string): Promise => { + const response = await api.get(`/quality/non-conformities/${nonConformityId}/actions/${actionId}`); + return response.data; + }, + + create: async (nonConformityId: string, data: CreateCorrectiveActionDto): Promise => { + const response = await api.post(`/quality/non-conformities/${nonConformityId}/actions`, data); + return response.data; + }, + + update: async (nonConformityId: string, actionId: string, data: UpdateCorrectiveActionDto): Promise => { + const response = await api.put(`/quality/non-conformities/${nonConformityId}/actions/${actionId}`, data); + return response.data; + }, + + delete: async (nonConformityId: string, actionId: string): Promise => { + await api.delete(`/quality/non-conformities/${nonConformityId}/actions/${actionId}`); + }, + + // Workflow + start: async (nonConformityId: string, actionId: string): Promise => { + const response = await api.post(`/quality/non-conformities/${nonConformityId}/actions/${actionId}/start`); + return response.data; + }, + + complete: async (nonConformityId: string, actionId: string, data: CompleteCorrectiveActionDto): Promise => { + const response = await api.post(`/quality/non-conformities/${nonConformityId}/actions/${actionId}/complete`, data); + return response.data; + }, + + verify: async (nonConformityId: string, actionId: string, effective: boolean): Promise => { + const response = await api.post(`/quality/non-conformities/${nonConformityId}/actions/${actionId}/verify`, { effective }); + return response.data; + }, +}; diff --git a/web/src/types/common.types.ts b/web/src/types/common.types.ts index 12df0c5..e2dca27 100644 --- a/web/src/types/common.types.ts +++ b/web/src/types/common.types.ts @@ -24,7 +24,10 @@ export type StatusColor = | 'purple' | 'orange' | 'pink' - | 'indigo'; + | 'indigo' + | 'teal' + | 'cyan' + | 'slate'; // =========================== // UI COMPONENT TYPES diff --git a/web/src/types/contracts.types.ts b/web/src/types/contracts.types.ts new file mode 100644 index 0000000..25af084 --- /dev/null +++ b/web/src/types/contracts.types.ts @@ -0,0 +1,337 @@ +/** + * Contracts Types - Contratos, Subcontratistas, Partidas, Addendas + */ + +// ============================================================================ +// ENUMS +// ============================================================================ + +export type ContractType = 'client' | 'subcontractor'; +export type ContractStatus = 'draft' | 'review' | 'approved' | 'active' | 'completed' | 'terminated'; +export type ClientContractType = 'desarrollo' | 'llave_en_mano' | 'administracion'; +export type SubcontractorSpecialty = 'cimentacion' | 'estructura' | 'instalaciones_electricas' | 'instalaciones_hidraulicas' | 'acabados' | 'urbanizacion' | 'carpinteria' | 'herreria' | 'otros'; +export type SubcontractorStatus = 'active' | 'inactive' | 'blacklisted'; +export type AddendumType = 'extension' | 'amount_increase' | 'amount_decrease' | 'scope_change' | 'termination' | 'other'; +export type AddendumStatus = 'draft' | 'review' | 'approved' | 'rejected'; + +// ============================================================================ +// CONTRACT TYPES +// ============================================================================ + +export interface Contract { + id: string; + tenantId: string; + projectId?: string; + fraccionamientoId?: string; + contractNumber: string; + contractType: ContractType; + clientContractType?: ClientContractType; + name: string; + description?: string; + clientName?: string; + clientRfc?: string; + clientAddress?: string; + subcontractorId?: string; + subcontractor?: Subcontractor; + specialty?: string; + startDate: string; + endDate: string; + contractAmount: number; + currency: string; + paymentTerms?: string; + retentionPercentage: number; + advancePercentage: number; + status: ContractStatus; + submittedAt?: string; + submittedById?: string; + legalApprovedAt?: string; + legalApprovedById?: string; + approvedAt?: string; + approvedById?: string; + signedAt?: string; + terminatedAt?: string; + terminationReason?: string; + documentUrl?: string; + signedDocumentUrl?: string; + progressPercentage: number; + invoicedAmount: number; + paidAmount: number; + notes?: string; + addendums?: ContractAddendum[]; + partidas?: ContractPartida[]; + createdAt: string; + updatedAt: string; +} + +export interface ContractFilters { + contractType?: ContractType; + status?: ContractStatus; + subcontractorId?: string; + fraccionamientoId?: string; + projectId?: string; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface CreateContractDto { + projectId?: string; + fraccionamientoId?: string; + contractNumber: string; + contractType: ContractType; + clientContractType?: ClientContractType; + name: string; + description?: string; + clientName?: string; + clientRfc?: string; + clientAddress?: string; + subcontractorId?: string; + specialty?: string; + startDate: string; + endDate: string; + contractAmount: number; + currency?: string; + paymentTerms?: string; + retentionPercentage?: number; + advancePercentage?: number; + notes?: string; +} + +export interface UpdateContractDto { + name?: string; + description?: string; + clientName?: string; + clientRfc?: string; + clientAddress?: string; + subcontractorId?: string; + specialty?: string; + startDate?: string; + endDate?: string; + contractAmount?: number; + paymentTerms?: string; + retentionPercentage?: number; + advancePercentage?: number; + notes?: string; +} + +export interface ContractStats { + total: number; + byStatus: Record; + totalAmount: number; + invoicedAmount: number; + paidAmount: number; + pendingAmount: number; +} + +// ============================================================================ +// SUBCONTRACTOR TYPES +// ============================================================================ + +export interface Subcontractor { + id: string; + tenantId: string; + code: string; + businessName: string; + tradeName?: string; + rfc: string; + address?: string; + phone?: string; + email?: string; + contactName?: string; + contactPhone?: string; + primarySpecialty: SubcontractorSpecialty; + secondarySpecialties?: string[]; + status: SubcontractorStatus; + totalContracts: number; + completedContracts: number; + averageRating: number; + totalIncidents: number; + bankName?: string; + bankAccount?: string; + clabe?: string; + notes?: string; + createdAt: string; + updatedAt: string; +} + +export interface SubcontractorFilters { + status?: SubcontractorStatus; + primarySpecialty?: SubcontractorSpecialty; + search?: string; + page?: number; + limit?: number; +} + +export interface CreateSubcontractorDto { + code: string; + businessName: string; + tradeName?: string; + rfc: string; + address?: string; + phone?: string; + email?: string; + contactName?: string; + contactPhone?: string; + primarySpecialty: SubcontractorSpecialty; + secondarySpecialties?: string[]; + bankName?: string; + bankAccount?: string; + clabe?: string; + notes?: string; +} + +export interface UpdateSubcontractorDto { + businessName?: string; + tradeName?: string; + address?: string; + phone?: string; + email?: string; + contactName?: string; + contactPhone?: string; + primarySpecialty?: SubcontractorSpecialty; + secondarySpecialties?: string[]; + status?: SubcontractorStatus; + bankName?: string; + bankAccount?: string; + clabe?: string; + notes?: string; +} + +// ============================================================================ +// CONTRACT PARTIDA TYPES +// ============================================================================ + +export interface ContractPartida { + id: string; + tenantId: string; + contractId: string; + conceptoId: string; + conceptoCode?: string; + conceptoDescription?: string; + unit?: string; + quantity: number; + unitPrice: number; + totalAmount: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateContractPartidaDto { + conceptoId: string; + quantity: number; + unitPrice: number; +} + +export interface UpdateContractPartidaDto { + quantity?: number; + unitPrice?: number; +} + +// ============================================================================ +// CONTRACT ADDENDUM TYPES +// ============================================================================ + +export interface ContractAddendum { + id: string; + tenantId: string; + contractId: string; + addendumNumber: string; + addendumType: AddendumType; + title: string; + description: string; + effectiveDate: string; + newEndDate?: string; + amountChange: number; + newContractAmount?: number; + scopeChanges?: string; + status: AddendumStatus; + approvedAt?: string; + approvedById?: string; + rejectionReason?: string; + documentUrl?: string; + notes?: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateAddendumDto { + addendumNumber: string; + addendumType: AddendumType; + title: string; + description: string; + effectiveDate: string; + newEndDate?: string; + amountChange?: number; + scopeChanges?: string; + notes?: string; +} + +export interface UpdateAddendumDto { + title?: string; + description?: string; + effectiveDate?: string; + newEndDate?: string; + amountChange?: number; + scopeChanges?: string; + notes?: string; +} + +// ============================================================================ +// CONSTANTS / OPTIONS +// ============================================================================ + +export const CONTRACT_TYPE_OPTIONS = [ + { value: 'client', label: 'Cliente', color: 'blue' }, + { value: 'subcontractor', label: 'Subcontratista', color: 'purple' }, +] as const; + +export const CONTRACT_STATUS_OPTIONS = [ + { value: 'draft', label: 'Borrador', color: 'gray' }, + { value: 'review', label: 'En Revision', color: 'yellow' }, + { value: 'approved', label: 'Aprobado', color: 'blue' }, + { value: 'active', label: 'Activo', color: 'green' }, + { value: 'completed', label: 'Completado', color: 'teal' }, + { value: 'terminated', label: 'Terminado', color: 'red' }, +] as const; + +export const CLIENT_CONTRACT_TYPE_OPTIONS = [ + { value: 'desarrollo', label: 'Desarrollo', color: 'blue' }, + { value: 'llave_en_mano', label: 'Llave en Mano', color: 'green' }, + { value: 'administracion', label: 'Administracion', color: 'purple' }, +] as const; + +export const SUBCONTRACTOR_SPECIALTY_OPTIONS = [ + { value: 'cimentacion', label: 'Cimentacion', color: 'gray' }, + { value: 'estructura', label: 'Estructura', color: 'blue' }, + { value: 'instalaciones_electricas', label: 'Instalaciones Electricas', color: 'yellow' }, + { value: 'instalaciones_hidraulicas', label: 'Instalaciones Hidraulicas', color: 'cyan' }, + { value: 'acabados', label: 'Acabados', color: 'pink' }, + { value: 'urbanizacion', label: 'Urbanizacion', color: 'green' }, + { value: 'carpinteria', label: 'Carpinteria', color: 'orange' }, + { value: 'herreria', label: 'Herreria', color: 'slate' }, + { value: 'otros', label: 'Otros', color: 'purple' }, +] as const; + +export const SUBCONTRACTOR_STATUS_OPTIONS = [ + { value: 'active', label: 'Activo', color: 'green' }, + { value: 'inactive', label: 'Inactivo', color: 'gray' }, + { value: 'blacklisted', label: 'Lista Negra', color: 'red' }, +] as const; + +export const ADDENDUM_TYPE_OPTIONS = [ + { value: 'extension', label: 'Extension de Plazo', color: 'blue' }, + { value: 'amount_increase', label: 'Incremento de Monto', color: 'green' }, + { value: 'amount_decrease', label: 'Reduccion de Monto', color: 'red' }, + { value: 'scope_change', label: 'Cambio de Alcance', color: 'yellow' }, + { value: 'termination', label: 'Terminacion', color: 'gray' }, + { value: 'other', label: 'Otro', color: 'purple' }, +] as const; + +export const ADDENDUM_STATUS_OPTIONS = [ + { value: 'draft', label: 'Borrador', color: 'gray' }, + { value: 'review', label: 'En Revision', color: 'yellow' }, + { value: 'approved', label: 'Aprobado', color: 'green' }, + { value: 'rejected', label: 'Rechazado', color: 'red' }, +] as const; diff --git a/web/src/types/quality.types.ts b/web/src/types/quality.types.ts new file mode 100644 index 0000000..3113dce --- /dev/null +++ b/web/src/types/quality.types.ts @@ -0,0 +1,504 @@ +/** + * Quality Types - Calidad, Inspecciones, No Conformidades, Tickets Postventa + */ + +// ============================================================================ +// ENUMS +// ============================================================================ + +export type ChecklistStage = 'foundation' | 'structure' | 'installations' | 'finishes' | 'delivery' | 'custom'; +export type InspectionStatus = 'pending' | 'in_progress' | 'completed' | 'approved' | 'rejected'; +export type InspectionResultStatus = 'pending' | 'passed' | 'failed' | 'not_applicable'; +export type TicketPriority = 'urgent' | 'high' | 'medium' | 'low'; +export type TicketStatus = 'created' | 'assigned' | 'in_progress' | 'resolved' | 'closed' | 'cancelled'; +export type TicketCategory = 'plumbing' | 'electrical' | 'finishes' | 'carpentry' | 'structural' | 'other'; +export type NCSeverity = 'minor' | 'major' | 'critical'; +export type NCStatus = 'open' | 'in_progress' | 'closed' | 'verified'; +export type ActionType = 'corrective' | 'preventive' | 'improvement'; +export type ActionStatus = 'pending' | 'in_progress' | 'completed' | 'verified'; +export type AssignmentStatus = 'assigned' | 'accepted' | 'in_progress' | 'completed' | 'reassigned'; + +// ============================================================================ +// CHECKLIST TYPES +// ============================================================================ + +export interface ChecklistItem { + id: string; + tenantId: string; + checklistId: string; + sequenceNumber: number; + category: string; + description: string; + isCritical: boolean; + requiresPhoto: boolean; + acceptanceCriteria?: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface Checklist { + id: string; + tenantId: string; + code: string; + name: string; + description?: string; + stage: ChecklistStage; + prototypeId?: string; + isActive: boolean; + version: number; + items?: ChecklistItem[]; + createdAt: string; + createdById?: string; + updatedAt: string; + updatedById?: string; +} + +export interface ChecklistFilters { + stage?: ChecklistStage; + prototypeId?: string; + isActive?: boolean; + search?: string; + page?: number; + limit?: number; +} + +export interface CreateChecklistItemDto { + sequenceNumber: number; + category: string; + description: string; + isCritical?: boolean; + requiresPhoto?: boolean; + acceptanceCriteria?: string; + isActive?: boolean; +} + +export interface CreateChecklistDto { + code: string; + name: string; + description?: string; + stage: ChecklistStage; + prototypeId?: string; + isActive?: boolean; + items?: CreateChecklistItemDto[]; +} + +export interface UpdateChecklistDto { + code?: string; + name?: string; + description?: string; + stage?: ChecklistStage; + prototypeId?: string; + isActive?: boolean; + items?: CreateChecklistItemDto[]; +} + +// ============================================================================ +// INSPECTION TYPES +// ============================================================================ + +export interface InspectionResult { + id: string; + tenantId: string; + inspectionId: string; + checklistItemId: string; + result: InspectionResultStatus; + observations?: string; + photoUrl?: string; + inspectedAt?: string; + checklistItem?: ChecklistItem; + createdAt: string; + updatedAt: string; +} + +export interface Inspection { + id: string; + tenantId: string; + checklistId: string; + loteId: string; + inspectionNumber: string; + inspectionDate: string; + inspectorId: string; + status: InspectionStatus; + totalItems: number; + passedItems: number; + failedItems: number; + passRate?: number; + completedAt?: string; + approvedById?: string; + approvedAt?: string; + notes?: string; + rejectionReason?: string; + checklist?: Checklist; + results?: InspectionResult[]; + createdAt: string; + createdById?: string; + updatedAt: string; + updatedById?: string; +} + +export interface InspectionFilters { + checklistId?: string; + loteId?: string; + inspectorId?: string; + status?: InspectionStatus; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface CreateInspectionResultDto { + checklistItemId: string; + result: InspectionResultStatus; + observations?: string; + photoUrl?: string; +} + +export interface CreateInspectionDto { + checklistId: string; + loteId: string; + inspectionDate: string; + inspectorId: string; + notes?: string; +} + +export interface UpdateInspectionDto { + inspectionDate?: string; + inspectorId?: string; + notes?: string; +} + +export interface SaveInspectionResultsDto { + results: CreateInspectionResultDto[]; +} + +export interface InspectionStats { + totalInspections: number; + pendingInspections: number; + inProgressInspections: number; + completedInspections: number; + approvedInspections: number; + rejectedInspections: number; + averagePassRate: number; +} + +// ============================================================================ +// POST-SALE TICKET TYPES +// ============================================================================ + +export interface TicketAssignment { + id: string; + tenantId: string; + ticketId: string; + technicianId: string; + assignedAt: string; + assignedById: string; + status: AssignmentStatus; + acceptedAt?: string; + scheduledDate?: string; + scheduledTime?: string; + completedAt?: string; + workNotes?: string; + reassignmentReason?: string; + isCurrent: boolean; + createdAt: string; + updatedAt: string; +} + +export interface PostSaleTicket { + id: string; + tenantId: string; + loteId: string; + derechohabienteId?: string; + ticketNumber: string; + category: TicketCategory; + priority: TicketPriority; + title: string; + description: string; + photoUrl?: string; + status: TicketStatus; + slaHours: number; + slaDueAt: string; + slaBreached: boolean; + assignedAt?: string; + resolvedAt?: string; + closedAt?: string; + resolutionNotes?: string; + resolutionPhotoUrl?: string; + satisfactionRating?: number; + satisfactionComment?: string; + contactName?: string; + contactPhone?: string; + assignments?: TicketAssignment[]; + createdAt: string; + createdById?: string; + updatedAt: string; + updatedById?: string; +} + +export interface TicketFilters { + loteId?: string; + category?: TicketCategory; + priority?: TicketPriority; + status?: TicketStatus; + slaBreached?: boolean; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface CreateTicketDto { + loteId: string; + derechohabienteId?: string; + category: TicketCategory; + priority: TicketPriority; + title: string; + description: string; + photoUrl?: string; + contactName?: string; + contactPhone?: string; +} + +export interface UpdateTicketDto { + category?: TicketCategory; + priority?: TicketPriority; + title?: string; + description?: string; + photoUrl?: string; + contactName?: string; + contactPhone?: string; +} + +export interface AssignTicketDto { + technicianId: string; + scheduledDate?: string; + scheduledTime?: string; +} + +export interface ResolveTicketDto { + resolutionNotes: string; + resolutionPhotoUrl?: string; +} + +export interface RateTicketDto { + satisfactionRating: number; + satisfactionComment?: string; +} + +export interface TicketStats { + totalTickets: number; + openTickets: number; + assignedTickets: number; + inProgressTickets: number; + resolvedTickets: number; + closedTickets: number; + slaBreach: number; + avgResolutionHours: number; + avgSatisfaction: number; +} + +// ============================================================================ +// NON-CONFORMITY TYPES +// ============================================================================ + +export interface CorrectiveAction { + id: string; + tenantId: string; + nonConformityId: string; + actionType: ActionType; + description: string; + responsibleId: string; + dueDate: string; + status: ActionStatus; + completedAt?: string; + completionNotes?: string; + verifiedAt?: string; + verifiedById?: string; + effectivenessVerified: boolean; + createdAt: string; + createdById?: string; + updatedAt: string; + updatedById?: string; +} + +export interface NonConformity { + id: string; + tenantId: string; + inspectionId?: string; + loteId: string; + ncNumber: string; + detectionDate: string; + category: string; + severity: NCSeverity; + description: string; + rootCause?: string; + photoUrl?: string; + contractorId?: string; + status: NCStatus; + dueDate?: string; + closedAt?: string; + closedById?: string; + verifiedAt?: string; + verifiedById?: string; + closurePhotoUrl?: string; + closureNotes?: string; + correctiveActions?: CorrectiveAction[]; + createdAt: string; + createdById?: string; + updatedAt: string; + updatedById?: string; +} + +export interface NonConformityFilters { + inspectionId?: string; + loteId?: string; + category?: string; + severity?: NCSeverity; + status?: NCStatus; + contractorId?: string; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface CreateNonConformityDto { + inspectionId?: string; + loteId: string; + detectionDate: string; + category: string; + severity: NCSeverity; + description: string; + rootCause?: string; + photoUrl?: string; + contractorId?: string; + dueDate?: string; +} + +export interface UpdateNonConformityDto { + category?: string; + severity?: NCSeverity; + description?: string; + rootCause?: string; + photoUrl?: string; + contractorId?: string; + dueDate?: string; +} + +export interface CloseNonConformityDto { + closureNotes: string; + closurePhotoUrl?: string; +} + +export interface CreateCorrectiveActionDto { + actionType: ActionType; + description: string; + responsibleId: string; + dueDate: string; +} + +export interface UpdateCorrectiveActionDto { + description?: string; + responsibleId?: string; + dueDate?: string; +} + +export interface CompleteCorrectiveActionDto { + completionNotes: string; +} + +export interface NonConformityStats { + totalNCs: number; + openNCs: number; + inProgressNCs: number; + closedNCs: number; + verifiedNCs: number; + minorNCs: number; + majorNCs: number; + criticalNCs: number; + avgClosureTime: number; +} + +// ============================================================================ +// OPTIONS FOR STATUS BADGES +// ============================================================================ + +export const CHECKLIST_STAGE_OPTIONS = [ + { value: 'foundation', label: 'Cimentacion', color: 'gray' }, + { value: 'structure', label: 'Estructura', color: 'blue' }, + { value: 'installations', label: 'Instalaciones', color: 'purple' }, + { value: 'finishes', label: 'Acabados', color: 'yellow' }, + { value: 'delivery', label: 'Entrega', color: 'green' }, + { value: 'custom', label: 'Personalizado', color: 'gray' }, +] as const; + +export const INSPECTION_STATUS_OPTIONS = [ + { value: 'pending', label: 'Pendiente', color: 'gray' }, + { value: 'in_progress', label: 'En Progreso', color: 'blue' }, + { value: 'completed', label: 'Completada', color: 'yellow' }, + { value: 'approved', label: 'Aprobada', color: 'green' }, + { value: 'rejected', label: 'Rechazada', color: 'red' }, +] as const; + +export const INSPECTION_RESULT_STATUS_OPTIONS = [ + { value: 'pending', label: 'Pendiente', color: 'gray' }, + { value: 'passed', label: 'Aprobado', color: 'green' }, + { value: 'failed', label: 'Fallido', color: 'red' }, + { value: 'not_applicable', label: 'N/A', color: 'gray' }, +] as const; + +export const TICKET_PRIORITY_OPTIONS = [ + { value: 'urgent', label: 'Urgente', color: 'red' }, + { value: 'high', label: 'Alta', color: 'orange' }, + { value: 'medium', label: 'Media', color: 'yellow' }, + { value: 'low', label: 'Baja', color: 'green' }, +] as const; + +export const TICKET_STATUS_OPTIONS = [ + { value: 'created', label: 'Creado', color: 'gray' }, + { value: 'assigned', label: 'Asignado', color: 'blue' }, + { value: 'in_progress', label: 'En Progreso', color: 'yellow' }, + { value: 'resolved', label: 'Resuelto', color: 'green' }, + { value: 'closed', label: 'Cerrado', color: 'gray' }, + { value: 'cancelled', label: 'Cancelado', color: 'red' }, +] as const; + +export const TICKET_CATEGORY_OPTIONS = [ + { value: 'plumbing', label: 'Plomeria', color: 'blue' }, + { value: 'electrical', label: 'Electricidad', color: 'yellow' }, + { value: 'finishes', label: 'Acabados', color: 'purple' }, + { value: 'carpentry', label: 'Carpinteria', color: 'orange' }, + { value: 'structural', label: 'Estructural', color: 'red' }, + { value: 'other', label: 'Otro', color: 'gray' }, +] as const; + +export const NC_SEVERITY_OPTIONS = [ + { value: 'minor', label: 'Menor', color: 'yellow' }, + { value: 'major', label: 'Mayor', color: 'orange' }, + { value: 'critical', label: 'Critica', color: 'red' }, +] as const; + +export const NC_STATUS_OPTIONS = [ + { value: 'open', label: 'Abierta', color: 'red' }, + { value: 'in_progress', label: 'En Progreso', color: 'yellow' }, + { value: 'closed', label: 'Cerrada', color: 'green' }, + { value: 'verified', label: 'Verificada', color: 'blue' }, +] as const; + +export const ACTION_TYPE_OPTIONS = [ + { value: 'corrective', label: 'Correctiva', color: 'red' }, + { value: 'preventive', label: 'Preventiva', color: 'blue' }, + { value: 'improvement', label: 'Mejora', color: 'green' }, +] as const; + +export const ACTION_STATUS_OPTIONS = [ + { value: 'pending', label: 'Pendiente', color: 'gray' }, + { value: 'in_progress', label: 'En Progreso', color: 'yellow' }, + { value: 'completed', label: 'Completada', color: 'green' }, + { value: 'verified', label: 'Verificada', color: 'blue' }, +] as const;