@asteby/metacore-runtime-react 46.0.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 +12 -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/dist/document/document-page.js +9 -5
- package/dist/document/types.d.ts +2 -0
- package/dist/document/types.d.ts.map +1 -1
- package/dist/document/use-document-sources.d.ts +6 -0
- package/dist/document/use-document-sources.d.ts.map +1 -1
- package/dist/document/use-document-sources.js +9 -1
- 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/document/__tests__/document-page.test.tsx +16 -0
- package/src/document/document-page.tsx +10 -5
- package/src/document/types.ts +2 -0
- package/src/document/use-document-sources.ts +14 -1
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { DocumentSourceDef } from './types';
|
|
2
2
|
export interface DocumentSourcesState {
|
|
3
3
|
sources: Record<string, Record<string, unknown>[]>;
|
|
4
|
+
/**
|
|
5
|
+
* Total del servidor por fuente directa (`meta.total`), para que un contador
|
|
6
|
+
* diga 312 y no «50» (el tamaño de la página que se trajo). Una fuente sin
|
|
7
|
+
* `meta.total` —o de dos saltos— no aparece: el contador cae al largo de sus filas.
|
|
8
|
+
*/
|
|
9
|
+
totals: Record<string, number>;
|
|
4
10
|
loading: boolean;
|
|
5
11
|
/** Vuelve a pedir todas las fuentes (tras ejecutar una acción). */
|
|
6
12
|
reload: () => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-document-sources.d.ts","sourceRoot":"","sources":["../../src/document/use-document-sources.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAQhD,MAAM,WAAW,oBAAoB;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAA;IAClD,OAAO,EAAE,OAAO,CAAA;IAChB,mEAAmE;IACnE,MAAM,EAAE,MAAM,IAAI,CAAA;CACrB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAC9B,IAAI,EAAE,iBAAiB,EAAE,GAAG,SAAS,EACrC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,GACtC,oBAAoB,
|
|
1
|
+
{"version":3,"file":"use-document-sources.d.ts","sourceRoot":"","sources":["../../src/document/use-document-sources.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAQhD,MAAM,WAAW,oBAAoB;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAA;IAClD;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,OAAO,EAAE,OAAO,CAAA;IAChB,mEAAmE;IACnE,MAAM,EAAE,MAAM,IAAI,CAAA;CACrB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAC9B,IAAI,EAAE,iBAAiB,EAAE,GAAG,SAAS,EACrC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,GACtC,oBAAoB,CAuEtB"}
|
|
@@ -12,16 +12,19 @@ const VIA_CHUNK = 40;
|
|
|
12
12
|
export function useDocumentSources(defs, recordId) {
|
|
13
13
|
const api = useApi();
|
|
14
14
|
const [sources, setSources] = useState({});
|
|
15
|
+
const [totals, setTotals] = useState({});
|
|
15
16
|
const [loading, setLoading] = useState(false);
|
|
16
17
|
const [nonce, setNonce] = useState(0);
|
|
17
18
|
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
|
18
19
|
useEffect(() => {
|
|
19
20
|
if (!defs || defs.length === 0 || recordId === undefined || recordId === null) {
|
|
20
21
|
setSources({});
|
|
22
|
+
setTotals({});
|
|
21
23
|
return;
|
|
22
24
|
}
|
|
23
25
|
let cancelled = false;
|
|
24
26
|
setLoading(true);
|
|
27
|
+
const seenTotals = {};
|
|
25
28
|
const fetchRows = async (d, params) => {
|
|
26
29
|
try {
|
|
27
30
|
const res = await api.get(`/data/${d.model}`, {
|
|
@@ -29,6 +32,10 @@ export function useDocumentSources(defs, recordId) {
|
|
|
29
32
|
});
|
|
30
33
|
const body = res.data;
|
|
31
34
|
const rows = (body?.data ?? body);
|
|
35
|
+
const total = body?.meta?.total;
|
|
36
|
+
// Sólo las consultas directas (sin `via`): las de dos saltos se parten en trozos y no suman.
|
|
37
|
+
if (!d.via && typeof total === 'number' && Number.isFinite(total))
|
|
38
|
+
seenTotals[d.key] = total;
|
|
32
39
|
return Array.isArray(rows) ? rows : [];
|
|
33
40
|
}
|
|
34
41
|
catch {
|
|
@@ -62,11 +69,12 @@ export function useDocumentSources(defs, recordId) {
|
|
|
62
69
|
if (cancelled)
|
|
63
70
|
return;
|
|
64
71
|
setSources(out);
|
|
72
|
+
setTotals(seenTotals);
|
|
65
73
|
setLoading(false);
|
|
66
74
|
});
|
|
67
75
|
return () => {
|
|
68
76
|
cancelled = true;
|
|
69
77
|
};
|
|
70
78
|
}, [api, defs, recordId, nonce]);
|
|
71
|
-
return { sources, loading, reload };
|
|
79
|
+
return { sources, totals, loading, reload };
|
|
72
80
|
}
|
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
|
+
})
|
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
|
+
}
|