1557 lines
45 KiB
Markdown
1557 lines
45 KiB
Markdown
# 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
|
|
```typescript
|
|
- 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
|
|
```typescript
|
|
- 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.
|
|
|
|
```tsx
|
|
// 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.
|
|
|
|
```tsx
|
|
// 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.
|
|
|
|
```tsx
|
|
// 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.
|
|
|
|
```tsx
|
|
// 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.
|
|
|
|
```tsx
|
|
// 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.
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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;
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// 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
|
|
- [x] Dashboard de calidad muestra KPIs en tiempo real
|
|
- [x] Inspecciones con checklists dinámicos y firma digital
|
|
- [x] Registro y seguimiento de no conformidades
|
|
- [x] Timeline completo de actividades de NC
|
|
- [x] Gestión de pruebas de laboratorio con resultados
|
|
- [x] Control de certificaciones con alertas de vencimiento
|
|
- [x] Filtros y búsqueda en todos los módulos
|
|
- [x] Exportación de reportes en PDF
|
|
|
|
### Técnicos
|
|
- [x] Componentes React con TypeScript
|
|
- [x] State management con Zustand
|
|
- [x] Data fetching con React Query
|
|
- [x] Validación de formularios con Zod
|
|
- [x] Responsive design con Tailwind CSS
|
|
- [x] Lazy loading de rutas
|
|
- [x] Tests unitarios con Vitest
|
|
- [x] Coverage >80%
|
|
|
|
### UX/UI
|
|
- [x] Interfaz intuitiva y fácil de usar
|
|
- [x] Feedback visual en todas las acciones
|
|
- [x] Loading states y skeleton screens
|
|
- [x] Error handling con mensajes claros
|
|
- [x] Diseño responsive para tablets y móviles
|
|
- [x] Accesibilidad (WCAG 2.1 AA)
|
|
|
|
---
|
|
|
|
**Fecha:** 2025-12-06
|
|
**Preparado por:** Equipo Frontend
|
|
**Versión:** 1.0
|
|
**Estado:** Listo para Implementación
|