@asteby/metacore-runtime-react 46.1.0 → 46.2.1

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.
@@ -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
+ }
@@ -14,3 +14,15 @@ export function parseCalendarDate(v: unknown): Date | undefined {
14
14
  const d = new Date(s)
15
15
  return isNaN(d.getTime()) ? undefined : d
16
16
  }
17
+
18
+ /**
19
+ * True when a serialized value is a pure CALENDAR DAY: `YYYY-MM-DD`, or that day at
20
+ * exactly UTC midnight (how the backend serves a `date` column). Anything with a
21
+ * real time of day is an INSTANT: a `timestamptz` shown with a day-only renderer
22
+ * must be read in the organization's zone, or an evening invoice (29 Sep 19:41 in
23
+ * Mexico City = 30 Sep 00:41Z) shows as the 30th (PIT-025).
24
+ */
25
+ export function isCalendarDayValue(v: unknown): boolean {
26
+ if (typeof v !== 'string') return false
27
+ return /^\d{4}-\d{2}-\d{2}(?:[T ]00:00:00(?:\.0+)?(?:Z|\+00:00)?)?$/.test(v.trim())
28
+ }
@@ -15,7 +15,7 @@
15
15
  import * as React from 'react'
16
16
  import { ColumnDef } from '@tanstack/react-table'
17
17
  import { format, type Locale } from 'date-fns'
18
- import { parseCalendarDate } from './calendar-date'
18
+ import { isCalendarDayValue, parseCalendarDate } from './calendar-date'
19
19
  import { es, enUS } from 'date-fns/locale'
20
20
  import { Calendar, Check, Copy, Mail, Minus, MoreHorizontal } from 'lucide-react'
21
21
  import {
@@ -525,10 +525,13 @@ export function formatDateCell(
525
525
  }).format(date),
526
526
  }
527
527
  }
528
- // Pure calendar date: pin to UTC so it never shifts across zones.
528
+ // Pure calendar date: pin to UTC so it never shifts across zones. A value
529
+ // with a real time of day is an instant even under a day-only renderer
530
+ // (timestamptz columns like invoice_date): show ITS day in the org zone.
531
+ const calendarDay = typeof value !== 'string' || isCalendarDayValue(value)
529
532
  return {
530
533
  display: new Intl.DateTimeFormat(localeTag, {
531
- timeZone: 'UTC',
534
+ timeZone: calendarDay ? 'UTC' : timeZone,
532
535
  dateStyle: 'long',
533
536
  }).format(date),
534
537
  }