workspace-v1/projects/erp-construccion/docs/02-definicion-modulos/MAI-006-calidad/especificaciones/ET-CAL-001-frontend.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
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
2026-01-04 03:37:42 -06:00

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