@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 46.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e61ec17: `InspectionChecklist` compartido (benchmark §7, TAL-4/TAL-5): plantilla de puntos con semáforo, profundidad de dibujo por llanta, fotos y video por punto (subida por el host vía `onUpload`), recomendación → renglón del presupuesto y encabezado de recepción (kilometraje, combustible, daños). Incluye la lógica pura (`treadStatus`, `summarizeInspection`, `validateInspection`, `serializeInspectionPoints`, `parseInspectionPoints`, `recommendationsToLines`) y la entrada en `BUSINESS_COMPONENTS`.
|
|
8
|
+
|
|
3
9
|
## 46.1.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../src/business/catalog.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,qBAAqB;IAClC,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAA;IACZ,yCAAyC;IACzC,KAAK,EAAE,MAAM,CAAA;IACb,uCAAuC;IACvC,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,4EAA4E;IAC5E,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,kDAAkD;IAClD,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,uDAAuD;IACvD,OAAO,EAAE,MAAM,EAAE,CAAA;CACpB;AAED,eAAO,MAAM,mBAAmB,EAAE,SAAS,qBAAqB,
|
|
1
|
+
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../src/business/catalog.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,qBAAqB;IAClC,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAA;IACZ,yCAAyC;IACzC,KAAK,EAAE,MAAM,CAAA;IACb,uCAAuC;IACvC,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,4EAA4E;IAC5E,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,kDAAkD;IAClD,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,uDAAuD;IACvD,OAAO,EAAE,MAAM,EAAE,CAAA;CACpB;AAED,eAAO,MAAM,mBAAmB,EAAE,SAAS,qBAAqB,EAkEtD,CAAA"}
|
package/dist/business/catalog.js
CHANGED
|
@@ -47,4 +47,21 @@ export const BUSINESS_COMPONENTS = [
|
|
|
47
47
|
usedBy: ['POS', 'Pedido', 'Factura', 'RMA', 'OT'],
|
|
48
48
|
helpers: ['computeReturnTotals', 'validateReturnChoices', 'creditNoteRelation', 'returnSteps', 'serializeReturn'],
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
name: 'InspectionChecklist',
|
|
52
|
+
value: 'points: InspectionPoint[] (+ header: InspectionHeader)',
|
|
53
|
+
events: ['onChange', 'onHeaderChange', 'onUpload', 'onValidate', 'onAddToBudget'],
|
|
54
|
+
permissions: [],
|
|
55
|
+
usedBy: ['Recepción de vehículo', 'OT', 'Alineación', 'Garantía'],
|
|
56
|
+
helpers: [
|
|
57
|
+
'makeInspectionPoint',
|
|
58
|
+
'treadStatus',
|
|
59
|
+
'worstStatus',
|
|
60
|
+
'summarizeInspection',
|
|
61
|
+
'validateInspection',
|
|
62
|
+
'serializeInspectionPoints',
|
|
63
|
+
'parseInspectionPoints',
|
|
64
|
+
'recommendationsToLines',
|
|
65
|
+
],
|
|
66
|
+
},
|
|
50
67
|
];
|
package/dist/business/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/business/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,cAAc,CAAA;AAC5B,cAAc,qBAAqB,CAAA;AACnC,cAAc,WAAW,CAAA;AACzB,cAAc,mBAAmB,CAAA;AACjC,cAAc,UAAU,CAAA;AACxB,cAAc,sBAAsB,CAAA;AACpC,cAAc,kBAAkB,CAAA;AAChC,cAAc,kBAAkB,CAAA;AAChC,cAAc,mBAAmB,CAAA;AACjC,cAAc,WAAW,CAAA;AACzB,cAAc,UAAU,CAAA;AACxB,cAAc,iBAAiB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/business/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,cAAc,CAAA;AAC5B,cAAc,qBAAqB,CAAA;AACnC,cAAc,WAAW,CAAA;AACzB,cAAc,mBAAmB,CAAA;AACjC,cAAc,UAAU,CAAA;AACxB,cAAc,sBAAsB,CAAA;AACpC,cAAc,kBAAkB,CAAA;AAChC,cAAc,kBAAkB,CAAA;AAChC,cAAc,mBAAmB,CAAA;AACjC,cAAc,WAAW,CAAA;AACzB,cAAc,UAAU,CAAA;AACxB,cAAc,iBAAiB,CAAA;AAC/B,cAAc,cAAc,CAAA;AAC5B,cAAc,wBAAwB,CAAA"}
|
package/dist/business/index.js
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type InspectionHeader, type InspectionPoint, type InspectionRules, type InspectionValidation, type TreadThresholds } from './inspection';
|
|
2
|
+
export interface InspectionUploadResult {
|
|
3
|
+
url: string;
|
|
4
|
+
/** Si el host no lo dice, se deduce del archivo. */
|
|
5
|
+
type?: 'photo' | 'video';
|
|
6
|
+
}
|
|
7
|
+
export interface InspectionChecklistProps {
|
|
8
|
+
points: InspectionPoint[];
|
|
9
|
+
onChange: (points: InspectionPoint[]) => void;
|
|
10
|
+
/** Encabezado de recepción (km, combustible, daños). Sin él no se muestra. */
|
|
11
|
+
header?: InspectionHeader;
|
|
12
|
+
onHeaderChange?: (header: InspectionHeader) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Sube un archivo al almacenamiento del host (el mismo `POST /upload` que
|
|
15
|
+
* usan los campos `upload`) y devuelve su URL. Sin él no hay botón de foto.
|
|
16
|
+
*/
|
|
17
|
+
onUpload?: (file: File, point: InspectionPoint | null) => Promise<InspectionUploadResult>;
|
|
18
|
+
/** Emite la validación tras cada cambio (para bloquear «Guardar»/«Enviar»). */
|
|
19
|
+
onValidate?: (v: InspectionValidation) => void;
|
|
20
|
+
rules?: InspectionRules;
|
|
21
|
+
/** Umbrales de profundidad de dibujo (mm). */
|
|
22
|
+
treadThresholds?: TreadThresholds;
|
|
23
|
+
/** Errores del servidor `"<índice>.<campo>"`. */
|
|
24
|
+
serverErrors?: Record<string, string>;
|
|
25
|
+
/** Muestra la recomendación y su precio. Default true. */
|
|
26
|
+
allowRecommendations?: boolean;
|
|
27
|
+
/** Permite agregar/quitar puntos a mano. Default false (los pone la plantilla). */
|
|
28
|
+
allowCustomPoints?: boolean;
|
|
29
|
+
/** Solo lectura: sin edición, para revisar una inspección enviada o cerrada. */
|
|
30
|
+
readOnly?: boolean;
|
|
31
|
+
currency?: string;
|
|
32
|
+
/** Botón «Agregar al presupuesto» sobre las recomendaciones (recibe los puntos). */
|
|
33
|
+
onAddToBudget?: (points: InspectionPoint[]) => void;
|
|
34
|
+
}
|
|
35
|
+
export declare function InspectionChecklist({ points, onChange, header, onHeaderChange, onUpload, onValidate, rules, treadThresholds, serverErrors, allowRecommendations, allowCustomPoints, readOnly, currency, onAddToBudget, }: InspectionChecklistProps): import("react").JSX.Element;
|
|
36
|
+
//# sourceMappingURL=inspection-checklist.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inspection-checklist.d.ts","sourceRoot":"","sources":["../../src/business/inspection-checklist.tsx"],"names":[],"mappings":"AAUA,OAAO,EASH,KAAK,gBAAgB,EAErB,KAAK,eAAe,EACpB,KAAK,eAAe,EAEpB,KAAK,oBAAoB,EACzB,KAAK,eAAe,EACvB,MAAM,cAAc,CAAA;AAErB,MAAM,WAAW,sBAAsB;IACnC,GAAG,EAAE,MAAM,CAAA;IACX,oDAAoD;IACpD,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAA;CAC3B;AAED,MAAM,WAAW,wBAAwB;IACrC,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,QAAQ,EAAE,CAAC,MAAM,EAAE,eAAe,EAAE,KAAK,IAAI,CAAA;IAC7C,8EAA8E;IAC9E,MAAM,CAAC,EAAE,gBAAgB,CAAA;IACzB,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,CAAA;IACnD;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,eAAe,GAAG,IAAI,KAAK,OAAO,CAAC,sBAAsB,CAAC,CAAA;IACzF,+EAA+E;IAC/E,UAAU,CAAC,EAAE,CAAC,CAAC,EAAE,oBAAoB,KAAK,IAAI,CAAA;IAC9C,KAAK,CAAC,EAAE,eAAe,CAAA;IACvB,8CAA8C;IAC9C,eAAe,CAAC,EAAE,eAAe,CAAA;IACjC,iDAAiD;IACjD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACrC,0DAA0D;IAC1D,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,oFAAoF;IACpF,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,EAAE,KAAK,IAAI,CAAA;CACtD;AAkDD,wBAAgB,mBAAmB,CAAC,EAChC,MAAM,EACN,QAAQ,EACR,MAAM,EACN,cAAc,EACd,QAAQ,EACR,UAAU,EACV,KAAK,EACL,eAA0C,EAC1C,YAAY,EACZ,oBAA2B,EAC3B,iBAAyB,EACzB,QAAgB,EAChB,QAAQ,EACR,aAAa,GAChB,EAAE,wBAAwB,+BA6S1B"}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// InspectionChecklist — plantilla de puntos con semáforo, medición, fotos/video
|
|
3
|
+
// por punto y recomendación → renglón del presupuesto (benchmark §7, TAL-4/5;
|
|
4
|
+
// Shopmonkey S39, Tekmetric S36). Controlado: `points` + `onChange`. Sirve a la
|
|
5
|
+
// recepción del vehículo (encabezado con km/combustible/daños), a la inspección
|
|
6
|
+
// multipunto de la OT, a la hoja de alineación y a la garantía.
|
|
7
|
+
import { useMemo, useRef, useState } from 'react';
|
|
8
|
+
import { useTranslation } from 'react-i18next';
|
|
9
|
+
import { Camera, Loader2, Trash2, Video } from 'lucide-react';
|
|
10
|
+
import { Badge, Button, Input, Textarea } from '@asteby/metacore-ui';
|
|
11
|
+
import { useFormatter } from './format';
|
|
12
|
+
import { DEFAULT_TREAD_THRESHOLDS, FUEL_LEVELS, hasRecommendation, newInspectionKey, summarizeInspection, treadStatus, validateInspection, } from './inspection';
|
|
13
|
+
const STATUS_STYLE = {
|
|
14
|
+
pending: 'border-border text-muted-foreground',
|
|
15
|
+
ok: 'border-emerald-500/50 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
|
|
16
|
+
attention: 'border-amber-500/50 bg-amber-500/15 text-amber-700 dark:text-amber-300',
|
|
17
|
+
urgent: 'border-red-500/50 bg-red-500/15 text-red-700 dark:text-red-300',
|
|
18
|
+
};
|
|
19
|
+
const STATUS_DOT = {
|
|
20
|
+
pending: 'bg-muted-foreground/40',
|
|
21
|
+
ok: 'bg-emerald-500',
|
|
22
|
+
attention: 'bg-amber-500',
|
|
23
|
+
urgent: 'bg-red-500',
|
|
24
|
+
};
|
|
25
|
+
const LIGHTS = ['ok', 'attention', 'urgent'];
|
|
26
|
+
function statusLabel(t, s) {
|
|
27
|
+
switch (s) {
|
|
28
|
+
case 'ok':
|
|
29
|
+
return t('inspection.status.ok', { defaultValue: 'Bien' });
|
|
30
|
+
case 'attention':
|
|
31
|
+
return t('inspection.status.attention', { defaultValue: 'Atención' });
|
|
32
|
+
case 'urgent':
|
|
33
|
+
return t('inspection.status.urgent', { defaultValue: 'Urgente' });
|
|
34
|
+
default:
|
|
35
|
+
return t('inspection.status.pending', { defaultValue: 'Sin revisar' });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function fuelLabel(t, f) {
|
|
39
|
+
switch (f) {
|
|
40
|
+
case 'empty':
|
|
41
|
+
return t('inspection.fuel.empty', { defaultValue: 'Reserva' });
|
|
42
|
+
case 'quarter':
|
|
43
|
+
return t('inspection.fuel.quarter', { defaultValue: '1/4' });
|
|
44
|
+
case 'half':
|
|
45
|
+
return t('inspection.fuel.half', { defaultValue: '1/2' });
|
|
46
|
+
case 'three_quarters':
|
|
47
|
+
return t('inspection.fuel.three_quarters', { defaultValue: '3/4' });
|
|
48
|
+
default:
|
|
49
|
+
return t('inspection.fuel.full', { defaultValue: 'Lleno' });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function mediaTypeOf(file, hint) {
|
|
53
|
+
return hint ?? (file.type.startsWith('video/') ? 'video' : 'photo');
|
|
54
|
+
}
|
|
55
|
+
export function InspectionChecklist({ points, onChange, header, onHeaderChange, onUpload, onValidate, rules, treadThresholds = DEFAULT_TREAD_THRESHOLDS, serverErrors, allowRecommendations = true, allowCustomPoints = false, readOnly = false, currency, onAddToBudget, }) {
|
|
56
|
+
const { t } = useTranslation();
|
|
57
|
+
const fmt = useFormatter({ currency });
|
|
58
|
+
const [uploading, setUploading] = useState(null);
|
|
59
|
+
const [uploadError, setUploadError] = useState(null);
|
|
60
|
+
const validation = useMemo(() => validateInspection(points, header, rules), [points, header, rules]);
|
|
61
|
+
const errors = { ...validation.errors, ...serverErrors };
|
|
62
|
+
const summary = useMemo(() => summarizeInspection(points), [points]);
|
|
63
|
+
const sections = useMemo(() => {
|
|
64
|
+
const order = [];
|
|
65
|
+
const map = new Map();
|
|
66
|
+
points.forEach((p, i) => {
|
|
67
|
+
const s = p.section || '';
|
|
68
|
+
if (!map.has(s)) {
|
|
69
|
+
map.set(s, []);
|
|
70
|
+
order.push(s);
|
|
71
|
+
}
|
|
72
|
+
map.get(s).push(i);
|
|
73
|
+
});
|
|
74
|
+
return order.map((s) => ({ section: s, indexes: map.get(s) }));
|
|
75
|
+
}, [points]);
|
|
76
|
+
const emit = (next) => {
|
|
77
|
+
onChange(next);
|
|
78
|
+
onValidate?.(validateInspection(next, header, rules));
|
|
79
|
+
};
|
|
80
|
+
const patch = (i, partial) => emit(points.map((p, j) => (j === i ? { ...p, ...partial } : p)));
|
|
81
|
+
const setValue = (i, raw) => {
|
|
82
|
+
const p = points[i];
|
|
83
|
+
const value = raw.trim() === '' ? null : Number(raw.replace(',', '.'));
|
|
84
|
+
const v = value != null && Number.isFinite(value) ? value : null;
|
|
85
|
+
// La profundidad de dibujo fija el semáforo; el técnico aún puede subirlo.
|
|
86
|
+
if (p.kind === 'tread')
|
|
87
|
+
patch(i, { value: v, status: treadStatus(v, treadThresholds) });
|
|
88
|
+
else
|
|
89
|
+
patch(i, { value: v });
|
|
90
|
+
};
|
|
91
|
+
const setStatus = (i, status) => {
|
|
92
|
+
const p = points[i];
|
|
93
|
+
// Verde no lleva recomendación: se limpia para no dejar un renglón huérfano.
|
|
94
|
+
patch(i, status === 'ok' ? { status, recommendation: '', unit_price: 0, quantity: 1 } : { status: p.status === status ? 'pending' : status });
|
|
95
|
+
};
|
|
96
|
+
const upload = async (i, file) => {
|
|
97
|
+
if (!onUpload)
|
|
98
|
+
return;
|
|
99
|
+
const p = points[i];
|
|
100
|
+
setUploading(p.key);
|
|
101
|
+
setUploadError(null);
|
|
102
|
+
try {
|
|
103
|
+
const res = await onUpload(file, p);
|
|
104
|
+
const media = { key: newInspectionKey('im'), url: res.url, type: mediaTypeOf(file, res.type) };
|
|
105
|
+
patch(i, { media: [...p.media, media] });
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
setUploadError(e instanceof Error ? e.message : t('inspection.uploadError', { defaultValue: 'No se pudo subir el archivo' }));
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
setUploading(null);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const setHeader = (partial) => {
|
|
115
|
+
if (!header)
|
|
116
|
+
return;
|
|
117
|
+
const next = { ...header, ...partial };
|
|
118
|
+
onHeaderChange?.(next);
|
|
119
|
+
onValidate?.(validateInspection(points, next, rules));
|
|
120
|
+
};
|
|
121
|
+
const addPoint = (section) => emit([...points, { key: newInspectionKey(), section, name: '', kind: 'check', status: 'pending', notes: '', recommendation: '', quantity: 1, unit_price: 0, media: [] }]);
|
|
122
|
+
return (_jsxs("div", { "data-slot": "inspection-checklist", className: "space-y-4", children: [header && (_jsxs("div", { "data-slot": "inspection-header", className: "grid gap-3 rounded-lg border bg-muted/20 p-3 sm:grid-cols-2", children: [_jsxs("label", { className: "space-y-1 text-sm", children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: t('inspection.odometer', { defaultValue: 'Kilometraje' }) }), _jsx(Input, { inputMode: "numeric", value: header.odometer_km == null ? '' : String(header.odometer_km), disabled: readOnly, onChange: (e) => {
|
|
123
|
+
const n = Number(e.target.value.replace(/[^\d.]/g, ''));
|
|
124
|
+
setHeader({ odometer_km: e.target.value.trim() === '' || !Number.isFinite(n) ? null : n });
|
|
125
|
+
} })] }), _jsxs("div", { className: "space-y-1 text-sm", children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: t('inspection.fuel.label', { defaultValue: 'Nivel de combustible' }) }), _jsx("div", { role: "radiogroup", className: "flex flex-wrap gap-1.5", children: FUEL_LEVELS.map((f) => (_jsx(Button, { type: "button", size: "sm", role: "radio", "aria-checked": header.fuel_level === f, variant: header.fuel_level === f ? 'default' : 'outline', disabled: readOnly, onClick: () => setHeader({ fuel_level: header.fuel_level === f ? '' : f }), children: fuelLabel(t, f) }, f))) })] }), _jsxs("label", { className: "space-y-1 text-sm sm:col-span-2", children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: t('inspection.damages', { defaultValue: 'Daños visibles al recibirlo' }) }), _jsx(Textarea, { rows: 2, value: header.damage_notes, disabled: readOnly, placeholder: t('inspection.damagesPlaceholder', { defaultValue: 'Golpes, rayones, faltantes...' }), onChange: (e) => setHeader({ damage_notes: e.target.value }) })] })] })), sections.map(({ section, indexes }) => (_jsxs("section", { "aria-label": section, className: "space-y-2", children: [section && _jsx("h4", { className: "text-sm font-semibold", children: section }), _jsx("div", { className: "space-y-2", children: indexes.map((i) => {
|
|
126
|
+
const p = points[i];
|
|
127
|
+
const needsValue = p.kind === 'tread' || p.kind === 'measure';
|
|
128
|
+
const issue = p.status === 'attention' || p.status === 'urgent';
|
|
129
|
+
return (_jsxs("div", { "data-slot": "inspection-point", "data-status": p.status, className: "space-y-2 rounded-lg border p-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: `h-2.5 w-2.5 shrink-0 rounded-full ${STATUS_DOT[p.status]}`, "aria-hidden": true }), allowCustomPoints && !readOnly ? (_jsx(Input, { className: "h-8 max-w-56", value: p.name, "aria-invalid": !!errors[`${i}.name`], onChange: (e) => patch(i, { name: e.target.value }) })) : (_jsx("span", { className: "min-w-0 flex-1 text-sm font-medium", children: p.name })), needsValue && (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { inputMode: "decimal", className: "h-8 w-20 text-right", value: p.value == null ? '' : String(p.value), disabled: readOnly, "aria-label": `${p.name} (${p.unit ?? ''})`, "aria-invalid": !!errors[`${i}.value`], onChange: (e) => setValue(i, e.target.value) }), _jsx("span", { className: "text-xs text-muted-foreground", children: p.unit ?? (p.kind === 'tread' ? 'mm' : '') })] })), _jsx("div", { role: "radiogroup", "aria-label": p.name, className: "flex gap-1", children: LIGHTS.map((s) => (_jsx("button", { type: "button", role: "radio", "aria-checked": p.status === s, disabled: readOnly, onClick: () => setStatus(i, s), className: `rounded-full border px-2.5 py-1 text-xs transition ${p.status === s ? STATUS_STYLE[s] : 'border-border text-muted-foreground hover:bg-muted'} disabled:opacity-60`, children: statusLabel(t, s) }, s))) }), allowCustomPoints && !readOnly && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-8 w-8 p-0", "aria-label": t('inspection.removePoint', { defaultValue: 'Quitar punto' }), onClick: () => emit(points.filter((_, j) => j !== i)), children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }), (issue || p.notes || readOnly === false) && (_jsx(Textarea, { rows: 1, value: p.notes, disabled: readOnly, "aria-invalid": !!errors[`${i}.notes`], placeholder: t('inspection.notes', { defaultValue: 'Observaciones' }), onChange: (e) => patch(i, { notes: e.target.value }) })), errors[`${i}.notes`] && _jsx("p", { className: "text-xs text-destructive", children: errors[`${i}.notes`] }), errors[`${i}.value`] && _jsx("p", { className: "text-xs text-destructive", children: errors[`${i}.value`] }), allowRecommendations && issue && (_jsxs("div", { className: "grid gap-2 rounded-md bg-muted/30 p-2 sm:grid-cols-[1fr_5rem_7rem]", children: [_jsx(Input, { value: p.recommendation, disabled: readOnly, "aria-invalid": !!errors[`${i}.recommendation`], placeholder: t('inspection.recommendation', { defaultValue: 'Trabajo recomendado' }), onChange: (e) => patch(i, { recommendation: e.target.value }) }), _jsx(Input, { inputMode: "decimal", className: "text-right", value: String(p.quantity), disabled: readOnly || !hasRecommendation(p), "aria-label": t('inspection.quantity', { defaultValue: 'Cantidad' }), onChange: (e) => patch(i, { quantity: Number(e.target.value.replace(',', '.')) || 0 }) }), _jsx(Input, { inputMode: "decimal", className: "text-right", value: String(p.unit_price), disabled: readOnly || !hasRecommendation(p), "aria-label": t('inspection.unitPrice', { defaultValue: 'Precio' }), "aria-invalid": !!errors[`${i}.unit_price`], onChange: (e) => patch(i, { unit_price: Number(e.target.value.replace(',', '.')) || 0 }) }), errors[`${i}.recommendation`] && (_jsx("p", { className: "text-xs text-destructive sm:col-span-3", children: errors[`${i}.recommendation`] }))] })), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [p.media.map((m) => (_jsxs("span", { className: "group relative", children: [m.type === 'video' ? (_jsx("video", { src: m.url, className: "h-14 w-20 rounded border object-cover", controls: true, preload: "metadata" })) : (_jsx("img", { src: m.url, alt: m.caption ?? p.name, className: "h-14 w-14 rounded border object-cover" })), !readOnly && (_jsx("button", { type: "button", "aria-label": t('inspection.removeMedia', { defaultValue: 'Quitar archivo' }), 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", onClick: () => patch(i, { media: p.media.filter((x) => x.key !== m.key) }), children: "\u00D7" }))] }, m.key))), !readOnly && onUpload && _jsx(MediaPick, { busy: uploading === p.key, onFile: (f) => upload(i, f) }), p.approval && p.approval !== 'none' && (_jsx(Badge, { variant: "outline", className: "ml-auto", children: p.approval === 'approved'
|
|
130
|
+
? t('inspection.approval.approved', { defaultValue: 'Aprobado por el cliente' })
|
|
131
|
+
: p.approval === 'declined'
|
|
132
|
+
? t('inspection.approval.declined', { defaultValue: 'Rechazado por el cliente' })
|
|
133
|
+
: t('inspection.approval.pending', { defaultValue: 'Esperando al cliente' }) }))] })] }, p.key));
|
|
134
|
+
}) }), allowCustomPoints && !readOnly && (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => addPoint(section), children: t('inspection.addPoint', { defaultValue: 'Agregar punto' }) }))] }, section || '_'))), uploadError && _jsx("p", { role: "alert", className: "text-sm text-destructive", children: uploadError }), validation.form && points.length > 0 && _jsx("p", { role: "alert", className: "text-sm text-destructive", children: validation.form }), _jsxs("div", { "data-slot": "inspection-summary", className: "flex flex-wrap items-center gap-2 rounded-lg border bg-muted/20 p-3 text-sm", children: [LIGHTS.map((s) => (_jsxs(Badge, { variant: "outline", className: STATUS_STYLE[s], children: [statusLabel(t, s), ": ", summary.counts[s]] }, s))), summary.pending > 0 && (_jsxs(Badge, { variant: "outline", className: STATUS_STYLE.pending, children: [statusLabel(t, 'pending'), ": ", summary.pending] })), allowRecommendations && summary.recommendations > 0 && (_jsxs("span", { className: "ml-auto font-medium", children: [t('inspection.recommendedTotal', { defaultValue: 'Recomendado' }), ": ", fmt.money(summary.recommendedTotal)] })), onAddToBudget && !readOnly && summary.recommendations > 0 && (_jsx(Button, { type: "button", size: "sm", onClick: () => onAddToBudget(points.filter(hasRecommendation)), children: t('inspection.addToBudget', { defaultValue: 'Agregar al presupuesto' }) }))] })] }));
|
|
135
|
+
}
|
|
136
|
+
/** Botón de foto/video: abre la cámara en móvil, el selector de archivos en escritorio. */
|
|
137
|
+
function MediaPick({ busy, onFile }) {
|
|
138
|
+
const { t } = useTranslation();
|
|
139
|
+
const ref = useRef(null);
|
|
140
|
+
return (_jsxs(_Fragment, { children: [_jsx("input", { ref: ref, type: "file", accept: "image/*,video/*", capture: "environment", className: "sr-only", tabIndex: -1, "aria-hidden": "true", onChange: (e) => {
|
|
141
|
+
const f = e.target.files?.[0];
|
|
142
|
+
if (ref.current)
|
|
143
|
+
ref.current.value = '';
|
|
144
|
+
if (f)
|
|
145
|
+
onFile(f);
|
|
146
|
+
} }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: busy, onClick: () => ref.current?.click(), children: [busy ? _jsx(Loader2, { className: "mr-1.5 h-4 w-4 animate-spin" }) : _jsx(Camera, { className: "mr-1.5 h-4 w-4" }), t('inspection.addMedia', { defaultValue: 'Foto o video' }), _jsx(Video, { className: "ml-1.5 h-3.5 w-3.5 opacity-60", "aria-hidden": true })] })] }));
|
|
147
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { type LineItem } from './line-items';
|
|
2
|
+
/** Semáforo de un punto: pendiente (sin revisar), verde, amarillo, rojo. */
|
|
3
|
+
export type InspectionStatus = 'pending' | 'ok' | 'attention' | 'urgent';
|
|
4
|
+
/**
|
|
5
|
+
* Qué captura el punto: `check` solo el semáforo; `tread` profundidad de dibujo
|
|
6
|
+
* en mm (el semáforo sale del valor); `measure` un valor numérico libre con su
|
|
7
|
+
* unidad. El nivel de combustible y el kilometraje van en el encabezado.
|
|
8
|
+
*/
|
|
9
|
+
export type InspectionPointKind = 'check' | 'tread' | 'measure';
|
|
10
|
+
export type InspectionMediaType = 'photo' | 'video';
|
|
11
|
+
export interface InspectionMedia {
|
|
12
|
+
/** Clave estable de UI. */
|
|
13
|
+
key: string;
|
|
14
|
+
url: string;
|
|
15
|
+
type: InspectionMediaType;
|
|
16
|
+
caption?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface InspectionPoint {
|
|
19
|
+
/** Clave estable de UI (no se envía). */
|
|
20
|
+
key: string;
|
|
21
|
+
/** Id de la fila guardada, si ya existe. */
|
|
22
|
+
id?: string;
|
|
23
|
+
/** Agrupa los puntos en el checklist («Llantas», «Frenos»...). */
|
|
24
|
+
section: string;
|
|
25
|
+
name: string;
|
|
26
|
+
kind: InspectionPointKind;
|
|
27
|
+
status: InspectionStatus;
|
|
28
|
+
/** Valor medido (`tread`/`measure`). */
|
|
29
|
+
value?: number | null;
|
|
30
|
+
unit?: string;
|
|
31
|
+
notes: string;
|
|
32
|
+
/** Trabajo recomendado; con texto, el punto es un renglón candidato. */
|
|
33
|
+
recommendation: string;
|
|
34
|
+
quantity: number;
|
|
35
|
+
unit_price: number;
|
|
36
|
+
media: InspectionMedia[];
|
|
37
|
+
/** Respuesta del cliente a la recomendación, cuando ya la dio. */
|
|
38
|
+
approval?: 'none' | 'pending' | 'approved' | 'declined';
|
|
39
|
+
}
|
|
40
|
+
export type FuelLevel = 'empty' | 'quarter' | 'half' | 'three_quarters' | 'full';
|
|
41
|
+
export declare const FUEL_LEVELS: readonly FuelLevel[];
|
|
42
|
+
/** Encabezado de una recepción: lo que se ve al recibir el vehículo. */
|
|
43
|
+
export interface InspectionHeader {
|
|
44
|
+
odometer_km?: number | null;
|
|
45
|
+
fuel_level?: FuelLevel | '';
|
|
46
|
+
/** Daños previos visibles, para deslindar responsabilidad. */
|
|
47
|
+
damage_notes: string;
|
|
48
|
+
}
|
|
49
|
+
/** Umbrales de profundidad de dibujo en mm (el límite legal común es 1.6). */
|
|
50
|
+
export interface TreadThresholds {
|
|
51
|
+
/** Por debajo de esto el semáforo es rojo. Default 1.6. */
|
|
52
|
+
urgent: number;
|
|
53
|
+
/** Por debajo de esto (y sobre `urgent`) es amarillo. Default 3. */
|
|
54
|
+
attention: number;
|
|
55
|
+
}
|
|
56
|
+
export declare const DEFAULT_TREAD_THRESHOLDS: TreadThresholds;
|
|
57
|
+
export declare function newInspectionKey(prefix?: string): string;
|
|
58
|
+
export declare function makeInspectionPoint(partial?: Partial<InspectionPoint>): InspectionPoint;
|
|
59
|
+
/** Semáforo que le toca a una profundidad de dibujo. Sin valor: `pending`. */
|
|
60
|
+
export declare function treadStatus(mm: number | null | undefined, th?: TreadThresholds): InspectionStatus;
|
|
61
|
+
/** El peor estado de la lista (rojo > amarillo > verde > pendiente). */
|
|
62
|
+
export declare function worstStatus(points: Pick<InspectionPoint, 'status'>[]): InspectionStatus;
|
|
63
|
+
/** ¿El punto trae una recomendación que se puede cotizar? */
|
|
64
|
+
export declare function hasRecommendation(p: Pick<InspectionPoint, 'recommendation'>): boolean;
|
|
65
|
+
export interface InspectionSummary {
|
|
66
|
+
total: number;
|
|
67
|
+
counts: Record<InspectionStatus, number>;
|
|
68
|
+
worst: InspectionStatus;
|
|
69
|
+
/** Puntos sin revisar. */
|
|
70
|
+
pending: number;
|
|
71
|
+
/** Puntos con recomendación. */
|
|
72
|
+
recommendations: number;
|
|
73
|
+
/** Suma cantidad × precio de las recomendaciones. */
|
|
74
|
+
recommendedTotal: number;
|
|
75
|
+
}
|
|
76
|
+
export declare function summarizeInspection(points: InspectionPoint[]): InspectionSummary;
|
|
77
|
+
export interface InspectionValidation {
|
|
78
|
+
ok: boolean;
|
|
79
|
+
/** Errores por `"<índice>.<campo>"` (mismo contrato que LineItemsEditor). */
|
|
80
|
+
errors: Record<string, string>;
|
|
81
|
+
/** Error de formulario (encabezado o conjunto). */
|
|
82
|
+
form?: string;
|
|
83
|
+
}
|
|
84
|
+
export interface InspectionRules {
|
|
85
|
+
/** Exige revisar todos los puntos (ninguno `pending`). Default false (borrador). */
|
|
86
|
+
requireAllReviewed?: boolean;
|
|
87
|
+
/** Exige kilometraje en el encabezado (recepción). Default false. */
|
|
88
|
+
requireOdometer?: boolean;
|
|
89
|
+
/** Un punto amarillo/rojo debe llevar nota, recomendación o evidencia. Default false. */
|
|
90
|
+
requireEvidenceOnIssues?: boolean;
|
|
91
|
+
}
|
|
92
|
+
export declare function validateInspection(points: InspectionPoint[], header?: InspectionHeader, rules?: InspectionRules): InspectionValidation;
|
|
93
|
+
/**
|
|
94
|
+
* Única puerta de salida hacia el backend: números reales, sin claves de UI,
|
|
95
|
+
* opcionales vacíos omitidos y `line_no` 1-based (mismo criterio que
|
|
96
|
+
* `serializeLineItems`).
|
|
97
|
+
*/
|
|
98
|
+
export declare function serializeInspectionPoints(points: InspectionPoint[]): Record<string, unknown>[];
|
|
99
|
+
/** Inversa de `serializeInspectionPoints`: filas del backend a puntos de UI. */
|
|
100
|
+
export declare function parseInspectionPoints(rows: unknown): InspectionPoint[];
|
|
101
|
+
/**
|
|
102
|
+
* Recomendación → renglón del presupuesto: cada punto con recomendación se
|
|
103
|
+
* vuelve un renglón `item` (descripción «Punto — recomendación»). Se puede
|
|
104
|
+
* filtrar por respuesta del cliente (p. ej. solo las aprobadas).
|
|
105
|
+
*/
|
|
106
|
+
export declare function recommendationsToLines(points: InspectionPoint[], opts?: {
|
|
107
|
+
approval?: InspectionPoint['approval'][];
|
|
108
|
+
}): LineItem[];
|
|
109
|
+
//# sourceMappingURL=inspection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inspection.d.ts","sourceRoot":"","sources":["../../src/business/inspection.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Inspección con checklist (recepción de vehículo, inspección multipunto, OT,
|
|
2
|
+
// alineación, garantía): lógica pura del <InspectionChecklist> (benchmark §7,
|
|
3
|
+
// TAL-4/TAL-5). El semáforo, el resumen y la salida hacia el backend viven aquí
|
|
4
|
+
// para que el componente y los addons (que guardan por una acción) coincidan.
|
|
5
|
+
import { makeLine } from './line-items';
|
|
6
|
+
import { roundMoney, toAmount } from './format';
|
|
7
|
+
export const FUEL_LEVELS = ['empty', 'quarter', 'half', 'three_quarters', 'full'];
|
|
8
|
+
export const DEFAULT_TREAD_THRESHOLDS = { urgent: 1.6, attention: 3 };
|
|
9
|
+
let seq = 0;
|
|
10
|
+
export function newInspectionKey(prefix = 'ip') {
|
|
11
|
+
seq += 1;
|
|
12
|
+
return `${prefix}-${Date.now().toString(36)}-${seq}`;
|
|
13
|
+
}
|
|
14
|
+
export function makeInspectionPoint(partial = {}) {
|
|
15
|
+
return {
|
|
16
|
+
key: partial.key ?? newInspectionKey(),
|
|
17
|
+
section: '',
|
|
18
|
+
name: '',
|
|
19
|
+
kind: 'check',
|
|
20
|
+
status: 'pending',
|
|
21
|
+
notes: '',
|
|
22
|
+
recommendation: '',
|
|
23
|
+
quantity: 1,
|
|
24
|
+
unit_price: 0,
|
|
25
|
+
media: [],
|
|
26
|
+
...partial,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Semáforo que le toca a una profundidad de dibujo. Sin valor: `pending`. */
|
|
30
|
+
export function treadStatus(mm, th = DEFAULT_TREAD_THRESHOLDS) {
|
|
31
|
+
if (mm == null || !Number.isFinite(mm))
|
|
32
|
+
return 'pending';
|
|
33
|
+
if (mm < th.urgent)
|
|
34
|
+
return 'urgent';
|
|
35
|
+
if (mm < th.attention)
|
|
36
|
+
return 'attention';
|
|
37
|
+
return 'ok';
|
|
38
|
+
}
|
|
39
|
+
const RANK = { pending: 0, ok: 1, attention: 2, urgent: 3 };
|
|
40
|
+
/** El peor estado de la lista (rojo > amarillo > verde > pendiente). */
|
|
41
|
+
export function worstStatus(points) {
|
|
42
|
+
return points.reduce((w, p) => (RANK[p.status] > RANK[w] ? p.status : w), 'pending');
|
|
43
|
+
}
|
|
44
|
+
/** ¿El punto trae una recomendación que se puede cotizar? */
|
|
45
|
+
export function hasRecommendation(p) {
|
|
46
|
+
return p.recommendation.trim() !== '';
|
|
47
|
+
}
|
|
48
|
+
export function summarizeInspection(points) {
|
|
49
|
+
const counts = { pending: 0, ok: 0, attention: 0, urgent: 0 };
|
|
50
|
+
let recommendations = 0;
|
|
51
|
+
let recommendedTotal = 0;
|
|
52
|
+
for (const p of points) {
|
|
53
|
+
counts[p.status] += 1;
|
|
54
|
+
if (hasRecommendation(p)) {
|
|
55
|
+
recommendations += 1;
|
|
56
|
+
recommendedTotal += p.quantity * p.unit_price;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
total: points.length,
|
|
61
|
+
counts,
|
|
62
|
+
worst: worstStatus(points),
|
|
63
|
+
pending: counts.pending,
|
|
64
|
+
recommendations,
|
|
65
|
+
recommendedTotal: roundMoney(recommendedTotal),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function validateInspection(points, header, rules = {}) {
|
|
69
|
+
const errors = {};
|
|
70
|
+
points.forEach((p, i) => {
|
|
71
|
+
if (p.name.trim() === '')
|
|
72
|
+
errors[`${i}.name`] = 'Falta el nombre del punto';
|
|
73
|
+
if ((p.kind === 'tread' || p.kind === 'measure') && p.value != null && (!Number.isFinite(p.value) || p.value < 0)) {
|
|
74
|
+
errors[`${i}.value`] = 'La medición no puede ser negativa';
|
|
75
|
+
}
|
|
76
|
+
if (p.quantity < 0 || p.unit_price < 0)
|
|
77
|
+
errors[`${i}.unit_price`] = 'Cantidad y precio no pueden ser negativos';
|
|
78
|
+
if (hasRecommendation(p) && p.status === 'ok') {
|
|
79
|
+
errors[`${i}.recommendation`] = 'Un punto en verde no lleva recomendación';
|
|
80
|
+
}
|
|
81
|
+
if (rules.requireEvidenceOnIssues &&
|
|
82
|
+
(p.status === 'attention' || p.status === 'urgent') &&
|
|
83
|
+
p.notes.trim() === '' &&
|
|
84
|
+
!hasRecommendation(p) &&
|
|
85
|
+
p.media.length === 0) {
|
|
86
|
+
errors[`${i}.notes`] = 'Anota, recomienda o agrega una foto';
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
let form;
|
|
90
|
+
if (rules.requireAllReviewed && points.some((p) => p.status === 'pending')) {
|
|
91
|
+
form = 'Faltan puntos por revisar';
|
|
92
|
+
}
|
|
93
|
+
if (rules.requireOdometer && header && (header.odometer_km == null || !(header.odometer_km >= 0))) {
|
|
94
|
+
form = form ?? 'Captura el kilometraje';
|
|
95
|
+
}
|
|
96
|
+
return { ok: Object.keys(errors).length === 0 && !form, errors, form };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Única puerta de salida hacia el backend: números reales, sin claves de UI,
|
|
100
|
+
* opcionales vacíos omitidos y `line_no` 1-based (mismo criterio que
|
|
101
|
+
* `serializeLineItems`).
|
|
102
|
+
*/
|
|
103
|
+
export function serializeInspectionPoints(points) {
|
|
104
|
+
return points.map((p, i) => {
|
|
105
|
+
const o = {
|
|
106
|
+
line_no: i + 1,
|
|
107
|
+
section: p.section.trim(),
|
|
108
|
+
name: p.name.trim(),
|
|
109
|
+
kind: p.kind,
|
|
110
|
+
status: p.status,
|
|
111
|
+
notes: p.notes.trim(),
|
|
112
|
+
recommendation: p.recommendation.trim(),
|
|
113
|
+
quantity: toAmount(p.quantity),
|
|
114
|
+
unit_price: toAmount(p.unit_price),
|
|
115
|
+
media: p.media.map((m) => ({ url: m.url, type: m.type, ...(m.caption ? { caption: m.caption } : {}) })),
|
|
116
|
+
};
|
|
117
|
+
if (p.id)
|
|
118
|
+
o.id = p.id;
|
|
119
|
+
if (p.value != null && Number.isFinite(p.value))
|
|
120
|
+
o.measure_value = p.value;
|
|
121
|
+
if (p.unit)
|
|
122
|
+
o.unit = p.unit;
|
|
123
|
+
return o;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** Inversa de `serializeInspectionPoints`: filas del backend a puntos de UI. */
|
|
127
|
+
export function parseInspectionPoints(rows) {
|
|
128
|
+
if (!Array.isArray(rows))
|
|
129
|
+
return [];
|
|
130
|
+
const str = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
|
|
131
|
+
return rows.map((r) => {
|
|
132
|
+
const o = (r ?? {});
|
|
133
|
+
const status = str(o.status);
|
|
134
|
+
const kind = str(o.kind);
|
|
135
|
+
const media = Array.isArray(o.media)
|
|
136
|
+
? o.media.map((m) => ({
|
|
137
|
+
key: newInspectionKey('im'),
|
|
138
|
+
url: str(m.url),
|
|
139
|
+
type: (str(m.type) === 'video' ? 'video' : 'photo'),
|
|
140
|
+
caption: str(m.caption) || undefined,
|
|
141
|
+
}))
|
|
142
|
+
: [];
|
|
143
|
+
const value = o.measure_value ?? o.value;
|
|
144
|
+
return makeInspectionPoint({
|
|
145
|
+
id: str(o.id) || undefined,
|
|
146
|
+
section: str(o.section),
|
|
147
|
+
name: str(o.name),
|
|
148
|
+
kind: kind === 'tread' || kind === 'measure' ? kind : 'check',
|
|
149
|
+
status: status in RANK ? status : 'pending',
|
|
150
|
+
value: value == null || value === '' ? null : toAmount(value),
|
|
151
|
+
unit: str(o.unit) || undefined,
|
|
152
|
+
notes: str(o.notes),
|
|
153
|
+
recommendation: str(o.recommendation),
|
|
154
|
+
quantity: o.quantity == null ? 1 : toAmount(o.quantity),
|
|
155
|
+
unit_price: toAmount(o.unit_price),
|
|
156
|
+
media,
|
|
157
|
+
approval: ['none', 'pending', 'approved', 'declined'].includes(str(o.approval))
|
|
158
|
+
? str(o.approval)
|
|
159
|
+
: undefined,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Recomendación → renglón del presupuesto: cada punto con recomendación se
|
|
165
|
+
* vuelve un renglón `item` (descripción «Punto — recomendación»). Se puede
|
|
166
|
+
* filtrar por respuesta del cliente (p. ej. solo las aprobadas).
|
|
167
|
+
*/
|
|
168
|
+
export function recommendationsToLines(points, opts = {}) {
|
|
169
|
+
return points
|
|
170
|
+
.filter((p) => hasRecommendation(p) && (!opts.approval || (p.approval != null && opts.approval.includes(p.approval))))
|
|
171
|
+
.map((p) => makeLine({
|
|
172
|
+
description: `${p.name} — ${p.recommendation.trim()}`,
|
|
173
|
+
quantity: p.quantity > 0 ? p.quantity : 1,
|
|
174
|
+
unit_price: p.unit_price,
|
|
175
|
+
}));
|
|
176
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// <InspectionChecklist>: el semáforo de una llanta sale de la profundidad, verde
|
|
4
|
+
// limpia la recomendación y la foto se sube por el host (onUpload).
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
8
|
+
|
|
9
|
+
vi.mock('react-i18next', () => ({
|
|
10
|
+
useTranslation: () => ({
|
|
11
|
+
t: (k: string, o?: any) => o?.defaultValue ?? k,
|
|
12
|
+
i18n: { language: 'es' },
|
|
13
|
+
}),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
import { InspectionChecklist } from '../business/inspection-checklist'
|
|
17
|
+
import { makeInspectionPoint, type InspectionHeader, type InspectionPoint } from '../business/inspection'
|
|
18
|
+
|
|
19
|
+
afterEach(cleanup)
|
|
20
|
+
|
|
21
|
+
function Harness({ initial, onUpload }: { initial: InspectionPoint[]; onUpload?: any }) {
|
|
22
|
+
const [points, setPoints] = useState(initial)
|
|
23
|
+
const [header, setHeader] = useState<InspectionHeader>({ damage_notes: '', odometer_km: null, fuel_level: '' })
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
<InspectionChecklist points={points} onChange={setPoints} header={header} onHeaderChange={setHeader} onUpload={onUpload} />
|
|
27
|
+
<output data-testid="state">{JSON.stringify({ points, header })}</output>
|
|
28
|
+
</>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
const state = () => JSON.parse(screen.getByTestId('state').textContent ?? '{}')
|
|
32
|
+
|
|
33
|
+
describe('InspectionChecklist', () => {
|
|
34
|
+
it('captura la profundidad y pinta el semáforo solo', () => {
|
|
35
|
+
render(<Harness initial={[makeInspectionPoint({ section: 'Llantas', name: 'Llanta FL', kind: 'tread', unit: 'mm' })]} />)
|
|
36
|
+
fireEvent.change(screen.getByLabelText('Llanta FL (mm)'), { target: { value: '1.2' } })
|
|
37
|
+
expect(state().points[0]).toMatchObject({ value: 1.2, status: 'urgent' })
|
|
38
|
+
fireEvent.change(screen.getByLabelText('Llanta FL (mm)'), { target: { value: '6' } })
|
|
39
|
+
expect(state().points[0].status).toBe('ok')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('verde limpia la recomendación; amarillo la permite', () => {
|
|
43
|
+
render(
|
|
44
|
+
<Harness
|
|
45
|
+
initial={[makeInspectionPoint({ name: 'Balatas', status: 'attention', recommendation: 'Cambiar', unit_price: 500 })]}
|
|
46
|
+
/>,
|
|
47
|
+
)
|
|
48
|
+
expect(screen.getByPlaceholderText('Trabajo recomendado')).toBeTruthy()
|
|
49
|
+
fireEvent.click(screen.getByRole('radio', { name: 'Bien' }))
|
|
50
|
+
expect(state().points[0]).toMatchObject({ status: 'ok', recommendation: '', unit_price: 0 })
|
|
51
|
+
expect(screen.queryByPlaceholderText('Trabajo recomendado')).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('captura kilometraje y combustible del encabezado', () => {
|
|
55
|
+
render(<Harness initial={[]} />)
|
|
56
|
+
fireEvent.change(screen.getByLabelText('Kilometraje'), { target: { value: '84210' } })
|
|
57
|
+
fireEvent.click(screen.getByRole('radio', { name: '1/2' }))
|
|
58
|
+
expect(state().header).toMatchObject({ odometer_km: 84210, fuel_level: 'half' })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('sube la foto por el host y la cuelga del punto', async () => {
|
|
62
|
+
const onUpload = vi.fn(async () => ({ url: '/storage/public/inspections/a.jpg' }))
|
|
63
|
+
const { container } = render(<Harness initial={[makeInspectionPoint({ name: 'Frenos' })]} onUpload={onUpload} />)
|
|
64
|
+
const input = container.querySelector('input[type=file]') as HTMLInputElement
|
|
65
|
+
fireEvent.change(input, { target: { files: [new File(['x'], 'a.jpg', { type: 'image/jpeg' })] } })
|
|
66
|
+
await waitFor(() => expect(state().points[0].media).toHaveLength(1))
|
|
67
|
+
expect(state().points[0].media[0]).toMatchObject({ url: '/storage/public/inspections/a.jpg', type: 'photo' })
|
|
68
|
+
expect(onUpload).toHaveBeenCalledTimes(1)
|
|
69
|
+
})
|
|
70
|
+
})
|