// InspectionChecklist — plantilla de puntos con semáforo, medición, fotos/video // por punto y recomendación → renglón del presupuesto (benchmark §7, TAL-4/5; // Shopmonkey S39, Tekmetric S36). Controlado: `points` + `onChange`. Sirve a la // recepción del vehículo (encabezado con km/combustible/daños), a la inspección // multipunto de la OT, a la hoja de alineación y a la garantía. import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Camera, Loader2, Trash2, Video } from 'lucide-react' import { Badge, Button, Input, Textarea } from '@asteby/metacore-ui' import { useFormatter } from './format' import { DEFAULT_TREAD_THRESHOLDS, FUEL_LEVELS, hasRecommendation, newInspectionKey, summarizeInspection, treadStatus, validateInspection, type FuelLevel, type InspectionHeader, type InspectionMedia, type InspectionPoint, type InspectionRules, type InspectionStatus, type InspectionValidation, type TreadThresholds, } from './inspection' export interface InspectionUploadResult { url: string /** Si el host no lo dice, se deduce del archivo. */ type?: 'photo' | 'video' } export interface InspectionChecklistProps { points: InspectionPoint[] onChange: (points: InspectionPoint[]) => void /** Encabezado de recepción (km, combustible, daños). Sin él no se muestra. */ header?: InspectionHeader onHeaderChange?: (header: InspectionHeader) => void /** * Sube un archivo al almacenamiento del host (el mismo `POST /upload` que * usan los campos `upload`) y devuelve su URL. Sin él no hay botón de foto. */ onUpload?: (file: File, point: InspectionPoint | null) => Promise /** Emite la validación tras cada cambio (para bloquear «Guardar»/«Enviar»). */ onValidate?: (v: InspectionValidation) => void rules?: InspectionRules /** Umbrales de profundidad de dibujo (mm). */ treadThresholds?: TreadThresholds /** Errores del servidor `"<índice>."`. */ serverErrors?: Record /** Muestra la recomendación y su precio. Default true. */ allowRecommendations?: boolean /** Permite agregar/quitar puntos a mano. Default false (los pone la plantilla). */ allowCustomPoints?: boolean /** Solo lectura: sin edición, para revisar una inspección enviada o cerrada. */ readOnly?: boolean currency?: string /** Botón «Agregar al presupuesto» sobre las recomendaciones (recibe los puntos). */ onAddToBudget?: (points: InspectionPoint[]) => void } const STATUS_STYLE: Record = { pending: 'border-border text-muted-foreground', ok: 'border-emerald-500/50 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300', attention: 'border-amber-500/50 bg-amber-500/15 text-amber-700 dark:text-amber-300', urgent: 'border-red-500/50 bg-red-500/15 text-red-700 dark:text-red-300', } const STATUS_DOT: Record = { pending: 'bg-muted-foreground/40', ok: 'bg-emerald-500', attention: 'bg-amber-500', urgent: 'bg-red-500', } const LIGHTS: InspectionStatus[] = ['ok', 'attention', 'urgent'] function statusLabel(t: (k: string, o: { defaultValue: string }) => string, s: InspectionStatus): string { switch (s) { case 'ok': return t('inspection.status.ok', { defaultValue: 'Bien' }) case 'attention': return t('inspection.status.attention', { defaultValue: 'Atención' }) case 'urgent': return t('inspection.status.urgent', { defaultValue: 'Urgente' }) default: return t('inspection.status.pending', { defaultValue: 'Sin revisar' }) } } function fuelLabel(t: (k: string, o: { defaultValue: string }) => string, f: FuelLevel): string { switch (f) { case 'empty': return t('inspection.fuel.empty', { defaultValue: 'Reserva' }) case 'quarter': return t('inspection.fuel.quarter', { defaultValue: '1/4' }) case 'half': return t('inspection.fuel.half', { defaultValue: '1/2' }) case 'three_quarters': return t('inspection.fuel.three_quarters', { defaultValue: '3/4' }) default: return t('inspection.fuel.full', { defaultValue: 'Lleno' }) } } function mediaTypeOf(file: File, hint?: 'photo' | 'video'): 'photo' | 'video' { return hint ?? (file.type.startsWith('video/') ? 'video' : 'photo') } export function InspectionChecklist({ points, onChange, header, onHeaderChange, onUpload, onValidate, rules, treadThresholds = DEFAULT_TREAD_THRESHOLDS, serverErrors, allowRecommendations = true, allowCustomPoints = false, readOnly = false, currency, onAddToBudget, }: InspectionChecklistProps) { const { t } = useTranslation() const fmt = useFormatter({ currency }) const [uploading, setUploading] = useState(null) const [uploadError, setUploadError] = useState(null) const validation = useMemo(() => validateInspection(points, header, rules), [points, header, rules]) const errors = { ...validation.errors, ...serverErrors } const summary = useMemo(() => summarizeInspection(points), [points]) const sections = useMemo(() => { const order: string[] = [] const map = new Map() points.forEach((p, i) => { const s = p.section || '' if (!map.has(s)) { map.set(s, []) order.push(s) } map.get(s)!.push(i) }) return order.map((s) => ({ section: s, indexes: map.get(s)! })) }, [points]) const emit = (next: InspectionPoint[]) => { onChange(next) onValidate?.(validateInspection(next, header, rules)) } const patch = (i: number, partial: Partial) => emit(points.map((p, j) => (j === i ? { ...p, ...partial } : p))) const setValue = (i: number, raw: string) => { const p = points[i] const value = raw.trim() === '' ? null : Number(raw.replace(',', '.')) const v = value != null && Number.isFinite(value) ? value : null // La profundidad de dibujo fija el semáforo; el técnico aún puede subirlo. if (p.kind === 'tread') patch(i, { value: v, status: treadStatus(v, treadThresholds) }) else patch(i, { value: v }) } const setStatus = (i: number, status: InspectionStatus) => { const p = points[i] // Verde no lleva recomendación: se limpia para no dejar un renglón huérfano. patch(i, status === 'ok' ? { status, recommendation: '', unit_price: 0, quantity: 1 } : { status: p.status === status ? 'pending' : status }) } const upload = async (i: number, file: File) => { if (!onUpload) return const p = points[i] setUploading(p.key) setUploadError(null) try { const res = await onUpload(file, p) const media: InspectionMedia = { key: newInspectionKey('im'), url: res.url, type: mediaTypeOf(file, res.type) } patch(i, { media: [...p.media, media] }) } catch (e) { setUploadError(e instanceof Error ? e.message : t('inspection.uploadError', { defaultValue: 'No se pudo subir el archivo' })) } finally { setUploading(null) } } const setHeader = (partial: Partial) => { if (!header) return const next = { ...header, ...partial } onHeaderChange?.(next) onValidate?.(validateInspection(points, next, rules)) } const addPoint = (section: string) => emit([...points, { key: newInspectionKey(), section, name: '', kind: 'check', status: 'pending', notes: '', recommendation: '', quantity: 1, unit_price: 0, media: [] }]) return (
{header && (
{t('inspection.fuel.label', { defaultValue: 'Nivel de combustible' })}
{FUEL_LEVELS.map((f) => ( ))}