@asteby/metacore-runtime-react 46.1.0 → 46.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,4 +65,21 @@ export const BUSINESS_COMPONENTS: readonly BusinessComponentSpec[] = [
65
65
  usedBy: ['POS', 'Pedido', 'Factura', 'RMA', 'OT'],
66
66
  helpers: ['computeReturnTotals', 'validateReturnChoices', 'creditNoteRelation', 'returnSteps', 'serializeReturn'],
67
67
  },
68
+ {
69
+ name: 'InspectionChecklist',
70
+ value: 'points: InspectionPoint[] (+ header: InspectionHeader)',
71
+ events: ['onChange', 'onHeaderChange', 'onUpload', 'onValidate', 'onAddToBudget'],
72
+ permissions: [],
73
+ usedBy: ['Recepción de vehículo', 'OT', 'Alineación', 'Garantía'],
74
+ helpers: [
75
+ 'makeInspectionPoint',
76
+ 'treadStatus',
77
+ 'worstStatus',
78
+ 'summarizeInspection',
79
+ 'validateInspection',
80
+ 'serializeInspectionPoints',
81
+ 'parseInspectionPoints',
82
+ 'recommendationsToLines',
83
+ ],
84
+ },
68
85
  ] as const
@@ -13,3 +13,5 @@ export * from './customer-picker'
13
13
  export * from './catalog'
14
14
  export * from './return'
15
15
  export * from './return-wizard'
16
+ export * from './inspection'
17
+ export * from './inspection-checklist'
@@ -0,0 +1,457 @@
1
+ // InspectionChecklist — plantilla de puntos con semáforo, medición, fotos/video
2
+ // por punto y recomendación → renglón del presupuesto (benchmark §7, TAL-4/5;
3
+ // Shopmonkey S39, Tekmetric S36). Controlado: `points` + `onChange`. Sirve a la
4
+ // recepción del vehículo (encabezado con km/combustible/daños), a la inspección
5
+ // multipunto de la OT, a la hoja de alineación y a la garantía.
6
+ import { useMemo, useRef, useState } from 'react'
7
+ import { useTranslation } from 'react-i18next'
8
+ import { Camera, Loader2, Trash2, Video } from 'lucide-react'
9
+ import { Badge, Button, Input, Textarea } from '@asteby/metacore-ui'
10
+ import { useFormatter } from './format'
11
+ import {
12
+ DEFAULT_TREAD_THRESHOLDS,
13
+ FUEL_LEVELS,
14
+ hasRecommendation,
15
+ newInspectionKey,
16
+ summarizeInspection,
17
+ treadStatus,
18
+ validateInspection,
19
+ type FuelLevel,
20
+ type InspectionHeader,
21
+ type InspectionMedia,
22
+ type InspectionPoint,
23
+ type InspectionRules,
24
+ type InspectionStatus,
25
+ type InspectionValidation,
26
+ type TreadThresholds,
27
+ } from './inspection'
28
+
29
+ export interface InspectionUploadResult {
30
+ url: string
31
+ /** Si el host no lo dice, se deduce del archivo. */
32
+ type?: 'photo' | 'video'
33
+ }
34
+
35
+ export interface InspectionChecklistProps {
36
+ points: InspectionPoint[]
37
+ onChange: (points: InspectionPoint[]) => void
38
+ /** Encabezado de recepción (km, combustible, daños). Sin él no se muestra. */
39
+ header?: InspectionHeader
40
+ onHeaderChange?: (header: InspectionHeader) => void
41
+ /**
42
+ * Sube un archivo al almacenamiento del host (el mismo `POST /upload` que
43
+ * usan los campos `upload`) y devuelve su URL. Sin él no hay botón de foto.
44
+ */
45
+ onUpload?: (file: File, point: InspectionPoint | null) => Promise<InspectionUploadResult>
46
+ /** Emite la validación tras cada cambio (para bloquear «Guardar»/«Enviar»). */
47
+ onValidate?: (v: InspectionValidation) => void
48
+ rules?: InspectionRules
49
+ /** Umbrales de profundidad de dibujo (mm). */
50
+ treadThresholds?: TreadThresholds
51
+ /** Errores del servidor `"<índice>.<campo>"`. */
52
+ serverErrors?: Record<string, string>
53
+ /** Muestra la recomendación y su precio. Default true. */
54
+ allowRecommendations?: boolean
55
+ /** Permite agregar/quitar puntos a mano. Default false (los pone la plantilla). */
56
+ allowCustomPoints?: boolean
57
+ /** Solo lectura: sin edición, para revisar una inspección enviada o cerrada. */
58
+ readOnly?: boolean
59
+ currency?: string
60
+ /** Botón «Agregar al presupuesto» sobre las recomendaciones (recibe los puntos). */
61
+ onAddToBudget?: (points: InspectionPoint[]) => void
62
+ }
63
+
64
+ const STATUS_STYLE: Record<InspectionStatus, string> = {
65
+ pending: 'border-border text-muted-foreground',
66
+ ok: 'border-emerald-500/50 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
67
+ attention: 'border-amber-500/50 bg-amber-500/15 text-amber-700 dark:text-amber-300',
68
+ urgent: 'border-red-500/50 bg-red-500/15 text-red-700 dark:text-red-300',
69
+ }
70
+
71
+ const STATUS_DOT: Record<InspectionStatus, string> = {
72
+ pending: 'bg-muted-foreground/40',
73
+ ok: 'bg-emerald-500',
74
+ attention: 'bg-amber-500',
75
+ urgent: 'bg-red-500',
76
+ }
77
+
78
+ const LIGHTS: InspectionStatus[] = ['ok', 'attention', 'urgent']
79
+
80
+ function statusLabel(t: (k: string, o: { defaultValue: string }) => string, s: InspectionStatus): string {
81
+ switch (s) {
82
+ case 'ok':
83
+ return t('inspection.status.ok', { defaultValue: 'Bien' })
84
+ case 'attention':
85
+ return t('inspection.status.attention', { defaultValue: 'Atención' })
86
+ case 'urgent':
87
+ return t('inspection.status.urgent', { defaultValue: 'Urgente' })
88
+ default:
89
+ return t('inspection.status.pending', { defaultValue: 'Sin revisar' })
90
+ }
91
+ }
92
+
93
+ function fuelLabel(t: (k: string, o: { defaultValue: string }) => string, f: FuelLevel): string {
94
+ switch (f) {
95
+ case 'empty':
96
+ return t('inspection.fuel.empty', { defaultValue: 'Reserva' })
97
+ case 'quarter':
98
+ return t('inspection.fuel.quarter', { defaultValue: '1/4' })
99
+ case 'half':
100
+ return t('inspection.fuel.half', { defaultValue: '1/2' })
101
+ case 'three_quarters':
102
+ return t('inspection.fuel.three_quarters', { defaultValue: '3/4' })
103
+ default:
104
+ return t('inspection.fuel.full', { defaultValue: 'Lleno' })
105
+ }
106
+ }
107
+
108
+ function mediaTypeOf(file: File, hint?: 'photo' | 'video'): 'photo' | 'video' {
109
+ return hint ?? (file.type.startsWith('video/') ? 'video' : 'photo')
110
+ }
111
+
112
+ export function InspectionChecklist({
113
+ points,
114
+ onChange,
115
+ header,
116
+ onHeaderChange,
117
+ onUpload,
118
+ onValidate,
119
+ rules,
120
+ treadThresholds = DEFAULT_TREAD_THRESHOLDS,
121
+ serverErrors,
122
+ allowRecommendations = true,
123
+ allowCustomPoints = false,
124
+ readOnly = false,
125
+ currency,
126
+ onAddToBudget,
127
+ }: InspectionChecklistProps) {
128
+ const { t } = useTranslation()
129
+ const fmt = useFormatter({ currency })
130
+ const [uploading, setUploading] = useState<string | null>(null)
131
+ const [uploadError, setUploadError] = useState<string | null>(null)
132
+ const validation = useMemo(() => validateInspection(points, header, rules), [points, header, rules])
133
+ const errors = { ...validation.errors, ...serverErrors }
134
+ const summary = useMemo(() => summarizeInspection(points), [points])
135
+
136
+ const sections = useMemo(() => {
137
+ const order: string[] = []
138
+ const map = new Map<string, number[]>()
139
+ points.forEach((p, i) => {
140
+ const s = p.section || ''
141
+ if (!map.has(s)) {
142
+ map.set(s, [])
143
+ order.push(s)
144
+ }
145
+ map.get(s)!.push(i)
146
+ })
147
+ return order.map((s) => ({ section: s, indexes: map.get(s)! }))
148
+ }, [points])
149
+
150
+ const emit = (next: InspectionPoint[]) => {
151
+ onChange(next)
152
+ onValidate?.(validateInspection(next, header, rules))
153
+ }
154
+ const patch = (i: number, partial: Partial<InspectionPoint>) =>
155
+ emit(points.map((p, j) => (j === i ? { ...p, ...partial } : p)))
156
+
157
+ const setValue = (i: number, raw: string) => {
158
+ const p = points[i]
159
+ const value = raw.trim() === '' ? null : Number(raw.replace(',', '.'))
160
+ const v = value != null && Number.isFinite(value) ? value : null
161
+ // La profundidad de dibujo fija el semáforo; el técnico aún puede subirlo.
162
+ if (p.kind === 'tread') patch(i, { value: v, status: treadStatus(v, treadThresholds) })
163
+ else patch(i, { value: v })
164
+ }
165
+
166
+ const setStatus = (i: number, status: InspectionStatus) => {
167
+ const p = points[i]
168
+ // Verde no lleva recomendación: se limpia para no dejar un renglón huérfano.
169
+ patch(i, status === 'ok' ? { status, recommendation: '', unit_price: 0, quantity: 1 } : { status: p.status === status ? 'pending' : status })
170
+ }
171
+
172
+ const upload = async (i: number, file: File) => {
173
+ if (!onUpload) return
174
+ const p = points[i]
175
+ setUploading(p.key)
176
+ setUploadError(null)
177
+ try {
178
+ const res = await onUpload(file, p)
179
+ const media: InspectionMedia = { key: newInspectionKey('im'), url: res.url, type: mediaTypeOf(file, res.type) }
180
+ patch(i, { media: [...p.media, media] })
181
+ } catch (e) {
182
+ setUploadError(e instanceof Error ? e.message : t('inspection.uploadError', { defaultValue: 'No se pudo subir el archivo' }))
183
+ } finally {
184
+ setUploading(null)
185
+ }
186
+ }
187
+
188
+ const setHeader = (partial: Partial<InspectionHeader>) => {
189
+ if (!header) return
190
+ const next = { ...header, ...partial }
191
+ onHeaderChange?.(next)
192
+ onValidate?.(validateInspection(points, next, rules))
193
+ }
194
+
195
+ const addPoint = (section: string) =>
196
+ emit([...points, { key: newInspectionKey(), section, name: '', kind: 'check', status: 'pending', notes: '', recommendation: '', quantity: 1, unit_price: 0, media: [] }])
197
+
198
+ return (
199
+ <div data-slot="inspection-checklist" className="space-y-4">
200
+ {header && (
201
+ <div data-slot="inspection-header" className="grid gap-3 rounded-lg border bg-muted/20 p-3 sm:grid-cols-2">
202
+ <label className="space-y-1 text-sm">
203
+ <span className="text-xs font-medium text-muted-foreground">{t('inspection.odometer', { defaultValue: 'Kilometraje' })}</span>
204
+ <Input
205
+ inputMode="numeric"
206
+ value={header.odometer_km == null ? '' : String(header.odometer_km)}
207
+ disabled={readOnly}
208
+ onChange={(e) => {
209
+ const n = Number(e.target.value.replace(/[^\d.]/g, ''))
210
+ setHeader({ odometer_km: e.target.value.trim() === '' || !Number.isFinite(n) ? null : n })
211
+ }}
212
+ />
213
+ </label>
214
+ <div className="space-y-1 text-sm">
215
+ <span className="text-xs font-medium text-muted-foreground">{t('inspection.fuel.label', { defaultValue: 'Nivel de combustible' })}</span>
216
+ <div role="radiogroup" className="flex flex-wrap gap-1.5">
217
+ {FUEL_LEVELS.map((f) => (
218
+ <Button
219
+ key={f}
220
+ type="button"
221
+ size="sm"
222
+ role="radio"
223
+ aria-checked={header.fuel_level === f}
224
+ variant={header.fuel_level === f ? 'default' : 'outline'}
225
+ disabled={readOnly}
226
+ onClick={() => setHeader({ fuel_level: header.fuel_level === f ? '' : f })}
227
+ >
228
+ {fuelLabel(t, f)}
229
+ </Button>
230
+ ))}
231
+ </div>
232
+ </div>
233
+ <label className="space-y-1 text-sm sm:col-span-2">
234
+ <span className="text-xs font-medium text-muted-foreground">{t('inspection.damages', { defaultValue: 'Daños visibles al recibirlo' })}</span>
235
+ <Textarea
236
+ rows={2}
237
+ value={header.damage_notes}
238
+ disabled={readOnly}
239
+ placeholder={t('inspection.damagesPlaceholder', { defaultValue: 'Golpes, rayones, faltantes...' })}
240
+ onChange={(e) => setHeader({ damage_notes: e.target.value })}
241
+ />
242
+ </label>
243
+ </div>
244
+ )}
245
+
246
+ {sections.map(({ section, indexes }) => (
247
+ <section key={section || '_'} aria-label={section} className="space-y-2">
248
+ {section && <h4 className="text-sm font-semibold">{section}</h4>}
249
+ <div className="space-y-2">
250
+ {indexes.map((i) => {
251
+ const p = points[i]
252
+ const needsValue = p.kind === 'tread' || p.kind === 'measure'
253
+ const issue = p.status === 'attention' || p.status === 'urgent'
254
+ return (
255
+ <div key={p.key} data-slot="inspection-point" data-status={p.status} className="space-y-2 rounded-lg border p-3">
256
+ <div className="flex flex-wrap items-center gap-2">
257
+ <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${STATUS_DOT[p.status]}`} aria-hidden />
258
+ {allowCustomPoints && !readOnly ? (
259
+ <Input
260
+ className="h-8 max-w-56"
261
+ value={p.name}
262
+ aria-invalid={!!errors[`${i}.name`]}
263
+ onChange={(e) => patch(i, { name: e.target.value })}
264
+ />
265
+ ) : (
266
+ <span className="min-w-0 flex-1 text-sm font-medium">{p.name}</span>
267
+ )}
268
+ {needsValue && (
269
+ <div className="flex items-center gap-1">
270
+ <Input
271
+ inputMode="decimal"
272
+ className="h-8 w-20 text-right"
273
+ value={p.value == null ? '' : String(p.value)}
274
+ disabled={readOnly}
275
+ aria-label={`${p.name} (${p.unit ?? ''})`}
276
+ aria-invalid={!!errors[`${i}.value`]}
277
+ onChange={(e) => setValue(i, e.target.value)}
278
+ />
279
+ <span className="text-xs text-muted-foreground">{p.unit ?? (p.kind === 'tread' ? 'mm' : '')}</span>
280
+ </div>
281
+ )}
282
+ <div role="radiogroup" aria-label={p.name} className="flex gap-1">
283
+ {LIGHTS.map((s) => (
284
+ <button
285
+ key={s}
286
+ type="button"
287
+ role="radio"
288
+ aria-checked={p.status === s}
289
+ disabled={readOnly}
290
+ onClick={() => setStatus(i, s)}
291
+ className={`rounded-full border px-2.5 py-1 text-xs transition ${
292
+ p.status === s ? STATUS_STYLE[s] : 'border-border text-muted-foreground hover:bg-muted'
293
+ } disabled:opacity-60`}
294
+ >
295
+ {statusLabel(t, s)}
296
+ </button>
297
+ ))}
298
+ </div>
299
+ {allowCustomPoints && !readOnly && (
300
+ <Button
301
+ type="button"
302
+ variant="ghost"
303
+ size="sm"
304
+ className="h-8 w-8 p-0"
305
+ aria-label={t('inspection.removePoint', { defaultValue: 'Quitar punto' })}
306
+ onClick={() => emit(points.filter((_, j) => j !== i))}
307
+ >
308
+ <Trash2 className="h-4 w-4" />
309
+ </Button>
310
+ )}
311
+ </div>
312
+
313
+ {(issue || p.notes || readOnly === false) && (
314
+ <Textarea
315
+ rows={1}
316
+ value={p.notes}
317
+ disabled={readOnly}
318
+ aria-invalid={!!errors[`${i}.notes`]}
319
+ placeholder={t('inspection.notes', { defaultValue: 'Observaciones' })}
320
+ onChange={(e) => patch(i, { notes: e.target.value })}
321
+ />
322
+ )}
323
+ {errors[`${i}.notes`] && <p className="text-xs text-destructive">{errors[`${i}.notes`]}</p>}
324
+ {errors[`${i}.value`] && <p className="text-xs text-destructive">{errors[`${i}.value`]}</p>}
325
+
326
+ {allowRecommendations && issue && (
327
+ <div className="grid gap-2 rounded-md bg-muted/30 p-2 sm:grid-cols-[1fr_5rem_7rem]">
328
+ <Input
329
+ value={p.recommendation}
330
+ disabled={readOnly}
331
+ aria-invalid={!!errors[`${i}.recommendation`]}
332
+ placeholder={t('inspection.recommendation', { defaultValue: 'Trabajo recomendado' })}
333
+ onChange={(e) => patch(i, { recommendation: e.target.value })}
334
+ />
335
+ <Input
336
+ inputMode="decimal"
337
+ className="text-right"
338
+ value={String(p.quantity)}
339
+ disabled={readOnly || !hasRecommendation(p)}
340
+ aria-label={t('inspection.quantity', { defaultValue: 'Cantidad' })}
341
+ onChange={(e) => patch(i, { quantity: Number(e.target.value.replace(',', '.')) || 0 })}
342
+ />
343
+ <Input
344
+ inputMode="decimal"
345
+ className="text-right"
346
+ value={String(p.unit_price)}
347
+ disabled={readOnly || !hasRecommendation(p)}
348
+ aria-label={t('inspection.unitPrice', { defaultValue: 'Precio' })}
349
+ aria-invalid={!!errors[`${i}.unit_price`]}
350
+ onChange={(e) => patch(i, { unit_price: Number(e.target.value.replace(',', '.')) || 0 })}
351
+ />
352
+ {errors[`${i}.recommendation`] && (
353
+ <p className="text-xs text-destructive sm:col-span-3">{errors[`${i}.recommendation`]}</p>
354
+ )}
355
+ </div>
356
+ )}
357
+
358
+ <div className="flex flex-wrap items-center gap-2">
359
+ {p.media.map((m) => (
360
+ <span key={m.key} className="group relative">
361
+ {m.type === 'video' ? (
362
+ <video src={m.url} className="h-14 w-20 rounded border object-cover" controls preload="metadata" />
363
+ ) : (
364
+ <img src={m.url} alt={m.caption ?? p.name} className="h-14 w-14 rounded border object-cover" />
365
+ )}
366
+ {!readOnly && (
367
+ <button
368
+ type="button"
369
+ aria-label={t('inspection.removeMedia', { defaultValue: 'Quitar archivo' })}
370
+ className="absolute -right-1 -top-1 hidden h-5 w-5 items-center justify-center rounded-full bg-background text-xs shadow group-hover:flex"
371
+ onClick={() => patch(i, { media: p.media.filter((x) => x.key !== m.key) })}
372
+ >
373
+ ×
374
+ </button>
375
+ )}
376
+ </span>
377
+ ))}
378
+ {!readOnly && onUpload && <MediaPick busy={uploading === p.key} onFile={(f) => upload(i, f)} />}
379
+ {p.approval && p.approval !== 'none' && (
380
+ <Badge variant="outline" className="ml-auto">
381
+ {p.approval === 'approved'
382
+ ? t('inspection.approval.approved', { defaultValue: 'Aprobado por el cliente' })
383
+ : p.approval === 'declined'
384
+ ? t('inspection.approval.declined', { defaultValue: 'Rechazado por el cliente' })
385
+ : t('inspection.approval.pending', { defaultValue: 'Esperando al cliente' })}
386
+ </Badge>
387
+ )}
388
+ </div>
389
+ </div>
390
+ )
391
+ })}
392
+ </div>
393
+ {allowCustomPoints && !readOnly && (
394
+ <Button type="button" variant="outline" size="sm" onClick={() => addPoint(section)}>
395
+ {t('inspection.addPoint', { defaultValue: 'Agregar punto' })}
396
+ </Button>
397
+ )}
398
+ </section>
399
+ ))}
400
+
401
+ {uploadError && <p role="alert" className="text-sm text-destructive">{uploadError}</p>}
402
+ {validation.form && points.length > 0 && <p role="alert" className="text-sm text-destructive">{validation.form}</p>}
403
+
404
+ <div data-slot="inspection-summary" className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/20 p-3 text-sm">
405
+ {LIGHTS.map((s) => (
406
+ <Badge key={s} variant="outline" className={STATUS_STYLE[s]}>
407
+ {statusLabel(t, s)}: {summary.counts[s]}
408
+ </Badge>
409
+ ))}
410
+ {summary.pending > 0 && (
411
+ <Badge variant="outline" className={STATUS_STYLE.pending}>
412
+ {statusLabel(t, 'pending')}: {summary.pending}
413
+ </Badge>
414
+ )}
415
+ {allowRecommendations && summary.recommendations > 0 && (
416
+ <span className="ml-auto font-medium">
417
+ {t('inspection.recommendedTotal', { defaultValue: 'Recomendado' })}: {fmt.money(summary.recommendedTotal)}
418
+ </span>
419
+ )}
420
+ {onAddToBudget && !readOnly && summary.recommendations > 0 && (
421
+ <Button type="button" size="sm" onClick={() => onAddToBudget(points.filter(hasRecommendation))}>
422
+ {t('inspection.addToBudget', { defaultValue: 'Agregar al presupuesto' })}
423
+ </Button>
424
+ )}
425
+ </div>
426
+ </div>
427
+ )
428
+ }
429
+
430
+ /** Botón de foto/video: abre la cámara en móvil, el selector de archivos en escritorio. */
431
+ function MediaPick({ busy, onFile }: { busy: boolean; onFile: (f: File) => void }) {
432
+ const { t } = useTranslation()
433
+ const ref = useRef<HTMLInputElement | null>(null)
434
+ return (
435
+ <>
436
+ <input
437
+ ref={ref}
438
+ type="file"
439
+ accept="image/*,video/*"
440
+ capture="environment"
441
+ className="sr-only"
442
+ tabIndex={-1}
443
+ aria-hidden="true"
444
+ onChange={(e) => {
445
+ const f = e.target.files?.[0]
446
+ if (ref.current) ref.current.value = ''
447
+ if (f) onFile(f)
448
+ }}
449
+ />
450
+ <Button type="button" variant="outline" size="sm" disabled={busy} onClick={() => ref.current?.click()}>
451
+ {busy ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Camera className="mr-1.5 h-4 w-4" />}
452
+ {t('inspection.addMedia', { defaultValue: 'Foto o video' })}
453
+ <Video className="ml-1.5 h-3.5 w-3.5 opacity-60" aria-hidden />
454
+ </Button>
455
+ </>
456
+ )
457
+ }
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ makeInspectionPoint,
4
+ parseInspectionPoints,
5
+ recommendationsToLines,
6
+ serializeInspectionPoints,
7
+ summarizeInspection,
8
+ treadStatus,
9
+ validateInspection,
10
+ worstStatus,
11
+ } from './inspection'
12
+
13
+ const pt = (p: Parameters<typeof makeInspectionPoint>[0]) => makeInspectionPoint({ section: 'Llantas', ...p })
14
+
15
+ describe('inspection · semáforo', () => {
16
+ it('la profundidad de dibujo fija el color con los umbrales dados', () => {
17
+ expect(treadStatus(null)).toBe('pending')
18
+ expect(treadStatus(1.5)).toBe('urgent')
19
+ expect(treadStatus(1.6)).toBe('attention')
20
+ expect(treadStatus(2.9)).toBe('attention')
21
+ expect(treadStatus(3)).toBe('ok')
22
+ expect(treadStatus(4, { urgent: 2, attention: 5 })).toBe('attention')
23
+ })
24
+ it('el peor estado gana: rojo > amarillo > verde > pendiente', () => {
25
+ expect(worstStatus([])).toBe('pending')
26
+ expect(worstStatus([pt({ status: 'ok' }), pt({ status: 'attention' })])).toBe('attention')
27
+ expect(worstStatus([pt({ status: 'urgent' }), pt({ status: 'attention' }), pt({ status: 'ok' })])).toBe('urgent')
28
+ })
29
+ it('resume conteos y total recomendado (solo puntos con recomendación)', () => {
30
+ const s = summarizeInspection([
31
+ pt({ name: 'a', status: 'ok' }),
32
+ pt({ name: 'b', status: 'attention', recommendation: 'Cambiar balatas', quantity: 2, unit_price: 350.5 }),
33
+ pt({ name: 'c', status: 'urgent', notes: 'sin recomendación', unit_price: 999 }),
34
+ pt({ name: 'd' }),
35
+ ])
36
+ expect(s.counts).toEqual({ pending: 1, ok: 1, attention: 1, urgent: 1 })
37
+ expect(s.worst).toBe('urgent')
38
+ expect(s.recommendations).toBe(1)
39
+ expect(s.recommendedTotal).toBe(701)
40
+ })
41
+ })
42
+
43
+ describe('inspection · validación', () => {
44
+ it('un punto en verde no lleva recomendación y no hay medidas negativas', () => {
45
+ const v = validateInspection([
46
+ pt({ name: 'ok', status: 'ok', recommendation: 'algo' }),
47
+ pt({ name: 'mm', kind: 'tread', value: -1, status: 'urgent' }),
48
+ ])
49
+ expect(v.ok).toBe(false)
50
+ expect(v.errors['0.recommendation']).toBeTruthy()
51
+ expect(v.errors['1.value']).toBeTruthy()
52
+ })
53
+ it('exige revisar todo y el kilometraje solo cuando la regla lo pide', () => {
54
+ const points = [pt({ name: 'a', status: 'ok' }), pt({ name: 'b' })]
55
+ expect(validateInspection(points).ok).toBe(true)
56
+ expect(validateInspection(points, undefined, { requireAllReviewed: true }).form).toMatch(/revisar/)
57
+ expect(validateInspection([], { damage_notes: '' }, { requireOdometer: true }).ok).toBe(false)
58
+ expect(validateInspection([], { damage_notes: '', odometer_km: 0 }, { requireOdometer: true }).ok).toBe(true)
59
+ })
60
+ it('una alerta sin nota, recomendación ni foto se marca si la regla lo pide', () => {
61
+ const v = validateInspection([pt({ name: 'a', status: 'urgent' })], undefined, { requireEvidenceOnIssues: true })
62
+ expect(v.errors['0.notes']).toBeTruthy()
63
+ const withPhoto = pt({ name: 'a', status: 'urgent', media: [{ key: 'm', url: '/x.jpg', type: 'photo' }] })
64
+ expect(validateInspection([withPhoto], undefined, { requireEvidenceOnIssues: true }).ok).toBe(true)
65
+ })
66
+ })
67
+
68
+ describe('inspection · serialización', () => {
69
+ it('serializa números reales, line_no 1-based y sin claves de UI', () => {
70
+ const [row] = serializeInspectionPoints([
71
+ pt({
72
+ id: 'abc',
73
+ name: ' Llanta FL ',
74
+ kind: 'tread',
75
+ value: 2.4,
76
+ unit: 'mm',
77
+ status: 'attention',
78
+ recommendation: ' Cambiar ',
79
+ quantity: 1,
80
+ unit_price: 1800,
81
+ media: [{ key: 'k', url: '/u/1.jpg', type: 'photo' }],
82
+ }),
83
+ ])
84
+ expect(row).toMatchObject({
85
+ id: 'abc',
86
+ line_no: 1,
87
+ name: 'Llanta FL',
88
+ measure_value: 2.4,
89
+ unit: 'mm',
90
+ recommendation: 'Cambiar',
91
+ unit_price: 1800,
92
+ media: [{ url: '/u/1.jpg', type: 'photo' }],
93
+ })
94
+ expect(row).not.toHaveProperty('key')
95
+ const [empty] = serializeInspectionPoints([pt({ name: 'x' })])
96
+ expect(empty).not.toHaveProperty('measure_value')
97
+ expect(empty).not.toHaveProperty('id')
98
+ })
99
+ it('parse es la inversa y tolera basura', () => {
100
+ expect(parseInspectionPoints(null)).toEqual([])
101
+ const [p] = parseInspectionPoints([
102
+ { id: 'i', name: 'Frenos', section: 'Frenos', kind: 'measure', status: 'urgent', measure_value: '3.5', unit: 'mm', quantity: '2', unit_price: '10.5', approval: 'declined', media: [{ url: '/v.mp4', type: 'video' }] },
103
+ ])
104
+ expect(p).toMatchObject({ id: 'i', kind: 'measure', status: 'urgent', value: 3.5, quantity: 2, unit_price: 10.5, approval: 'declined' })
105
+ expect(p.media[0]).toMatchObject({ url: '/v.mp4', type: 'video' })
106
+ const [bad] = parseInspectionPoints([{ status: 'rojo', kind: 'raro' }])
107
+ expect(bad.status).toBe('pending')
108
+ expect(bad.kind).toBe('check')
109
+ })
110
+ })
111
+
112
+ describe('inspection · recomendación → renglón', () => {
113
+ it('convierte solo los puntos con recomendación y filtra por respuesta del cliente', () => {
114
+ const points = [
115
+ pt({ name: 'Balatas', recommendation: 'Cambiar', quantity: 2, unit_price: 400, status: 'urgent', approval: 'approved' }),
116
+ pt({ name: 'Aceite', recommendation: 'Cambio', unit_price: 900, status: 'attention', approval: 'declined' }),
117
+ pt({ name: 'Luces', status: 'ok' }),
118
+ ]
119
+ const all = recommendationsToLines(points)
120
+ expect(all.map((l) => l.description)).toEqual(['Balatas — Cambiar', 'Aceite — Cambio'])
121
+ expect(all[0]).toMatchObject({ kind: 'item', quantity: 2, unit_price: 400 })
122
+ expect(recommendationsToLines(points, { approval: ['approved'] })).toHaveLength(1)
123
+ expect(recommendationsToLines(points, { approval: ['declined'] })[0].description).toBe('Aceite — Cambio')
124
+ })
125
+ })