Sistema NEXUS v3.4 migrado con: Estructura principal: - core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles) - core/catalog: Catalogo de funcionalidades reutilizables - shared/knowledge-base: Base de conocimiento compartida - devtools/scripts: Herramientas de desarrollo - control-plane/registries: Control de servicios y CI/CD - orchestration/: Configuracion de orquestacion de agentes Proyectos incluidos (11): - gamilit (submodule -> GitHub) - trading-platform (OrbiquanTIA) - erp-suite con 5 verticales: - erp-core, construccion, vidrio-templado - mecanicas-diesel, retail, clinicas - betting-analytics - inmobiliaria-analytics - platform_marketing_content - pos-micro, erp-basico Configuracion: - .gitignore completo para Node.js/Python/Docker - gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git) - Sistema de puertos estandarizado (3005-3199) Generated with NEXUS v3.4 Migration System EPIC-010: Configuracion Git y Repositorios
45 KiB
45 KiB
ET-CAL-001: Implementación Frontend - Módulo de Calidad
Épica: MAI-006 - Calidad Módulo: Frontend - Gestión de Calidad Responsable Técnico: Equipo Frontend Fecha: 2025-12-06 Versión: 1.0
1. Objetivo Técnico
Implementar la interfaz de usuario completa para el módulo de Calidad del sistema de construcción, permitiendo:
- Gestión de inspecciones de calidad
- Registro y seguimiento de no conformidades (NC)
- Gestión de pruebas de laboratorio
- Control de certificaciones
- Dashboard ejecutivo con KPIs de calidad
- Workflows de aprobación y cierre
2. Stack Tecnológico
Frontend
- React 18+ con TypeScript
- Vite 5+ como bundler
- Zustand para state management
- React Query (TanStack Query) para data fetching
- React Router 6+ para navegación
- Tailwind CSS para estilos
- Shadcn/ui para componentes base
- React Hook Form + Zod para formularios
- Recharts para gráficos
- date-fns para manejo de fechas
Integración
- Axios para HTTP client
- React Dropzone para upload de archivos
- React Signature Canvas para firmas digitales
3. Estructura de Feature Modules
src/features/quality/
├── inspections/
│ ├── components/
│ │ ├── InspectionChecklist.tsx
│ │ ├── InspectionForm.tsx
│ │ ├── InspectionList.tsx
│ │ ├── InspectionDetail.tsx
│ │ ├── ChecklistItem.tsx
│ │ └── SignaturePanel.tsx
│ ├── hooks/
│ │ ├── useInspections.ts
│ │ ├── useCreateInspection.ts
│ │ └── useSignInspection.ts
│ ├── api/
│ │ └── inspectionApi.ts
│ └── types/
│ └── inspection.types.ts
│
├── non-conformities/
│ ├── components/
│ │ ├── NCForm.tsx
│ │ ├── NCTimeline.tsx
│ │ ├── NCList.tsx
│ │ ├── NCDetail.tsx
│ │ ├── NCStatusBadge.tsx
│ │ └── CorrectiveActionForm.tsx
│ ├── hooks/
│ │ ├── useNonConformities.ts
│ │ ├── useCreateNC.ts
│ │ └── useCloseNC.ts
│ ├── api/
│ │ └── ncApi.ts
│ └── types/
│ └── nc.types.ts
│
├── lab-tests/
│ ├── components/
│ │ ├── LabTestForm.tsx
│ │ ├── LabTestResults.tsx
│ │ ├── LabTestList.tsx
│ │ ├── TestResultsChart.tsx
│ │ └── ComplianceIndicator.tsx
│ ├── hooks/
│ │ ├── useLabTests.ts
│ │ └── useTestResults.ts
│ ├── api/
│ │ └── labTestApi.ts
│ └── types/
│ └── labTest.types.ts
│
├── certifications/
│ ├── components/
│ │ ├── CertificationCard.tsx
│ │ ├── CertificationForm.tsx
│ │ ├── CertificationList.tsx
│ │ ├── CertificationDetail.tsx
│ │ └── ExpiryAlert.tsx
│ ├── hooks/
│ │ ├── useCertifications.ts
│ │ └── useExpiringCertifications.ts
│ ├── api/
│ │ └── certificationApi.ts
│ └── types/
│ └── certification.types.ts
│
├── dashboard/
│ ├── components/
│ │ ├── QualityDashboard.tsx
│ │ ├── QualityKPIs.tsx
│ │ ├── ComplianceChart.tsx
│ │ ├── NCTrendChart.tsx
│ │ ├── InspectionStatusChart.tsx
│ │ └── RecentActivity.tsx
│ ├── hooks/
│ │ └── useQualityMetrics.ts
│ └── api/
│ └── dashboardApi.ts
│
└── stores/
├── inspectionStore.ts
├── ncStore.ts
├── labTestStore.ts
└── qualityFiltersStore.ts
4. Componentes Principales
4.1 InspectionChecklist
Componente para realizar inspecciones de calidad con checklist dinámico.
// src/features/quality/inspections/components/InspectionChecklist.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { ChecklistItem } from './ChecklistItem';
import { SignaturePanel } from './SignaturePanel';
import { useCreateInspection } from '../hooks/useCreateInspection';
import { InspectionTemplate, ChecklistItemData } from '../types/inspection.types';
interface InspectionChecklistProps {
projectId: string;
template: InspectionTemplate;
unitId?: string;
onComplete?: (inspectionId: string) => void;
}
const inspectionSchema = z.object({
items: z.array(z.object({
itemId: z.string(),
isCompliant: z.boolean().nullable(),
value: z.string().optional(),
measurement: z.number().optional(),
notes: z.string().optional(),
photoIds: z.array(z.string()).default([]),
})),
generalNotes: z.string().optional(),
signatureData: z.string().optional(),
});
type InspectionFormData = z.infer<typeof inspectionSchema>;
export function InspectionChecklist({
projectId,
template,
unitId,
onComplete,
}: InspectionChecklistProps) {
const [currentSection, setCurrentSection] = useState(0);
const [showSignature, setShowSignature] = useState(false);
const { mutate: createInspection, isPending } = useCreateInspection();
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<InspectionFormData>({
resolver: zodResolver(inspectionSchema),
defaultValues: {
items: template.items.map(item => ({
itemId: item.itemId,
isCompliant: null,
notes: '',
photoIds: [],
})),
},
});
const sections = [...new Set(template.items.map(item => item.section))];
const currentItems = template.items.filter(
item => item.section === sections[currentSection]
);
const handleItemChange = (
itemId: string,
field: keyof ChecklistItemData,
value: any
) => {
const items = watch('items');
const index = items.findIndex(i => i.itemId === itemId);
if (index !== -1) {
setValue(`items.${index}.${field}`, value);
}
};
const onSubmit = (data: InspectionFormData) => {
// Calcular compliance
const compliantItems = data.items.filter(
item => item.isCompliant === true
).length;
// Detectar no conformidades
const nonConformities = data.items
.filter(item => item.isCompliant === false)
.map((item, index) => {
const templateItem = template.items.find(t => t.itemId === item.itemId);
return {
ncId: `NC-${Date.now()}-${index}`,
itemId: item.itemId,
description: `${templateItem?.question}: ${item.notes || 'No conforme'}`,
severity: 'major' as const,
status: 'open' as const,
};
});
createInspection({
projectId,
unitId,
templateId: template.id,
inspectionDate: new Date(),
items: data.items,
totalItems: template.items.length,
compliantItems,
nonConformities,
generalNotes: data.generalNotes,
signatureData: data.signatureData,
}, {
onSuccess: (inspection) => {
onComplete?.(inspection.id);
},
});
};
const handleNextSection = () => {
if (currentSection < sections.length - 1) {
setCurrentSection(prev => prev + 1);
} else {
setShowSignature(true);
}
};
const handlePreviousSection = () => {
if (currentSection > 0) {
setCurrentSection(prev => prev - 1);
}
};
const progress = ((currentSection + 1) / sections.length) * 100;
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold mb-2">{template.name}</h2>
<p className="text-gray-600 mb-4">{template.description}</p>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600">
<span>Sección {currentSection + 1} de {sections.length}</span>
<span>{Math.round(progress)}% completado</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
{!showSignature ? (
<>
{/* Current Section */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-xl font-semibold mb-6">
{sections[currentSection]}
</h3>
<div className="space-y-6">
{currentItems.map((item, index) => (
<ChecklistItem
key={item.itemId}
item={item}
value={watch('items').find(i => i.itemId === item.itemId)}
onChange={(field, value) => handleItemChange(item.itemId, field, value)}
/>
))}
</div>
</div>
{/* Navigation */}
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={handlePreviousSection}
disabled={currentSection === 0}
>
Anterior
</Button>
<Button
type="button"
onClick={handleNextSection}
>
{currentSection === sections.length - 1 ? 'Firmar y Completar' : 'Siguiente'}
</Button>
</div>
</>
) : (
<>
{/* Signature Section */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-xl font-semibold mb-6">Firma de Inspección</h3>
<Textarea
placeholder="Notas generales de la inspección..."
className="mb-6"
{...register('generalNotes')}
/>
<SignaturePanel
onSignatureChange={(signature) => setValue('signatureData', signature)}
/>
</div>
{/* Submit */}
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => setShowSignature(false)}
>
Volver a Checklist
</Button>
<Button
onClick={handleSubmit(onSubmit)}
disabled={isPending}
>
{isPending ? 'Guardando...' : 'Completar Inspección'}
</Button>
</div>
</>
)}
</div>
);
}
4.2 NCForm
Formulario para registrar no conformidades.
// src/features/quality/non-conformities/components/NCForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { PhotoUpload } from '@/components/shared/PhotoUpload';
import { useCreateNC } from '../hooks/useCreateNC';
const ncSchema = z.object({
projectId: z.string(),
inspectionId: z.string().optional(),
location: z.string().min(1, 'La ubicación es requerida'),
description: z.string().min(10, 'La descripción debe tener al menos 10 caracteres'),
severity: z.enum(['minor', 'major', 'critical']),
category: z.string(),
detectedBy: z.string(),
detectionDate: z.date(),
correctiveAction: z.string().min(10, 'La acción correctiva es requerida'),
responsibleId: z.string().min(1, 'Debe asignar un responsable'),
dueDate: z.date(),
photoIds: z.array(z.string()).optional(),
});
type NCFormData = z.infer<typeof ncSchema>;
interface NCFormProps {
projectId: string;
inspectionId?: string;
onSuccess?: () => void;
}
export function NCForm({ projectId, inspectionId, onSuccess }: NCFormProps) {
const { mutate: createNC, isPending } = useCreateNC();
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<NCFormData>({
resolver: zodResolver(ncSchema),
defaultValues: {
projectId,
inspectionId,
detectionDate: new Date(),
severity: 'major',
},
});
const onSubmit = (data: NCFormData) => {
createNC(data, {
onSuccess: () => {
onSuccess?.();
},
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Ubicación */}
<div>
<label className="block text-sm font-medium mb-2">
Ubicación / Elemento
</label>
<Input
{...register('location')}
placeholder="Ej: Columna C-3, Nivel 2"
error={errors.location?.message}
/>
</div>
{/* Descripción */}
<div>
<label className="block text-sm font-medium mb-2">
Descripción de la No Conformidad
</label>
<Textarea
{...register('description')}
placeholder="Describa detalladamente la no conformidad encontrada..."
rows={4}
error={errors.description?.message}
/>
</div>
{/* Severity y Category */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Severidad
</label>
<Select
{...register('severity')}
options={[
{ value: 'minor', label: 'Menor' },
{ value: 'major', label: 'Mayor' },
{ value: 'critical', label: 'Crítica' },
]}
error={errors.severity?.message}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Categoría
</label>
<Select
{...register('category')}
options={[
{ value: 'structural', label: 'Estructural' },
{ value: 'architectural', label: 'Arquitectónico' },
{ value: 'mep', label: 'Instalaciones' },
{ value: 'finishing', label: 'Acabados' },
{ value: 'safety', label: 'Seguridad' },
]}
error={errors.category?.message}
/>
</div>
</div>
{/* Acción Correctiva */}
<div>
<label className="block text-sm font-medium mb-2">
Acción Correctiva Propuesta
</label>
<Textarea
{...register('correctiveAction')}
placeholder="Describa la acción correctiva necesaria..."
rows={3}
error={errors.correctiveAction?.message}
/>
</div>
{/* Responsable y Fecha Límite */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Responsable
</label>
<Select
{...register('responsibleId')}
options={[
// Se cargarían desde API
]}
placeholder="Seleccionar responsable"
error={errors.responsibleId?.message}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Fecha Límite
</label>
<DatePicker
control={control}
name="dueDate"
error={errors.dueDate?.message}
/>
</div>
</div>
{/* Evidencia Fotográfica */}
<div>
<label className="block text-sm font-medium mb-2">
Evidencia Fotográfica
</label>
<PhotoUpload
projectId={projectId}
onUploadSuccess={(photoIds) => {
// Actualizar form con IDs de fotos
}}
/>
</div>
{/* Submit */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline">
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Guardando...' : 'Registrar NC'}
</Button>
</div>
</form>
);
}
4.3 NCTimeline
Componente para mostrar el timeline de una no conformidad.
// src/features/quality/non-conformities/components/NCTimeline.tsx
import { formatDistanceToNow } from 'date-fns';
import { es } from 'date-fns/locale';
import { Check, Clock, AlertCircle, FileText, Image } from 'lucide-react';
import { NCActivity } from '../types/nc.types';
interface NCTimelineProps {
activities: NCActivity[];
}
const activityIcons = {
created: AlertCircle,
assigned: Clock,
in_progress: Clock,
verification_requested: FileText,
photo_added: Image,
closed: Check,
};
const activityColors = {
created: 'bg-red-100 text-red-600',
assigned: 'bg-blue-100 text-blue-600',
in_progress: 'bg-yellow-100 text-yellow-600',
verification_requested: 'bg-purple-100 text-purple-600',
photo_added: 'bg-green-100 text-green-600',
closed: 'bg-green-100 text-green-600',
};
export function NCTimeline({ activities }: NCTimelineProps) {
return (
<div className="flow-root">
<ul className="-mb-8">
{activities.map((activity, index) => {
const Icon = activityIcons[activity.type];
const isLast = index === activities.length - 1;
return (
<li key={activity.id}>
<div className="relative pb-8">
{!isLast && (
<span
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
aria-hidden="true"
/>
)}
<div className="relative flex space-x-3">
<div>
<span
className={`h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white ${activityColors[activity.type]}`}
>
<Icon className="h-4 w-4" />
</span>
</div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div>
<p className="text-sm text-gray-900">
{activity.description}
</p>
{activity.notes && (
<p className="mt-1 text-sm text-gray-500">
{activity.notes}
</p>
)}
<p className="mt-0.5 text-xs text-gray-500">
por {activity.user.name}
</p>
</div>
<div className="whitespace-nowrap text-right text-sm text-gray-500">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
locale: es,
})}
</div>
</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}
4.4 LabTestResults
Componente para visualizar resultados de pruebas de laboratorio.
// src/features/quality/lab-tests/components/LabTestResults.tsx
import { Check, X, AlertTriangle } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { TestResultsChart } from './TestResultsChart';
import { ComplianceIndicator } from './ComplianceIndicator';
import { LabTest, TestResult } from '../types/labTest.types';
interface LabTestResultsProps {
test: LabTest;
}
export function LabTestResults({ test }: LabTestResultsProps) {
const getComplianceStatus = (result: TestResult) => {
const value = result.measuredValue;
const min = result.minValue;
const max = result.maxValue;
const target = result.targetValue;
if (min !== null && max !== null) {
if (value >= min && value <= max) return 'compliant';
if (value < min * 0.9 || value > max * 1.1) return 'critical';
return 'warning';
}
if (target !== null) {
const deviation = Math.abs((value - target) / target) * 100;
if (deviation <= 5) return 'compliant';
if (deviation <= 10) return 'warning';
return 'critical';
}
return 'unknown';
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'compliant':
return <Check className="h-5 w-5 text-green-600" />;
case 'warning':
return <AlertTriangle className="h-5 w-5 text-yellow-600" />;
case 'critical':
return <X className="h-5 w-5 text-red-600" />;
default:
return null;
}
};
const getStatusBadge = (status: string) => {
const variants = {
compliant: 'success',
warning: 'warning',
critical: 'destructive',
};
const labels = {
compliant: 'Conforme',
warning: 'Advertencia',
critical: 'No Conforme',
};
return (
<Badge variant={variants[status as keyof typeof variants]}>
{labels[status as keyof typeof labels]}
</Badge>
);
};
return (
<div className="space-y-6">
{/* Header */}
<Card className="p-6">
<div className="flex justify-between items-start">
<div>
<h3 className="text-xl font-semibold">{test.testName}</h3>
<p className="text-sm text-gray-600 mt-1">
{test.testCode} | {test.material}
</p>
<p className="text-sm text-gray-500 mt-1">
Norma: {test.standard}
</p>
</div>
<ComplianceIndicator
compliantTests={test.compliantTests}
totalTests={test.totalTests}
/>
</div>
</Card>
{/* Results Table */}
<Card className="p-6">
<h4 className="text-lg font-semibold mb-4">Resultados de Pruebas</h4>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4">Parámetro</th>
<th className="text-center py-3 px-4">Valor Medido</th>
<th className="text-center py-3 px-4">Rango Aceptable</th>
<th className="text-center py-3 px-4">Unidad</th>
<th className="text-center py-3 px-4">Estado</th>
</tr>
</thead>
<tbody>
{test.results.map((result, index) => {
const status = getComplianceStatus(result);
return (
<tr key={index} className="border-b hover:bg-gray-50">
<td className="py-3 px-4">
<div>
<p className="font-medium">{result.parameter}</p>
{result.description && (
<p className="text-sm text-gray-500">
{result.description}
</p>
)}
</div>
</td>
<td className="text-center py-3 px-4">
<span className="font-semibold">
{result.measuredValue.toFixed(2)}
</span>
</td>
<td className="text-center py-3 px-4 text-sm text-gray-600">
{result.minValue !== null && result.maxValue !== null ? (
<span>
{result.minValue.toFixed(2)} - {result.maxValue.toFixed(2)}
</span>
) : result.targetValue !== null ? (
<span>
Target: {result.targetValue.toFixed(2)} ± {result.tolerance}%
</span>
) : (
'-'
)}
</td>
<td className="text-center py-3 px-4 text-sm">
{result.unit}
</td>
<td className="text-center py-3 px-4">
<div className="flex items-center justify-center gap-2">
{getStatusIcon(status)}
{getStatusBadge(status)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
{/* Charts */}
{test.historicalData && test.historicalData.length > 0 && (
<Card className="p-6">
<h4 className="text-lg font-semibold mb-4">Tendencia Histórica</h4>
<TestResultsChart data={test.historicalData} />
</Card>
)}
{/* Observations */}
{test.observations && (
<Card className="p-6">
<h4 className="text-lg font-semibold mb-2">Observaciones</h4>
<p className="text-gray-700">{test.observations}</p>
</Card>
)}
</div>
);
}
4.5 CertificationCard
Componente card para mostrar certificaciones.
// src/features/quality/certifications/components/CertificationCard.tsx
import { format, differenceInDays, isPast } from 'date-fns';
import { es } from 'date-fns/locale';
import { FileText, Calendar, AlertCircle, Download } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Certification } from '../types/certification.types';
interface CertificationCardProps {
certification: Certification;
onView?: () => void;
onDownload?: () => void;
}
export function CertificationCard({
certification,
onView,
onDownload,
}: CertificationCardProps) {
const getExpiryStatus = () => {
if (!certification.expiryDate) return null;
const daysUntilExpiry = differenceInDays(
new Date(certification.expiryDate),
new Date()
);
if (daysUntilExpiry < 0) {
return { status: 'expired', label: 'Vencida', variant: 'destructive' };
}
if (daysUntilExpiry <= 30) {
return { status: 'expiring', label: 'Por vencer', variant: 'warning' };
}
return { status: 'valid', label: 'Vigente', variant: 'success' };
};
const expiryStatus = getExpiryStatus();
return (
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-start gap-4">
{/* Icon */}
<div className="flex-shrink-0">
<div className="h-12 w-12 rounded-lg bg-blue-100 flex items-center justify-center">
<FileText className="h-6 w-6 text-blue-600" />
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-lg font-semibold text-gray-900">
{certification.certificationName}
</h3>
<p className="text-sm text-gray-600">
{certification.certificationCode}
</p>
</div>
{expiryStatus && (
<Badge variant={expiryStatus.variant as any}>
{expiryStatus.label}
</Badge>
)}
</div>
{/* Details */}
<div className="space-y-2 mb-4">
<div className="flex items-center text-sm text-gray-600">
<span className="font-medium mr-2">Tipo:</span>
{certification.certificationType}
</div>
<div className="flex items-center text-sm text-gray-600">
<span className="font-medium mr-2">Emisor:</span>
{certification.issuingOrg}
</div>
{certification.scope && (
<div className="flex items-center text-sm text-gray-600">
<span className="font-medium mr-2">Alcance:</span>
{certification.scope}
</div>
)}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center text-gray-600">
<Calendar className="h-4 w-4 mr-1" />
<span className="font-medium mr-1">Emisión:</span>
{format(new Date(certification.issueDate), 'dd/MM/yyyy', { locale: es })}
</div>
{certification.expiryDate && (
<div className="flex items-center text-gray-600">
<Calendar className="h-4 w-4 mr-1" />
<span className="font-medium mr-1">Vencimiento:</span>
{format(new Date(certification.expiryDate), 'dd/MM/yyyy', { locale: es })}
</div>
)}
</div>
</div>
{/* Expiry Alert */}
{expiryStatus && expiryStatus.status !== 'valid' && (
<div className={`p-3 rounded-lg mb-4 ${
expiryStatus.status === 'expired'
? 'bg-red-50 border border-red-200'
: 'bg-yellow-50 border border-yellow-200'
}`}>
<div className="flex items-start gap-2">
<AlertCircle className={`h-5 w-5 flex-shrink-0 ${
expiryStatus.status === 'expired' ? 'text-red-600' : 'text-yellow-600'
}`} />
<div>
<p className={`text-sm font-medium ${
expiryStatus.status === 'expired' ? 'text-red-900' : 'text-yellow-900'
}`}>
{expiryStatus.status === 'expired'
? 'Esta certificación ha vencido'
: `Vence en ${differenceInDays(new Date(certification.expiryDate!), new Date())} días`
}
</p>
<p className={`text-sm ${
expiryStatus.status === 'expired' ? 'text-red-700' : 'text-yellow-700'
}`}>
Actualizar lo antes posible
</p>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onView}>
Ver Detalles
</Button>
{certification.documentUrl && (
<Button size="sm" variant="outline" onClick={onDownload}>
<Download className="h-4 w-4 mr-1" />
Descargar
</Button>
)}
</div>
</div>
</div>
</Card>
);
}
4.6 QualityDashboard
Dashboard principal con KPIs de calidad.
// src/features/quality/dashboard/components/QualityDashboard.tsx
import { useQualityMetrics } from '../hooks/useQualityMetrics';
import { QualityKPIs } from './QualityKPIs';
import { ComplianceChart } from './ComplianceChart';
import { NCTrendChart } from './NCTrendChart';
import { InspectionStatusChart } from './InspectionStatusChart';
import { RecentActivity } from './RecentActivity';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
interface QualityDashboardProps {
projectId: string;
dateRange?: {
from: Date;
to: Date;
};
}
export function QualityDashboard({ projectId, dateRange }: QualityDashboardProps) {
const { data: metrics, isLoading } = useQualityMetrics(projectId, dateRange);
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-32 w-full" />
<div className="grid grid-cols-2 gap-6">
<Skeleton className="h-80 w-full" />
<Skeleton className="h-80 w-full" />
</div>
</div>
);
}
if (!metrics) {
return <div>No hay datos disponibles</div>;
}
return (
<div className="space-y-6">
{/* KPIs */}
<QualityKPIs metrics={metrics.kpis} />
{/* Charts Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Compliance Chart */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
Cumplimiento de Inspecciones
</h3>
<ComplianceChart data={metrics.complianceData} />
</Card>
{/* NC Trend */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
Tendencia de No Conformidades
</h3>
<NCTrendChart data={metrics.ncTrendData} />
</Card>
{/* Inspection Status */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
Estado de Inspecciones
</h3>
<InspectionStatusChart data={metrics.inspectionStatusData} />
</Card>
{/* Recent Activity */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
Actividad Reciente
</h3>
<RecentActivity activities={metrics.recentActivities} />
</Card>
</div>
{/* Critical Items */}
{metrics.criticalItems && metrics.criticalItems.length > 0 && (
<Card className="p-6 border-red-200 bg-red-50">
<h3 className="text-lg font-semibold text-red-900 mb-4">
Elementos Críticos que Requieren Atención
</h3>
<div className="space-y-3">
{metrics.criticalItems.map((item, index) => (
<div
key={index}
className="bg-white p-4 rounded-lg border border-red-200"
>
<div className="flex items-start justify-between">
<div>
<p className="font-medium text-gray-900">{item.title}</p>
<p className="text-sm text-gray-600 mt-1">
{item.description}
</p>
</div>
<Badge variant="destructive">{item.type}</Badge>
</div>
</div>
))}
</div>
</Card>
)}
</div>
);
}
5. Zustand Stores
5.1 Inspection Store
// src/features/quality/stores/inspectionStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { Inspection, InspectionFilters } from '../inspections/types/inspection.types';
interface InspectionState {
inspections: Inspection[];
selectedInspection: Inspection | null;
filters: InspectionFilters;
// Actions
setInspections: (inspections: Inspection[]) => void;
addInspection: (inspection: Inspection) => void;
updateInspection: (id: string, updates: Partial<Inspection>) => void;
setSelectedInspection: (inspection: Inspection | null) => void;
setFilters: (filters: Partial<InspectionFilters>) => void;
resetFilters: () => void;
}
const defaultFilters: InspectionFilters = {
status: undefined,
dateFrom: undefined,
dateTo: undefined,
unitId: undefined,
inspectorId: undefined,
};
export const useInspectionStore = create<InspectionState>()(
devtools(
persist(
(set) => ({
inspections: [],
selectedInspection: null,
filters: defaultFilters,
setInspections: (inspections) => set({ inspections }),
addInspection: (inspection) =>
set((state) => ({
inspections: [inspection, ...state.inspections],
})),
updateInspection: (id, updates) =>
set((state) => ({
inspections: state.inspections.map((inspection) =>
inspection.id === id ? { ...inspection, ...updates } : inspection
),
selectedInspection:
state.selectedInspection?.id === id
? { ...state.selectedInspection, ...updates }
: state.selectedInspection,
})),
setSelectedInspection: (inspection) =>
set({ selectedInspection: inspection }),
setFilters: (filters) =>
set((state) => ({
filters: { ...state.filters, ...filters },
})),
resetFilters: () => set({ filters: defaultFilters }),
}),
{
name: 'inspection-storage',
partialize: (state) => ({ filters: state.filters }),
}
)
)
);
5.2 NC Store
// src/features/quality/stores/ncStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { NonConformity, NCFilters } from '../non-conformities/types/nc.types';
interface NCState {
nonConformities: NonConformity[];
selectedNC: NonConformity | null;
filters: NCFilters;
// Actions
setNonConformities: (ncs: NonConformity[]) => void;
addNC: (nc: NonConformity) => void;
updateNC: (id: string, updates: Partial<NonConformity>) => void;
setSelectedNC: (nc: NonConformity | null) => void;
setFilters: (filters: Partial<NCFilters>) => void;
// Computed
getOpenNCs: () => NonConformity[];
getCriticalNCs: () => NonConformity[];
}
const defaultFilters: NCFilters = {
status: undefined,
severity: undefined,
category: undefined,
responsibleId: undefined,
};
export const useNCStore = create<NCState>()(
devtools((set, get) => ({
nonConformities: [],
selectedNC: null,
filters: defaultFilters,
setNonConformities: (ncs) => set({ nonConformities: ncs }),
addNC: (nc) =>
set((state) => ({
nonConformities: [nc, ...state.nonConformities],
})),
updateNC: (id, updates) =>
set((state) => ({
nonConformities: state.nonConformities.map((nc) =>
nc.id === id ? { ...nc, ...updates } : nc
),
selectedNC:
state.selectedNC?.id === id
? { ...state.selectedNC, ...updates }
: state.selectedNC,
})),
setSelectedNC: (nc) => set({ selectedNC: nc }),
setFilters: (filters) =>
set((state) => ({
filters: { ...state.filters, ...filters },
})),
getOpenNCs: () =>
get().nonConformities.filter((nc) => nc.status === 'open'),
getCriticalNCs: () =>
get().nonConformities.filter((nc) => nc.severity === 'critical'),
}))
);
6. Custom Hooks
6.1 useInspections
// src/features/quality/inspections/hooks/useInspections.ts
import { useQuery } from '@tanstack/react-query';
import { inspectionApi } from '../api/inspectionApi';
import { useInspectionStore } from '../../stores/inspectionStore';
export function useInspections(projectId: string) {
const { filters, setInspections } = useInspectionStore();
return useQuery({
queryKey: ['inspections', projectId, filters],
queryFn: async () => {
const data = await inspectionApi.getInspections(projectId, filters);
setInspections(data);
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
6.2 useCreateInspection
// src/features/quality/inspections/hooks/useCreateInspection.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { inspectionApi } from '../api/inspectionApi';
import { useInspectionStore } from '../../stores/inspectionStore';
import { toast } from '@/hooks/use-toast';
export function useCreateInspection() {
const queryClient = useQueryClient();
const { addInspection } = useInspectionStore();
return useMutation({
mutationFn: inspectionApi.createInspection,
onSuccess: (data) => {
addInspection(data);
queryClient.invalidateQueries({ queryKey: ['inspections'] });
toast({
title: 'Inspección creada',
description: 'La inspección se ha guardado correctamente',
});
},
onError: (error) => {
toast({
title: 'Error',
description: 'No se pudo crear la inspección',
variant: 'destructive',
});
console.error('Error creating inspection:', error);
},
});
}
7. Routing
// src/routes/quality.routes.tsx
import { lazy } from 'react';
import { Route } from 'react-router-dom';
const QualityDashboard = lazy(() => import('@/features/quality/dashboard/components/QualityDashboard'));
const InspectionList = lazy(() => import('@/features/quality/inspections/components/InspectionList'));
const InspectionDetail = lazy(() => import('@/features/quality/inspections/components/InspectionDetail'));
const NCList = lazy(() => import('@/features/quality/non-conformities/components/NCList'));
const NCDetail = lazy(() => import('@/features/quality/non-conformities/components/NCDetail'));
const LabTestList = lazy(() => import('@/features/quality/lab-tests/components/LabTestList'));
const CertificationList = lazy(() => import('@/features/quality/certifications/components/CertificationList'));
export const qualityRoutes = (
<>
<Route path="quality">
<Route index element={<QualityDashboard />} />
<Route path="inspections">
<Route index element={<InspectionList />} />
<Route path=":id" element={<InspectionDetail />} />
<Route path="new" element={<InspectionForm />} />
</Route>
<Route path="non-conformities">
<Route index element={<NCList />} />
<Route path=":id" element={<NCDetail />} />
<Route path="new" element={<NCForm />} />
</Route>
<Route path="lab-tests">
<Route index element={<LabTestList />} />
<Route path=":id" element={<LabTestResults />} />
</Route>
<Route path="certifications">
<Route index element={<CertificationList />} />
</Route>
</Route>
</>
);
8. TypeScript Types
// src/features/quality/inspections/types/inspection.types.ts
export interface InspectionTemplate {
id: string;
name: string;
description: string;
items: ChecklistTemplateItem[];
}
export interface ChecklistTemplateItem {
itemId: string;
section: string;
question: string;
type: 'boolean' | 'numeric' | 'text' | 'photo';
isRequired: boolean;
hasTolerance: boolean;
tolerance?: string;
minValue?: number;
maxValue?: number;
unit?: string;
referenceValue?: number;
helpText?: string;
requiresPhoto: boolean;
}
export interface Inspection {
id: string;
projectId: string;
unitId?: string;
templateId: string;
inspectionCode: string;
inspectionDate: Date;
inspectorId: string;
items: ChecklistItemData[];
totalItems: number;
compliantItems: number;
compliancePercent: number;
nonConformities?: NonConformity[];
status: 'draft' | 'completed' | 'approved' | 'rejected';
signatureData?: string;
signedBy?: string;
signedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface ChecklistItemData {
itemId: string;
isCompliant: boolean | null;
value?: string;
measurement?: number;
notes?: string;
photoIds: string[];
}
export interface InspectionFilters {
status?: string;
dateFrom?: Date;
dateTo?: Date;
unitId?: string;
inspectorId?: string;
}
// src/features/quality/non-conformities/types/nc.types.ts
export interface NonConformity {
id: string;
projectId: string;
ncCode: string;
inspectionId?: string;
location: string;
description: string;
severity: 'minor' | 'major' | 'critical';
category: string;
detectedBy: string;
detectionDate: Date;
correctiveAction: string;
responsibleId: string;
dueDate: Date;
status: 'open' | 'in_progress' | 'verification' | 'closed';
photoIds: string[];
verificationPhotoIds: string[];
closedDate?: Date;
closedBy?: string;
activities: NCActivity[];
createdAt: Date;
updatedAt: Date;
}
export interface NCActivity {
id: string;
type: 'created' | 'assigned' | 'in_progress' | 'verification_requested' | 'photo_added' | 'closed';
description: string;
notes?: string;
user: {
id: string;
name: string;
};
createdAt: Date;
}
export interface NCFilters {
status?: string;
severity?: string;
category?: string;
responsibleId?: string;
}
9. Criterios de Aceptación
Funcionales
- Dashboard de calidad muestra KPIs en tiempo real
- Inspecciones con checklists dinámicos y firma digital
- Registro y seguimiento de no conformidades
- Timeline completo de actividades de NC
- Gestión de pruebas de laboratorio con resultados
- Control de certificaciones con alertas de vencimiento
- Filtros y búsqueda en todos los módulos
- Exportación de reportes en PDF
Técnicos
- Componentes React con TypeScript
- State management con Zustand
- Data fetching con React Query
- Validación de formularios con Zod
- Responsive design con Tailwind CSS
- Lazy loading de rutas
- Tests unitarios con Vitest
- Coverage >80%
UX/UI
- Interfaz intuitiva y fácil de usar
- Feedback visual en todas las acciones
- Loading states y skeleton screens
- Error handling con mensajes claros
- Diseño responsive para tablets y móviles
- Accesibilidad (WCAG 2.1 AA)
Fecha: 2025-12-06 Preparado por: Equipo Frontend Versión: 1.0 Estado: Listo para Implementación