@asteby/metacore-runtime-react 45.0.0 → 45.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.
Files changed (137) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/approval-events.d.ts +16 -0
  3. package/dist/approval-events.d.ts.map +1 -0
  4. package/dist/approval-events.js +27 -0
  5. package/dist/approval-gate.d.ts +39 -0
  6. package/dist/approval-gate.d.ts.map +1 -0
  7. package/dist/approval-gate.js +110 -0
  8. package/dist/approval-pin-dialog.d.ts +25 -0
  9. package/dist/approval-pin-dialog.d.ts.map +1 -0
  10. package/dist/approval-pin-dialog.js +66 -0
  11. package/dist/business/catalog.d.ts +16 -0
  12. package/dist/business/catalog.d.ts.map +1 -0
  13. package/dist/business/catalog.js +42 -0
  14. package/dist/business/customer-picker.d.ts +39 -0
  15. package/dist/business/customer-picker.d.ts.map +1 -0
  16. package/dist/business/customer-picker.js +51 -0
  17. package/dist/business/feedback.d.ts +72 -0
  18. package/dist/business/feedback.d.ts.map +1 -0
  19. package/dist/business/feedback.js +52 -0
  20. package/dist/business/field-errors.d.ts +26 -0
  21. package/dist/business/field-errors.d.ts.map +1 -0
  22. package/dist/business/field-errors.js +59 -0
  23. package/dist/business/format.d.ts +49 -0
  24. package/dist/business/format.d.ts.map +1 -0
  25. package/dist/business/format.js +128 -0
  26. package/dist/business/index.d.ts +14 -0
  27. package/dist/business/index.d.ts.map +1 -0
  28. package/dist/business/index.js +13 -0
  29. package/dist/business/line-items-editor.d.ts +25 -0
  30. package/dist/business/line-items-editor.d.ts.map +1 -0
  31. package/dist/business/line-items-editor.js +36 -0
  32. package/dist/business/line-items.d.ts +69 -0
  33. package/dist/business/line-items.d.ts.map +1 -0
  34. package/dist/business/line-items.js +133 -0
  35. package/dist/business/payment-capture.d.ts +21 -0
  36. package/dist/business/payment-capture.d.ts.map +1 -0
  37. package/dist/business/payment-capture.js +24 -0
  38. package/dist/business/payment.d.ts +61 -0
  39. package/dist/business/payment.d.ts.map +1 -0
  40. package/dist/business/payment.js +74 -0
  41. package/dist/business/product-picker.d.ts +24 -0
  42. package/dist/business/product-picker.d.ts.map +1 -0
  43. package/dist/business/product-picker.js +48 -0
  44. package/dist/business/product-search.d.ts +70 -0
  45. package/dist/business/product-search.d.ts.map +1 -0
  46. package/dist/business/product-search.js +51 -0
  47. package/dist/business/refund-destination.d.ts +19 -0
  48. package/dist/business/refund-destination.d.ts.map +1 -0
  49. package/dist/business/refund-destination.js +31 -0
  50. package/dist/business/refund.d.ts +29 -0
  51. package/dist/business/refund.d.ts.map +1 -0
  52. package/dist/business/refund.js +43 -0
  53. package/dist/business/use-async-search.d.ts +10 -0
  54. package/dist/business/use-async-search.d.ts.map +1 -0
  55. package/dist/business/use-async-search.js +38 -0
  56. package/dist/document/action-bar.d.ts +12 -0
  57. package/dist/document/action-bar.d.ts.map +1 -0
  58. package/dist/document/action-bar.js +39 -0
  59. package/dist/document/cancel-with-reason.d.ts +47 -0
  60. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  61. package/dist/document/cancel-with-reason.js +52 -0
  62. package/dist/document/document-header.d.ts +41 -0
  63. package/dist/document/document-header.d.ts.map +1 -0
  64. package/dist/document/document-header.js +30 -0
  65. package/dist/document/document-page.d.ts +37 -0
  66. package/dist/document/document-page.d.ts.map +1 -0
  67. package/dist/document/document-page.js +137 -0
  68. package/dist/document/document-timeline.d.ts +20 -0
  69. package/dist/document/document-timeline.d.ts.map +1 -0
  70. package/dist/document/document-timeline.js +26 -0
  71. package/dist/document/evaluate.d.ts +31 -0
  72. package/dist/document/evaluate.d.ts.map +1 -0
  73. package/dist/document/evaluate.js +170 -0
  74. package/dist/document/format.d.ts +9 -0
  75. package/dist/document/format.d.ts.map +1 -0
  76. package/dist/document/format.js +52 -0
  77. package/dist/document/index.d.ts +13 -0
  78. package/dist/document/index.d.ts.map +1 -0
  79. package/dist/document/index.js +12 -0
  80. package/dist/document/registry.d.ts +20 -0
  81. package/dist/document/registry.d.ts.map +1 -0
  82. package/dist/document/registry.js +20 -0
  83. package/dist/document/smart-buttons.d.ts +16 -0
  84. package/dist/document/smart-buttons.d.ts.map +1 -0
  85. package/dist/document/smart-buttons.js +16 -0
  86. package/dist/document/status-badge.d.ts +16 -0
  87. package/dist/document/status-badge.d.ts.map +1 -0
  88. package/dist/document/status-badge.js +23 -0
  89. package/dist/document/types.d.ts +196 -0
  90. package/dist/document/types.d.ts.map +1 -0
  91. package/dist/document/types.js +1 -0
  92. package/dist/document/use-document-sources.d.ts +14 -0
  93. package/dist/document/use-document-sources.d.ts.map +1 -0
  94. package/dist/document/use-document-sources.js +72 -0
  95. package/dist/index.d.ts +4 -0
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +5 -0
  98. package/dist/server-error.d.ts.map +1 -1
  99. package/dist/server-error.js +19 -0
  100. package/package.json +1 -1
  101. package/src/__tests__/approval-gate.test.tsx +134 -0
  102. package/src/approval-events.ts +39 -0
  103. package/src/approval-gate.tsx +180 -0
  104. package/src/approval-pin-dialog.tsx +162 -0
  105. package/src/business/business.test.ts +164 -0
  106. package/src/business/catalog.ts +60 -0
  107. package/src/business/customer-picker.tsx +201 -0
  108. package/src/business/feedback.tsx +141 -0
  109. package/src/business/field-errors.ts +92 -0
  110. package/src/business/format.ts +151 -0
  111. package/src/business/index.ts +13 -0
  112. package/src/business/line-items-editor.tsx +259 -0
  113. package/src/business/line-items.ts +190 -0
  114. package/src/business/payment-capture.tsx +158 -0
  115. package/src/business/payment.ts +125 -0
  116. package/src/business/product-picker.tsx +171 -0
  117. package/src/business/product-search.ts +108 -0
  118. package/src/business/refund-destination.tsx +121 -0
  119. package/src/business/refund.ts +71 -0
  120. package/src/business/use-async-search.ts +43 -0
  121. package/src/document/__tests__/document-page.test.tsx +107 -0
  122. package/src/document/__tests__/evaluate.test.ts +140 -0
  123. package/src/document/action-bar.tsx +140 -0
  124. package/src/document/cancel-with-reason.tsx +214 -0
  125. package/src/document/document-header.tsx +140 -0
  126. package/src/document/document-page.tsx +268 -0
  127. package/src/document/document-timeline.tsx +66 -0
  128. package/src/document/evaluate.ts +180 -0
  129. package/src/document/format.ts +59 -0
  130. package/src/document/index.ts +28 -0
  131. package/src/document/registry.ts +39 -0
  132. package/src/document/smart-buttons.tsx +54 -0
  133. package/src/document/status-badge.tsx +44 -0
  134. package/src/document/types.ts +203 -0
  135. package/src/document/use-document-sources.ts +91 -0
  136. package/src/index.ts +23 -0
  137. package/src/server-error.ts +20 -0
@@ -0,0 +1,162 @@
1
+ // ApprovalPinDialog — the supervisor PIN prompt shared by every sensitive action
2
+ // (POS sell-without-stock, discount above the cap, price below the floor, refund,
3
+ // CFDI cancel, inventory adjust). Presentational: it collects a reason + a PIN and
4
+ // hands them to `onSubmit`, which does the network call, so federated addons that
5
+ // carry their own HTTP client reuse the same dialog as hosts with an ApiProvider.
6
+ //
7
+ // The reason is always required (audit trail: who asked, who authorized, why).
8
+ // A rejected `onSubmit` keeps the dialog open and shows the error inline (wrong
9
+ // PIN, locked, no permission) instead of a toast that can be missed.
10
+ import { useEffect, useState } from 'react'
11
+ import { useTranslation } from 'react-i18next'
12
+ import { Loader2, ShieldCheck } from 'lucide-react'
13
+ import {
14
+ Button,
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogFooter,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ Input,
22
+ Textarea,
23
+ } from '@asteby/metacore-ui/primitives'
24
+
25
+ export interface ApprovalPinSubmit {
26
+ pin: string
27
+ reason: string
28
+ }
29
+
30
+ export interface ApprovalPinDialogProps {
31
+ open: boolean
32
+ /** Headline, e.g. "Venta sin existencias". */
33
+ title: string
34
+ /** What is being authorized, in the operator's words. */
35
+ description?: React.ReactNode
36
+ /** Called with the typed PIN + reason. Throw (or reject) to keep the dialog open. */
37
+ onSubmit: (v: ApprovalPinSubmit) => Promise<void>
38
+ onCancel: () => void
39
+ /** Optional secondary action, e.g. "Enviar a la bandeja" (async request). */
40
+ secondaryAction?: { label: string; onClick: () => void }
41
+ }
42
+
43
+ /** Best human message out of an axios-style error carrying the kernel envelope. */
44
+ export function approvalErrorMessage(err: unknown, t: (k: string, o?: { defaultValue?: string }) => string): string {
45
+ const data = (err as { response?: { data?: { error?: { code?: string; message?: string }; message?: string } } })?.response?.data
46
+ switch (data?.error?.code) {
47
+ case 'approval_pin_invalid':
48
+ return t('approvals.pin_invalid', { defaultValue: 'PIN incorrecto o sin permiso para autorizar esto.' })
49
+ case 'approval_pin_locked':
50
+ return t('approvals.pin_locked', { defaultValue: 'Demasiados intentos. Espera unos minutos.' })
51
+ case 'approval_pin_unavailable':
52
+ return t('approvals.pin_unavailable', { defaultValue: 'La autorización con PIN no está disponible.' })
53
+ case 'approval_reason_required':
54
+ return t('approvals.reason_required', { defaultValue: 'Escribe el motivo.' })
55
+ }
56
+ return data?.error?.message || data?.message || (err as Error)?.message || t('common.error', { defaultValue: 'Algo salió mal' })
57
+ }
58
+
59
+ export function ApprovalPinDialog({ open, title, description, onSubmit, onCancel, secondaryAction }: ApprovalPinDialogProps) {
60
+ const { t } = useTranslation()
61
+ const [pin, setPin] = useState('')
62
+ const [reason, setReason] = useState('')
63
+ const [busy, setBusy] = useState(false)
64
+ const [error, setError] = useState<string | null>(null)
65
+
66
+ useEffect(() => {
67
+ if (open) {
68
+ setPin('')
69
+ setReason('')
70
+ setError(null)
71
+ setBusy(false)
72
+ }
73
+ }, [open])
74
+
75
+ const ready = /^\d{4,8}$/.test(pin) && reason.trim().length > 0
76
+
77
+ const submit = async () => {
78
+ if (!ready || busy) return
79
+ setBusy(true)
80
+ setError(null)
81
+ try {
82
+ await onSubmit({ pin, reason: reason.trim() })
83
+ } catch (err) {
84
+ setError(approvalErrorMessage(err, t as never))
85
+ setPin('')
86
+ } finally {
87
+ setBusy(false)
88
+ }
89
+ }
90
+
91
+ return (
92
+ <Dialog open={open} onOpenChange={(o) => { if (!o && !busy) onCancel() }}>
93
+ <DialogContent className="sm:max-w-md">
94
+ <DialogHeader>
95
+ <DialogTitle className="flex items-center gap-2">
96
+ <ShieldCheck className="h-5 w-5" />
97
+ {title}
98
+ </DialogTitle>
99
+ <DialogDescription asChild>
100
+ <div>
101
+ {description ?? t('approvals.pin_prompt', { defaultValue: 'Requiere autorización de un supervisor.' })}
102
+ </div>
103
+ </DialogDescription>
104
+ </DialogHeader>
105
+ <form
106
+ className="space-y-3"
107
+ onSubmit={(e) => {
108
+ e.preventDefault()
109
+ void submit()
110
+ }}
111
+ >
112
+ <div className="space-y-1.5">
113
+ <label className="text-sm font-medium" htmlFor="approval-reason">
114
+ {t('approvals.reason', { defaultValue: 'Motivo' })}
115
+ </label>
116
+ <Textarea
117
+ id="approval-reason"
118
+ value={reason}
119
+ onChange={(e) => setReason(e.target.value)}
120
+ rows={2}
121
+ placeholder={t('approvals.reason_placeholder', { defaultValue: 'Por qué se autoriza' })}
122
+ />
123
+ </div>
124
+ <div className="space-y-1.5">
125
+ <label className="text-sm font-medium" htmlFor="approval-pin">
126
+ {t('approvals.pin', { defaultValue: 'PIN del supervisor' })}
127
+ </label>
128
+ <Input
129
+ id="approval-pin"
130
+ type="password"
131
+ inputMode="numeric"
132
+ autoComplete="off"
133
+ maxLength={8}
134
+ value={pin}
135
+ onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
136
+ aria-invalid={error ? true : undefined}
137
+ />
138
+ </div>
139
+ {error ? (
140
+ <p role="alert" className="text-sm text-destructive">
141
+ {error}
142
+ </p>
143
+ ) : null}
144
+ <DialogFooter className="gap-2 sm:gap-2">
145
+ {secondaryAction ? (
146
+ <Button type="button" variant="ghost" onClick={secondaryAction.onClick} disabled={busy}>
147
+ {secondaryAction.label}
148
+ </Button>
149
+ ) : null}
150
+ <Button type="button" variant="outline" onClick={onCancel} disabled={busy}>
151
+ {t('common.cancel', { defaultValue: 'Cancelar' })}
152
+ </Button>
153
+ <Button type="submit" disabled={!ready || busy}>
154
+ {busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
155
+ {t('approvals.authorize', { defaultValue: 'Autorizar' })}
156
+ </Button>
157
+ </DialogFooter>
158
+ </form>
159
+ </DialogContent>
160
+ </Dialog>
161
+ )
162
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createFormatter, formatDate, formatDateTime, formatMoney, roundMoney, toAmount } from './format'
3
+ import { mapApiError } from './field-errors'
4
+ import {
5
+ computeTotals,
6
+ makeLine,
7
+ parseLineItems,
8
+ serializeLineItems,
9
+ validateLineItems,
10
+ } from './line-items'
11
+ import { newTender, serializePayment, summarizePayment, validatePayment, type PaymentMethodOption } from './payment'
12
+ import { singleDestination, validateRefund } from './refund'
13
+ import { parseProductQuery, parseTireSize, productToLine } from './product-search'
14
+
15
+ const nbsp = (s: string) => s.replace(/ /g, ' ')
16
+
17
+ describe('format', () => {
18
+ it('formatea moneda es-MX con símbolo corto y sin mezclar locale (PIT-023)', () => {
19
+ expect(nbsp(formatMoney(1060, { currency: 'MXN' }))).toBe('$1,060.00')
20
+ expect(nbsp(formatMoney(1.06, { currency: 'MXN' }))).toBe('$1.06')
21
+ expect(formatMoney('abc')).toBe('$0.00')
22
+ expect(formatMoney(NaN)).toBe('')
23
+ })
24
+ it('usa la moneda dada, con USD solo como último recurso', () => {
25
+ expect(nbsp(formatMoney(5, { currency: 'EUR' }))).toContain('€')
26
+ expect(nbsp(formatMoney(5))).toBe('US$5.00'.replace('US', ''))
27
+ })
28
+ it('una fecha de calendario no retrocede un día (PIT-025)', () => {
29
+ expect(formatDate('2026-09-29', { timeZone: 'America/Mexico_City' })).toContain('29')
30
+ expect(formatDateTime('2026-09-29T02:00:00Z', { timeZone: 'America/Mexico_City' })).toContain('28')
31
+ })
32
+ it('createFormatter fija locale, moneda y zona', () => {
33
+ const f = createFormatter({ currency: 'MXN', timeZone: 'America/Mexico_City' })
34
+ expect(nbsp(f.money(10))).toBe('$10.00')
35
+ expect(f.locale).toBe('es-MX')
36
+ })
37
+ it('roundMoney y toAmount', () => {
38
+ expect(roundMoney(1.005)).toBe(1.01)
39
+ expect(toAmount('$1,234.50')).toBe(1234.5)
40
+ expect(toAmount('1.234,50')).toBe(1234.5)
41
+ })
42
+ })
43
+
44
+ describe('mapApiError', () => {
45
+ const axiosErr = (status: number, data: unknown) => ({ response: { status, data } })
46
+ it('422 con errors → mensajes por campo', () => {
47
+ const m = mapApiError(axiosErr(422, { errors: { sku: [{ code: 'duplicate' }] } }), { labels: { sku: 'SKU' } })
48
+ expect(m.hasFieldErrors).toBe(true)
49
+ expect(m.fields.sku).toContain('SKU')
50
+ })
51
+ it('409 duplicado que nombra el campo → error del campo', () => {
52
+ const m = mapApiError(axiosErr(409, { message: 'conflict', details: 'Key (sku)=(A1) already exists' }), {
53
+ knownFields: ['sku', 'name'],
54
+ labels: { sku: 'SKU' },
55
+ })
56
+ expect(m.status).toBe(409)
57
+ expect(m.fields.sku).toBe('Ya existe un registro con ese SKU')
58
+ })
59
+ it('409 sin campo → banner general', () => {
60
+ const m = mapApiError(axiosErr(409, { message: 'Estado inválido' }))
61
+ expect(m.hasFieldErrors).toBe(false)
62
+ expect(m.form).toBe('Estado inválido')
63
+ })
64
+ it('red caída → fallback', () => {
65
+ expect(mapApiError(new Error('Network Error'), { fallback: 'Falló' }).form).toContain('Falló')
66
+ })
67
+ })
68
+
69
+ describe('line items', () => {
70
+ it('calcula totales con descuento e impuesto', () => {
71
+ const lines = [makeLine({ quantity: 2, unit_price: 100, discount: 10, tax_rate: 0.16 }), makeLine({ kind: 'section', description: 'Mano de obra' })]
72
+ expect(computeTotals(lines)).toMatchObject({ subtotal: 200, discount: 20, tax: 28.8, total: 208.8, units: 2 })
73
+ })
74
+ it('valida negativos, descuento y sobreventa según política', () => {
75
+ const l = makeLine({ description: 'x', quantity: -1, unit_price: -5, discount: 120 })
76
+ const v = validateLineItems([l])
77
+ expect(v.valid).toBe(false)
78
+ expect(Object.keys(v.errors)).toEqual(expect.arrayContaining(['0.quantity', '0.unit_price', '0.discount']))
79
+ const over = makeLine({ description: 'x', quantity: 5, available: 2 })
80
+ expect(validateLineItems([over], { stockPolicy: 'warn' })).toMatchObject({ valid: true })
81
+ expect(validateLineItems([over], { stockPolicy: 'warn' }).warnings['0.quantity']).toBeTruthy()
82
+ expect(validateLineItems([over], { stockPolicy: 'block' }).valid).toBe(false)
83
+ expect(validateLineItems([over], { stockPolicy: 'allow' }).warnings).toEqual({})
84
+ })
85
+ it('exige al menos un renglón', () => {
86
+ expect(validateLineItems([]).form).toBeTruthy()
87
+ expect(validateLineItems([], { requireItems: false }).valid).toBe(true)
88
+ })
89
+ it('serializa sin cadenas vacías ni claves de UI y round-trip (PIT-018)', () => {
90
+ const out = serializeLineItems([
91
+ makeLine({ description: ' Llanta ', quantity: '2' as unknown as number, unit_price: '' as unknown as number, sku: '', lot: ' L1 ' }),
92
+ makeLine({ kind: 'note', description: 'Nota' }),
93
+ ])
94
+ expect(out[0]).toEqual({ position: 1, kind: 'item', description: 'Llanta', quantity: 2, unit_price: 0, discount: 0, tax_rate: 0, lot: 'L1' })
95
+ expect(out[1]).toEqual({ position: 2, kind: 'note', description: 'Nota' })
96
+ expect(out[0]).not.toHaveProperty('key')
97
+ expect(parseLineItems(out)).toHaveLength(2)
98
+ })
99
+ })
100
+
101
+ describe('payment', () => {
102
+ const methods: PaymentMethodOption[] = [
103
+ { id: 'cash', label: 'Efectivo', cash: true },
104
+ { id: 'card', label: 'Tarjeta', requiresReference: true },
105
+ ]
106
+ it('cambio solo sobre efectivo', () => {
107
+ const s = summarizePayment(100, [newTender('cash', 150)], methods)
108
+ expect(s).toMatchObject({ paid: 150, change: 50, remaining: 0, overpaid: 0, settled: true })
109
+ const o = summarizePayment(100, [newTender('card', 130)], methods)
110
+ expect(o).toMatchObject({ change: 0, overpaid: 30 })
111
+ })
112
+ it('mixto: la tarjeta pide referencia y no se puede exceder', () => {
113
+ const t = [newTender('cash', 40), newTender('card', 60)]
114
+ const v = validatePayment(100, t, methods)
115
+ expect(v.errors['1.reference']).toBeTruthy()
116
+ t[1]!.reference = 'AUTH1'
117
+ expect(validatePayment(100, t, methods).valid).toBe(true)
118
+ expect(validatePayment(100, [newTender('card', 130)], methods).form).toMatch(/efectivo/)
119
+ })
120
+ it('parcial solo con política', () => {
121
+ const t = [newTender('cash', 30)]
122
+ expect(validatePayment(100, t, methods).valid).toBe(false)
123
+ expect(validatePayment(100, t, methods, { allowPartial: true }).valid).toBe(true)
124
+ expect(serializePayment(t, methods, 100)).toMatchObject({ paid: 30, remaining: 70, change: 0 })
125
+ })
126
+ })
127
+
128
+ describe('refund', () => {
129
+ it('un destino cubre todo', () => {
130
+ expect(validateRefund(100, singleDestination('cash', 100)).valid).toBe(true)
131
+ })
132
+ it('respeta topes, no repite destino y exige cuadrar', () => {
133
+ const opts = [
134
+ { kind: 'card' as const, label: 'Tarjeta', maxAmount: 60 },
135
+ { kind: 'cash' as const, label: 'Efectivo' },
136
+ { kind: 'credit_note' as const, label: 'NC', available: false, disabledReason: 'Sin CFDI' },
137
+ ]
138
+ expect(validateRefund(100, [{ kind: 'card', amount: 80 }], opts).errors['0.amount']).toBeTruthy()
139
+ expect(validateRefund(100, [{ kind: 'card', amount: 60 }, { kind: 'cash', amount: 40 }], opts).valid).toBe(true)
140
+ expect(validateRefund(100, [{ kind: 'card', amount: 60 }], opts).form).toMatch(/Falta/)
141
+ expect(validateRefund(100, [{ kind: 'credit_note', amount: 100 }], opts).errors['0.kind']).toBe('Sin CFDI')
142
+ })
143
+ })
144
+
145
+ describe('product search', () => {
146
+ it('clasifica medida, código de barras, y texto', () => {
147
+ expect(parseTireSize('205/55R16')?.normalized).toBe('205/55R16')
148
+ expect(parseTireSize('205 55 16')?.normalized).toBe('205/55R16')
149
+ expect(parseTireSize('225-45-ZR17')?.normalized).toBe('225/45R17')
150
+ expect(parseTireSize('999/99R99')).toBeNull()
151
+ expect(parseProductQuery('7501234567890')).toMatchObject({ kind: 'barcode' })
152
+ expect(parseProductQuery('205/55R16')).toMatchObject({ kind: 'tire_size' })
153
+ expect(parseProductQuery(' ')).toMatchObject({ kind: 'empty' })
154
+ expect(parseProductQuery('michelin')).toMatchObject({ kind: 'text', text: 'michelin' })
155
+ })
156
+ it('producto + variante → renglón con existencia por almacén', () => {
157
+ const line = productToLine(
158
+ { id: 'p1', name: 'Llanta X', price: 1000, tax_rate: 0.16, variants: [] },
159
+ { id: 'v1', label: '205/55R16', price: 1200, stock: [{ warehouse_id: 'a', available: 3 }, { warehouse_id: 'b', available: 9 }] },
160
+ { warehouseId: 'a' },
161
+ )
162
+ expect(line).toMatchObject({ product_id: 'v1', unit_price: 1200, tax_rate: 0.16, available: 3, description: 'Llanta X · 205/55R16' })
163
+ })
164
+ })
@@ -0,0 +1,60 @@
1
+ // Catálogo formal de componentes de negocio compartidos (benchmark §7).
2
+ // Es metadato legible por máquina: el contrato estable (props, eventos,
3
+ // permisos) que los addons consumen. Ver docs/business-components.md.
4
+ export interface BusinessComponentSpec {
5
+ /** Nombre exportado desde `@asteby/metacore-runtime-react`. */
6
+ name: string
7
+ /** Prop de datos principal y su tipo. */
8
+ value: string
9
+ /** Eventos (props `on*`) que emite. */
10
+ events: string[]
11
+ /** Capabilities que consulta con `useCan` (formato `<modelo>.<acción>`). */
12
+ permissions: string[]
13
+ /** Módulos donde el benchmark dice que se usa. */
14
+ usedBy: string[]
15
+ /** Utilidades puras exportadas junto al componente. */
16
+ helpers: string[]
17
+ }
18
+
19
+ export const BUSINESS_COMPONENTS: readonly BusinessComponentSpec[] = [
20
+ {
21
+ name: 'CustomerPicker',
22
+ value: 'value: CustomerResult | null',
23
+ events: ['onChange'],
24
+ permissions: ['<model>.create (alta rápida; default Customer → customer.create)'],
25
+ usedBy: ['POS', 'Cotización', 'Pedido', 'Apartado', 'Factura', 'OT', 'RMA', 'Cobranza'],
26
+ helpers: [],
27
+ },
28
+ {
29
+ name: 'ProductPicker',
30
+ value: '(sin estado: search + onSelect)',
31
+ events: ['onSelect'],
32
+ permissions: [],
33
+ usedBy: ['POS', 'Cotización', 'Pedido', 'OC', 'Recepción', 'Traspaso', 'Ajuste', 'OT'],
34
+ helpers: ['parseProductQuery', 'parseTireSize', 'availableStock', 'productToLine'],
35
+ },
36
+ {
37
+ name: 'LineItemsEditor',
38
+ value: 'value: LineItem[]',
39
+ events: ['onChange', 'onValidate', 'onRequestProduct'],
40
+ permissions: ['editPermission (prop, opcional)'],
41
+ usedBy: ['Cotización', 'Pedido', 'Factura', 'OC', 'Recepción', 'OT', 'RMA', 'NC'],
42
+ helpers: ['makeLine', 'computeLine', 'computeTotals', 'validateLineItems', 'serializeLineItems', 'parseLineItems'],
43
+ },
44
+ {
45
+ name: 'PaymentCapture',
46
+ value: 'value: PaymentTender[]',
47
+ events: ['onChange', 'onSubmit'],
48
+ permissions: ['submitPermission (prop, opcional)'],
49
+ usedBy: ['POS/Caja', 'Apartado', 'Cobranza', 'Factura (Registrar pago)', 'OT'],
50
+ helpers: ['newTender', 'summarizePayment', 'validatePayment', 'serializePayment'],
51
+ },
52
+ {
53
+ name: 'RefundDestination',
54
+ value: 'value: RefundAllocation[]',
55
+ events: ['onChange', 'onValidate'],
56
+ permissions: [],
57
+ usedBy: ['ReturnWizard', 'Cancelación de apartado', 'Anticipos'],
58
+ helpers: ['validateRefund', 'singleDestination', 'DEFAULT_REFUND_DESTINATIONS'],
59
+ },
60
+ ] as const
@@ -0,0 +1,201 @@
1
+ // CustomerPicker + alta rápida — busca por nombre, RFC/ID fiscal, teléfono, placa
2
+ // o VIN; muestra saldo, crédito y alertas (benchmark §7). El alta rápida reutiliza
3
+ // el <CreateRecordDialog> dinámico del modelo (formulario desde metadata).
4
+ import { useCallback, useState } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { Plus, Search, X } from 'lucide-react'
7
+ import { Badge, Button, Input } from '@asteby/metacore-ui'
8
+ import { CreateRecordDialog } from '../dialogs/create-record-dialog'
9
+ import { useApi } from '../api-context'
10
+ import { useCan } from '../permissions-context'
11
+ import { useFormatter } from './format'
12
+ import { useAsyncSearch } from './use-async-search'
13
+ import { EmptyState } from './feedback'
14
+
15
+ export interface CustomerResult {
16
+ id: string
17
+ name: string
18
+ /** Identificador fiscal (RFC u homólogo): dato de la org, sin formato hardcodeado. */
19
+ tax_id?: string
20
+ phone?: string
21
+ email?: string
22
+ plate?: string
23
+ vin?: string
24
+ /** Saldo por cobrar (adeudo). */
25
+ balance?: number
26
+ credit_limit?: number
27
+ /** Alertas visibles: «Cliente con adeudo vencido», «Lista negra». */
28
+ alerts?: string[]
29
+ }
30
+
31
+ export interface CustomerPickerProps {
32
+ value: CustomerResult | null
33
+ /** Evento único de cambio: cliente elegido/creado, o `null` al limpiar. */
34
+ onChange: (customer: CustomerResult | null) => void
35
+ /** Búsqueda (nombre, ID fiscal, teléfono, placa, VIN). Estable (useCallback). */
36
+ search: (q: string, signal: AbortSignal) => Promise<CustomerResult[]>
37
+ /** Modelo del kernel para el alta rápida. Default `Customer`. */
38
+ model?: string
39
+ /** Endpoint CRUD del alta. Default `/data/<model>/me`. */
40
+ endpoint?: string
41
+ /** Convierte el registro guardado a `CustomerResult`. Default: id + name. */
42
+ mapRecord?: (rec: Record<string, unknown>) => CustomerResult
43
+ /** Valida el ID fiscal según la config de la org; devuelve el mensaje o undefined. */
44
+ validateTaxId?: (taxId: string) => string | undefined
45
+ /** Overrides de permiso (default `<model>.create` con useCan). */
46
+ canCreate?: boolean
47
+ /** Valores iniciales del alta rápida (p. ej. el texto buscado como nombre). */
48
+ createDefaults?: Record<string, unknown>
49
+ currency?: string
50
+ disabled?: boolean
51
+ placeholder?: string
52
+ }
53
+
54
+ function snake(model: string): string {
55
+ return model.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
56
+ }
57
+
58
+ export function CustomerPicker({
59
+ value,
60
+ onChange,
61
+ search,
62
+ model = 'Customer',
63
+ endpoint,
64
+ mapRecord,
65
+ validateTaxId,
66
+ canCreate,
67
+ createDefaults,
68
+ currency,
69
+ disabled,
70
+ placeholder,
71
+ }: CustomerPickerProps) {
72
+ const { t } = useTranslation()
73
+ const api = useApi()
74
+ const can = useCan()
75
+ const fmt = useFormatter({ currency })
76
+ const [text, setText] = useState('')
77
+ const [creating, setCreating] = useState(false)
78
+ const { results, loading, error } = useAsyncSearch(text, search, { minChars: 2 })
79
+ const mayCreate = canCreate ?? can(`${snake(model)}.create`)
80
+ const base = endpoint ?? `/data/${model}/me`
81
+ const taxIdError = value?.tax_id && validateTaxId ? validateTaxId(value.tax_id) : undefined
82
+
83
+ const pick = (c: CustomerResult) => {
84
+ onChange(c)
85
+ setText('')
86
+ }
87
+ const toResult = useCallback(
88
+ (rec: Record<string, unknown>): CustomerResult =>
89
+ mapRecord
90
+ ? mapRecord(rec)
91
+ : {
92
+ id: String(rec.id ?? ''),
93
+ name: String(rec.name ?? ''),
94
+ tax_id: typeof rec.tax_id === 'string' ? rec.tax_id : undefined,
95
+ phone: typeof rec.phone === 'string' ? rec.phone : undefined,
96
+ },
97
+ [mapRecord],
98
+ )
99
+
100
+ if (value) {
101
+ const overLimit = value.credit_limit != null && (value.balance ?? 0) > value.credit_limit
102
+ return (
103
+ <div data-slot="customer-picker" className="space-y-1 rounded-md border p-2">
104
+ <div className="flex items-start justify-between gap-2">
105
+ <div>
106
+ <p className="text-sm font-medium">{value.name}</p>
107
+ <p className="text-xs text-muted-foreground">
108
+ {[value.tax_id, value.phone, value.plate, value.vin].filter(Boolean).join(' · ')}
109
+ </p>
110
+ </div>
111
+ {!disabled && (
112
+ <Button type="button" size="icon" variant="ghost" onClick={() => onChange(null)} aria-label={t('customerPicker.clear', { defaultValue: 'Quitar cliente' })}>
113
+ <X className="size-4" />
114
+ </Button>
115
+ )}
116
+ </div>
117
+ <div className="flex flex-wrap gap-1.5">
118
+ {value.balance != null && value.balance !== 0 && (
119
+ <Badge variant={overLimit ? 'danger' : 'warning'}>
120
+ {t('customerPicker.balance', { defaultValue: 'Adeudo' })} {fmt.money(value.balance)}
121
+ </Badge>
122
+ )}
123
+ {value.credit_limit != null && (
124
+ <Badge variant="muted">
125
+ {t('customerPicker.credit', { defaultValue: 'Crédito' })} {fmt.money(value.credit_limit)}
126
+ </Badge>
127
+ )}
128
+ {value.alerts?.map((a) => (
129
+ <Badge key={a} variant="danger">{a}</Badge>
130
+ ))}
131
+ </div>
132
+ {taxIdError && <p role="alert" className="text-xs text-destructive">{taxIdError}</p>}
133
+ </div>
134
+ )
135
+ }
136
+
137
+ return (
138
+ <div data-slot="customer-picker" className="space-y-2">
139
+ <div className="flex items-center gap-1.5">
140
+ <div className="relative flex-1">
141
+ <Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
142
+ <Input
143
+ className="pl-8"
144
+ disabled={disabled}
145
+ value={text}
146
+ placeholder={placeholder ?? t('customerPicker.placeholder', { defaultValue: 'Nombre, RFC, teléfono, placa o VIN' })}
147
+ onChange={(e) => setText(e.target.value)}
148
+ />
149
+ </div>
150
+ {mayCreate && (
151
+ <Button type="button" variant="outline" size="icon" disabled={disabled} onClick={() => setCreating(true)} aria-label={t('customerPicker.create', { defaultValue: 'Nuevo cliente' })}>
152
+ <Plus className="size-4" />
153
+ </Button>
154
+ )}
155
+ </div>
156
+ {loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
157
+ {error && !loading && (
158
+ <p role="alert" className="text-sm text-destructive">
159
+ {t('customerPicker.error', { defaultValue: 'No se pudo buscar clientes. Inténtalo de nuevo.' })}
160
+ </p>
161
+ )}
162
+ {!loading && !error && text.trim().length >= 2 && results.length === 0 && (
163
+ <EmptyState
164
+ title={t('customerPicker.empty', { defaultValue: 'No encontramos a ese cliente' })}
165
+ action={mayCreate ? { label: t('customerPicker.createAction', { defaultValue: 'Crear cliente' }), onClick: () => setCreating(true) } : undefined}
166
+ />
167
+ )}
168
+ {results.length > 0 && (
169
+ <ul role="listbox" className="max-h-64 divide-y overflow-auto rounded-md border">
170
+ {results.map((c) => (
171
+ <li key={c.id} role="option" aria-selected={false}>
172
+ <button type="button" className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-accent" onClick={() => pick(c)}>
173
+ <span>
174
+ <span className="block text-sm font-medium">{c.name}</span>
175
+ <span className="block text-xs text-muted-foreground">{[c.tax_id, c.phone, c.plate, c.vin].filter(Boolean).join(' · ')}</span>
176
+ </span>
177
+ {c.balance != null && c.balance > 0 && <Badge variant="warning">{fmt.money(c.balance)}</Badge>}
178
+ </button>
179
+ </li>
180
+ ))}
181
+ </ul>
182
+ )}
183
+
184
+ {creating && (
185
+ <CreateRecordDialog
186
+ modelKey={model}
187
+ open={creating}
188
+ onOpenChange={setCreating}
189
+ endpoint={base}
190
+ defaults={createDefaults ?? (text.trim() ? { name: text.trim() } : undefined)}
191
+ onCreate={async (data) => {
192
+ const res = await api.post(base, data)
193
+ const rec = (res.data?.data ?? res.data) as Record<string, unknown>
194
+ pick(toResult(rec))
195
+ return rec.id != null ? { id: String(rec.id) } : undefined
196
+ }}
197
+ />
198
+ )}
199
+ </div>
200
+ )
201
+ }