@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.
- package/CHANGELOG.md +6 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +17 -0
- package/dist/business/index.d.ts +2 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +2 -0
- package/dist/business/inspection-checklist.d.ts +36 -0
- package/dist/business/inspection-checklist.d.ts.map +1 -0
- package/dist/business/inspection-checklist.js +147 -0
- package/dist/business/inspection.d.ts +109 -0
- package/dist/business/inspection.d.ts.map +1 -0
- package/dist/business/inspection.js +176 -0
- package/package.json +1 -1
- package/src/__tests__/inspection-checklist.test.tsx +70 -0
- package/src/business/catalog.ts +17 -0
- package/src/business/index.ts +2 -0
- package/src/business/inspection-checklist.tsx +457 -0
- package/src/business/inspection.test.ts +125 -0
- package/src/business/inspection.ts +281 -0
package/src/business/catalog.ts
CHANGED
|
@@ -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
|
package/src/business/index.ts
CHANGED
|
@@ -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
|
+
})
|