@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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,281 @@
|
|
|
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, type LineItem } from './line-items'
|
|
6
|
+
import { roundMoney, toAmount } from './format'
|
|
7
|
+
|
|
8
|
+
/** Semáforo de un punto: pendiente (sin revisar), verde, amarillo, rojo. */
|
|
9
|
+
export type InspectionStatus = 'pending' | 'ok' | 'attention' | 'urgent'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Qué captura el punto: `check` solo el semáforo; `tread` profundidad de dibujo
|
|
13
|
+
* en mm (el semáforo sale del valor); `measure` un valor numérico libre con su
|
|
14
|
+
* unidad. El nivel de combustible y el kilometraje van en el encabezado.
|
|
15
|
+
*/
|
|
16
|
+
export type InspectionPointKind = 'check' | 'tread' | 'measure'
|
|
17
|
+
|
|
18
|
+
export type InspectionMediaType = 'photo' | 'video'
|
|
19
|
+
|
|
20
|
+
export interface InspectionMedia {
|
|
21
|
+
/** Clave estable de UI. */
|
|
22
|
+
key: string
|
|
23
|
+
url: string
|
|
24
|
+
type: InspectionMediaType
|
|
25
|
+
caption?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InspectionPoint {
|
|
29
|
+
/** Clave estable de UI (no se envía). */
|
|
30
|
+
key: string
|
|
31
|
+
/** Id de la fila guardada, si ya existe. */
|
|
32
|
+
id?: string
|
|
33
|
+
/** Agrupa los puntos en el checklist («Llantas», «Frenos»...). */
|
|
34
|
+
section: string
|
|
35
|
+
name: string
|
|
36
|
+
kind: InspectionPointKind
|
|
37
|
+
status: InspectionStatus
|
|
38
|
+
/** Valor medido (`tread`/`measure`). */
|
|
39
|
+
value?: number | null
|
|
40
|
+
unit?: string
|
|
41
|
+
notes: string
|
|
42
|
+
/** Trabajo recomendado; con texto, el punto es un renglón candidato. */
|
|
43
|
+
recommendation: string
|
|
44
|
+
quantity: number
|
|
45
|
+
unit_price: number
|
|
46
|
+
media: InspectionMedia[]
|
|
47
|
+
/** Respuesta del cliente a la recomendación, cuando ya la dio. */
|
|
48
|
+
approval?: 'none' | 'pending' | 'approved' | 'declined'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type FuelLevel = 'empty' | 'quarter' | 'half' | 'three_quarters' | 'full'
|
|
52
|
+
|
|
53
|
+
export const FUEL_LEVELS: readonly FuelLevel[] = ['empty', 'quarter', 'half', 'three_quarters', 'full']
|
|
54
|
+
|
|
55
|
+
/** Encabezado de una recepción: lo que se ve al recibir el vehículo. */
|
|
56
|
+
export interface InspectionHeader {
|
|
57
|
+
odometer_km?: number | null
|
|
58
|
+
fuel_level?: FuelLevel | ''
|
|
59
|
+
/** Daños previos visibles, para deslindar responsabilidad. */
|
|
60
|
+
damage_notes: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Umbrales de profundidad de dibujo en mm (el límite legal común es 1.6). */
|
|
64
|
+
export interface TreadThresholds {
|
|
65
|
+
/** Por debajo de esto el semáforo es rojo. Default 1.6. */
|
|
66
|
+
urgent: number
|
|
67
|
+
/** Por debajo de esto (y sobre `urgent`) es amarillo. Default 3. */
|
|
68
|
+
attention: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const DEFAULT_TREAD_THRESHOLDS: TreadThresholds = { urgent: 1.6, attention: 3 }
|
|
72
|
+
|
|
73
|
+
let seq = 0
|
|
74
|
+
export function newInspectionKey(prefix = 'ip'): string {
|
|
75
|
+
seq += 1
|
|
76
|
+
return `${prefix}-${Date.now().toString(36)}-${seq}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function makeInspectionPoint(partial: Partial<InspectionPoint> = {}): InspectionPoint {
|
|
80
|
+
return {
|
|
81
|
+
key: partial.key ?? newInspectionKey(),
|
|
82
|
+
section: '',
|
|
83
|
+
name: '',
|
|
84
|
+
kind: 'check',
|
|
85
|
+
status: 'pending',
|
|
86
|
+
notes: '',
|
|
87
|
+
recommendation: '',
|
|
88
|
+
quantity: 1,
|
|
89
|
+
unit_price: 0,
|
|
90
|
+
media: [],
|
|
91
|
+
...partial,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Semáforo que le toca a una profundidad de dibujo. Sin valor: `pending`. */
|
|
96
|
+
export function treadStatus(mm: number | null | undefined, th: TreadThresholds = DEFAULT_TREAD_THRESHOLDS): InspectionStatus {
|
|
97
|
+
if (mm == null || !Number.isFinite(mm)) return 'pending'
|
|
98
|
+
if (mm < th.urgent) return 'urgent'
|
|
99
|
+
if (mm < th.attention) return 'attention'
|
|
100
|
+
return 'ok'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const RANK: Record<InspectionStatus, number> = { pending: 0, ok: 1, attention: 2, urgent: 3 }
|
|
104
|
+
|
|
105
|
+
/** El peor estado de la lista (rojo > amarillo > verde > pendiente). */
|
|
106
|
+
export function worstStatus(points: Pick<InspectionPoint, 'status'>[]): InspectionStatus {
|
|
107
|
+
return points.reduce<InspectionStatus>((w, p) => (RANK[p.status] > RANK[w] ? p.status : w), 'pending')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** ¿El punto trae una recomendación que se puede cotizar? */
|
|
111
|
+
export function hasRecommendation(p: Pick<InspectionPoint, 'recommendation'>): boolean {
|
|
112
|
+
return p.recommendation.trim() !== ''
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface InspectionSummary {
|
|
116
|
+
total: number
|
|
117
|
+
counts: Record<InspectionStatus, number>
|
|
118
|
+
worst: InspectionStatus
|
|
119
|
+
/** Puntos sin revisar. */
|
|
120
|
+
pending: number
|
|
121
|
+
/** Puntos con recomendación. */
|
|
122
|
+
recommendations: number
|
|
123
|
+
/** Suma cantidad × precio de las recomendaciones. */
|
|
124
|
+
recommendedTotal: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function summarizeInspection(points: InspectionPoint[]): InspectionSummary {
|
|
128
|
+
const counts: Record<InspectionStatus, number> = { pending: 0, ok: 0, attention: 0, urgent: 0 }
|
|
129
|
+
let recommendations = 0
|
|
130
|
+
let recommendedTotal = 0
|
|
131
|
+
for (const p of points) {
|
|
132
|
+
counts[p.status] += 1
|
|
133
|
+
if (hasRecommendation(p)) {
|
|
134
|
+
recommendations += 1
|
|
135
|
+
recommendedTotal += p.quantity * p.unit_price
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
total: points.length,
|
|
140
|
+
counts,
|
|
141
|
+
worst: worstStatus(points),
|
|
142
|
+
pending: counts.pending,
|
|
143
|
+
recommendations,
|
|
144
|
+
recommendedTotal: roundMoney(recommendedTotal),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface InspectionValidation {
|
|
149
|
+
ok: boolean
|
|
150
|
+
/** Errores por `"<índice>.<campo>"` (mismo contrato que LineItemsEditor). */
|
|
151
|
+
errors: Record<string, string>
|
|
152
|
+
/** Error de formulario (encabezado o conjunto). */
|
|
153
|
+
form?: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface InspectionRules {
|
|
157
|
+
/** Exige revisar todos los puntos (ninguno `pending`). Default false (borrador). */
|
|
158
|
+
requireAllReviewed?: boolean
|
|
159
|
+
/** Exige kilometraje en el encabezado (recepción). Default false. */
|
|
160
|
+
requireOdometer?: boolean
|
|
161
|
+
/** Un punto amarillo/rojo debe llevar nota, recomendación o evidencia. Default false. */
|
|
162
|
+
requireEvidenceOnIssues?: boolean
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function validateInspection(
|
|
166
|
+
points: InspectionPoint[],
|
|
167
|
+
header?: InspectionHeader,
|
|
168
|
+
rules: InspectionRules = {},
|
|
169
|
+
): InspectionValidation {
|
|
170
|
+
const errors: Record<string, string> = {}
|
|
171
|
+
points.forEach((p, i) => {
|
|
172
|
+
if (p.name.trim() === '') errors[`${i}.name`] = 'Falta el nombre del punto'
|
|
173
|
+
if ((p.kind === 'tread' || p.kind === 'measure') && p.value != null && (!Number.isFinite(p.value) || p.value < 0)) {
|
|
174
|
+
errors[`${i}.value`] = 'La medición no puede ser negativa'
|
|
175
|
+
}
|
|
176
|
+
if (p.quantity < 0 || p.unit_price < 0) errors[`${i}.unit_price`] = 'Cantidad y precio no pueden ser negativos'
|
|
177
|
+
if (hasRecommendation(p) && p.status === 'ok') {
|
|
178
|
+
errors[`${i}.recommendation`] = 'Un punto en verde no lleva recomendación'
|
|
179
|
+
}
|
|
180
|
+
if (
|
|
181
|
+
rules.requireEvidenceOnIssues &&
|
|
182
|
+
(p.status === 'attention' || p.status === 'urgent') &&
|
|
183
|
+
p.notes.trim() === '' &&
|
|
184
|
+
!hasRecommendation(p) &&
|
|
185
|
+
p.media.length === 0
|
|
186
|
+
) {
|
|
187
|
+
errors[`${i}.notes`] = 'Anota, recomienda o agrega una foto'
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
let form: string | undefined
|
|
191
|
+
if (rules.requireAllReviewed && points.some((p) => p.status === 'pending')) {
|
|
192
|
+
form = 'Faltan puntos por revisar'
|
|
193
|
+
}
|
|
194
|
+
if (rules.requireOdometer && header && (header.odometer_km == null || !(header.odometer_km >= 0))) {
|
|
195
|
+
form = form ?? 'Captura el kilometraje'
|
|
196
|
+
}
|
|
197
|
+
return { ok: Object.keys(errors).length === 0 && !form, errors, form }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Única puerta de salida hacia el backend: números reales, sin claves de UI,
|
|
202
|
+
* opcionales vacíos omitidos y `line_no` 1-based (mismo criterio que
|
|
203
|
+
* `serializeLineItems`).
|
|
204
|
+
*/
|
|
205
|
+
export function serializeInspectionPoints(points: InspectionPoint[]): Record<string, unknown>[] {
|
|
206
|
+
return points.map((p, i) => {
|
|
207
|
+
const o: Record<string, unknown> = {
|
|
208
|
+
line_no: i + 1,
|
|
209
|
+
section: p.section.trim(),
|
|
210
|
+
name: p.name.trim(),
|
|
211
|
+
kind: p.kind,
|
|
212
|
+
status: p.status,
|
|
213
|
+
notes: p.notes.trim(),
|
|
214
|
+
recommendation: p.recommendation.trim(),
|
|
215
|
+
quantity: toAmount(p.quantity),
|
|
216
|
+
unit_price: toAmount(p.unit_price),
|
|
217
|
+
media: p.media.map((m) => ({ url: m.url, type: m.type, ...(m.caption ? { caption: m.caption } : {}) })),
|
|
218
|
+
}
|
|
219
|
+
if (p.id) o.id = p.id
|
|
220
|
+
if (p.value != null && Number.isFinite(p.value)) o.measure_value = p.value
|
|
221
|
+
if (p.unit) o.unit = p.unit
|
|
222
|
+
return o
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Inversa de `serializeInspectionPoints`: filas del backend a puntos de UI. */
|
|
227
|
+
export function parseInspectionPoints(rows: unknown): InspectionPoint[] {
|
|
228
|
+
if (!Array.isArray(rows)) return []
|
|
229
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : String(v))
|
|
230
|
+
return rows.map((r) => {
|
|
231
|
+
const o = (r ?? {}) as Record<string, unknown>
|
|
232
|
+
const status = str(o.status) as InspectionStatus
|
|
233
|
+
const kind = str(o.kind) as InspectionPointKind
|
|
234
|
+
const media = Array.isArray(o.media)
|
|
235
|
+
? (o.media as Record<string, unknown>[]).map((m) => ({
|
|
236
|
+
key: newInspectionKey('im'),
|
|
237
|
+
url: str(m.url),
|
|
238
|
+
type: (str(m.type) === 'video' ? 'video' : 'photo') as InspectionMediaType,
|
|
239
|
+
caption: str(m.caption) || undefined,
|
|
240
|
+
}))
|
|
241
|
+
: []
|
|
242
|
+
const value = o.measure_value ?? o.value
|
|
243
|
+
return makeInspectionPoint({
|
|
244
|
+
id: str(o.id) || undefined,
|
|
245
|
+
section: str(o.section),
|
|
246
|
+
name: str(o.name),
|
|
247
|
+
kind: kind === 'tread' || kind === 'measure' ? kind : 'check',
|
|
248
|
+
status: status in RANK ? status : 'pending',
|
|
249
|
+
value: value == null || value === '' ? null : toAmount(value),
|
|
250
|
+
unit: str(o.unit) || undefined,
|
|
251
|
+
notes: str(o.notes),
|
|
252
|
+
recommendation: str(o.recommendation),
|
|
253
|
+
quantity: o.quantity == null ? 1 : toAmount(o.quantity),
|
|
254
|
+
unit_price: toAmount(o.unit_price),
|
|
255
|
+
media,
|
|
256
|
+
approval: ['none', 'pending', 'approved', 'declined'].includes(str(o.approval))
|
|
257
|
+
? (str(o.approval) as InspectionPoint['approval'])
|
|
258
|
+
: undefined,
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Recomendación → renglón del presupuesto: cada punto con recomendación se
|
|
265
|
+
* vuelve un renglón `item` (descripción «Punto — recomendación»). Se puede
|
|
266
|
+
* filtrar por respuesta del cliente (p. ej. solo las aprobadas).
|
|
267
|
+
*/
|
|
268
|
+
export function recommendationsToLines(
|
|
269
|
+
points: InspectionPoint[],
|
|
270
|
+
opts: { approval?: InspectionPoint['approval'][] } = {},
|
|
271
|
+
): LineItem[] {
|
|
272
|
+
return points
|
|
273
|
+
.filter((p) => hasRecommendation(p) && (!opts.approval || (p.approval != null && opts.approval.includes(p.approval))))
|
|
274
|
+
.map((p) =>
|
|
275
|
+
makeLine({
|
|
276
|
+
description: `${p.name} — ${p.recommendation.trim()}`,
|
|
277
|
+
quantity: p.quantity > 0 ? p.quantity : 1,
|
|
278
|
+
unit_price: p.unit_price,
|
|
279
|
+
}),
|
|
280
|
+
)
|
|
281
|
+
}
|
|
@@ -104,4 +104,20 @@ describe('DocumentPage', () => {
|
|
|
104
104
|
await waitFor(() => expect(open).toHaveBeenCalledWith('https://x/y.pdf', '_blank', 'noopener,noreferrer'))
|
|
105
105
|
expect(onAction).not.toHaveBeenCalled()
|
|
106
106
|
})
|
|
107
|
+
|
|
108
|
+
it('el contador usa meta.total del servidor, no el tamaño de la página traída', async () => {
|
|
109
|
+
const api = {
|
|
110
|
+
get: vi.fn().mockResolvedValue({
|
|
111
|
+
data: { success: true, data: [{ id: 'r1', fiscal_uuid: 'REP-UUID-1' }], meta: { total: 312 } },
|
|
112
|
+
}),
|
|
113
|
+
} as unknown as ApiClient
|
|
114
|
+
render(
|
|
115
|
+
<ApiProvider client={api}>
|
|
116
|
+
<DocumentPage registration={registration} record={{ id: 'i1', number: 'A-42', total: 1500 }} onAction={vi.fn()} currency='MXN' />
|
|
117
|
+
</ApiProvider>,
|
|
118
|
+
)
|
|
119
|
+
// 312 en el botón inteligente y en la pestaña; nunca «1» (una fila traída).
|
|
120
|
+
await waitFor(() => expect(screen.getAllByText('312').length).toBeGreaterThan(0))
|
|
121
|
+
expect(screen.queryAllByText('1')).toHaveLength(0)
|
|
122
|
+
})
|
|
107
123
|
})
|
|
@@ -60,12 +60,12 @@ export function DocumentPage(props: DocumentPageProps) {
|
|
|
60
60
|
const fmt: FieldFormatOptions = { locale: props.locale, currency: props.currency, timeZone: props.timeZone }
|
|
61
61
|
|
|
62
62
|
const recordId = record.id as string | number | undefined
|
|
63
|
-
const { sources, reload } = useDocumentSources(spec.sources, recordId)
|
|
63
|
+
const { sources, totals, reload } = useDocumentSources(spec.sources, recordId)
|
|
64
64
|
|
|
65
65
|
const ctx: DocumentContext = useMemo(() => {
|
|
66
|
-
const base = { record, sources }
|
|
66
|
+
const base = { record, sources, totals }
|
|
67
67
|
return { ...base, derived: registration.derive?.(base) ?? {} }
|
|
68
|
-
}, [record, sources, registration])
|
|
68
|
+
}, [record, sources, totals, registration])
|
|
69
69
|
|
|
70
70
|
// Recarga las fuentes cuando el host avisa de un cambio (acción ejecutada).
|
|
71
71
|
const [lastReload, setLastReload] = useState(props.reloadKey ?? 0)
|
|
@@ -120,7 +120,7 @@ export function DocumentPage(props: DocumentPageProps) {
|
|
|
120
120
|
|
|
121
121
|
const smart: SmartButtonItem[] = (spec.smartButtons ?? [])
|
|
122
122
|
.map((b): SmartButtonItem | null => {
|
|
123
|
-
const count = b.source ?
|
|
123
|
+
const count = b.source ? sourceCount(ctx, b.source) : undefined
|
|
124
124
|
const active = b.source ? (count ?? 0) > 0 : b.field ? !!readPath(ctx, b.field) : true
|
|
125
125
|
if (b.hideWhenEmpty && !active) return null
|
|
126
126
|
const href = b.href ? interpolate(b.href, ctx) : undefined
|
|
@@ -179,8 +179,13 @@ export function DocumentPage(props: DocumentPageProps) {
|
|
|
179
179
|
)
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/** Total de una fuente: el del servidor si lo dio (`meta.total`), si no las filas traídas. */
|
|
183
|
+
function sourceCount(ctx: DocumentContext, key: string): number {
|
|
184
|
+
return ctx.totals?.[key] ?? ctx.sources[key]?.length ?? 0
|
|
185
|
+
}
|
|
186
|
+
|
|
182
187
|
function tabCount(tb: DocumentTabDef, ctx: DocumentContext): number | undefined {
|
|
183
|
-
if (tb.kind === 'records') return ctx.sources[tb.source]
|
|
188
|
+
if (tb.kind === 'records') return ctx.sources[tb.source] ? sourceCount(ctx, tb.source) : undefined
|
|
184
189
|
if (tb.kind === 'slot' && tb.count) {
|
|
185
190
|
const v = readPath(ctx, tb.count)
|
|
186
191
|
return typeof v === 'number' ? v : undefined
|
package/src/document/types.ts
CHANGED
|
@@ -183,6 +183,8 @@ export interface DocumentContext {
|
|
|
183
183
|
record: Record<string, unknown>
|
|
184
184
|
/** Filas por fuente (`sources[key]`). */
|
|
185
185
|
sources: Record<string, Record<string, unknown>[]>
|
|
186
|
+
/** Total del servidor por fuente (`meta.total`); si falta, el contador usa el largo de `sources[key]`. */
|
|
187
|
+
totals?: Record<string, number>
|
|
186
188
|
/** Derivados (`$`). */
|
|
187
189
|
derived: Record<string, unknown>
|
|
188
190
|
}
|
|
@@ -11,6 +11,12 @@ type Rows = Record<string, unknown>[]
|
|
|
11
11
|
|
|
12
12
|
export interface DocumentSourcesState {
|
|
13
13
|
sources: Record<string, Record<string, unknown>[]>
|
|
14
|
+
/**
|
|
15
|
+
* Total del servidor por fuente directa (`meta.total`), para que un contador
|
|
16
|
+
* diga 312 y no «50» (el tamaño de la página que se trajo). Una fuente sin
|
|
17
|
+
* `meta.total` —o de dos saltos— no aparece: el contador cae al largo de sus filas.
|
|
18
|
+
*/
|
|
19
|
+
totals: Record<string, number>
|
|
14
20
|
loading: boolean
|
|
15
21
|
/** Vuelve a pedir todas las fuentes (tras ejecutar una acción). */
|
|
16
22
|
reload: () => void
|
|
@@ -27,6 +33,7 @@ export function useDocumentSources(
|
|
|
27
33
|
): DocumentSourcesState {
|
|
28
34
|
const api = useApi()
|
|
29
35
|
const [sources, setSources] = useState<Record<string, Record<string, unknown>[]>>({})
|
|
36
|
+
const [totals, setTotals] = useState<Record<string, number>>({})
|
|
30
37
|
const [loading, setLoading] = useState(false)
|
|
31
38
|
const [nonce, setNonce] = useState(0)
|
|
32
39
|
const reload = useCallback(() => setNonce((n) => n + 1), [])
|
|
@@ -34,10 +41,12 @@ export function useDocumentSources(
|
|
|
34
41
|
useEffect(() => {
|
|
35
42
|
if (!defs || defs.length === 0 || recordId === undefined || recordId === null) {
|
|
36
43
|
setSources({})
|
|
44
|
+
setTotals({})
|
|
37
45
|
return
|
|
38
46
|
}
|
|
39
47
|
let cancelled = false
|
|
40
48
|
setLoading(true)
|
|
49
|
+
const seenTotals: Record<string, number> = {}
|
|
41
50
|
const fetchRows = async (d: DocumentSourceDef, params: Record<string, string>): Promise<Rows> => {
|
|
42
51
|
try {
|
|
43
52
|
const res = await api.get(`/data/${d.model}`, {
|
|
@@ -45,6 +54,9 @@ export function useDocumentSources(
|
|
|
45
54
|
})
|
|
46
55
|
const body = res.data
|
|
47
56
|
const rows = (body?.data ?? body) as unknown
|
|
57
|
+
const total = body?.meta?.total
|
|
58
|
+
// Sólo las consultas directas (sin `via`): las de dos saltos se parten en trozos y no suman.
|
|
59
|
+
if (!d.via && typeof total === 'number' && Number.isFinite(total)) seenTotals[d.key] = total
|
|
48
60
|
return Array.isArray(rows) ? (rows as Rows) : []
|
|
49
61
|
} catch {
|
|
50
62
|
return []
|
|
@@ -80,6 +92,7 @@ export function useDocumentSources(
|
|
|
80
92
|
.then((out) => {
|
|
81
93
|
if (cancelled) return
|
|
82
94
|
setSources(out)
|
|
95
|
+
setTotals(seenTotals)
|
|
83
96
|
setLoading(false)
|
|
84
97
|
})
|
|
85
98
|
return () => {
|
|
@@ -87,5 +100,5 @@ export function useDocumentSources(
|
|
|
87
100
|
}
|
|
88
101
|
}, [api, defs, recordId, nonce])
|
|
89
102
|
|
|
90
|
-
return { sources, loading, reload }
|
|
103
|
+
return { sources, totals, loading, reload }
|
|
91
104
|
}
|