@asteby/metacore-runtime-react 45.0.0 → 45.3.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 +29 -0
- package/dist/action-modal-dispatcher.d.ts +29 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +115 -6
- package/dist/activity-diff.d.ts +2 -0
- package/dist/activity-diff.d.ts.map +1 -1
- package/dist/activity-diff.js +2 -2
- package/dist/approval-events.d.ts +16 -0
- package/dist/approval-events.d.ts.map +1 -0
- package/dist/approval-events.js +27 -0
- package/dist/approval-gate.d.ts +39 -0
- package/dist/approval-gate.d.ts.map +1 -0
- package/dist/approval-gate.js +110 -0
- package/dist/approval-pin-dialog.d.ts +25 -0
- package/dist/approval-pin-dialog.d.ts.map +1 -0
- package/dist/approval-pin-dialog.js +66 -0
- package/dist/business/catalog.d.ts +16 -0
- package/dist/business/catalog.d.ts.map +1 -0
- package/dist/business/catalog.js +50 -0
- package/dist/business/customer-picker.d.ts +39 -0
- package/dist/business/customer-picker.d.ts.map +1 -0
- package/dist/business/customer-picker.js +51 -0
- package/dist/business/feedback.d.ts +72 -0
- package/dist/business/feedback.d.ts.map +1 -0
- package/dist/business/feedback.js +52 -0
- package/dist/business/field-errors.d.ts +26 -0
- package/dist/business/field-errors.d.ts.map +1 -0
- package/dist/business/field-errors.js +59 -0
- package/dist/business/format.d.ts +49 -0
- package/dist/business/format.d.ts.map +1 -0
- package/dist/business/format.js +128 -0
- package/dist/business/index.d.ts +16 -0
- package/dist/business/index.d.ts.map +1 -0
- package/dist/business/index.js +15 -0
- package/dist/business/line-items-editor.d.ts +25 -0
- package/dist/business/line-items-editor.d.ts.map +1 -0
- package/dist/business/line-items-editor.js +36 -0
- package/dist/business/line-items.d.ts +69 -0
- package/dist/business/line-items.d.ts.map +1 -0
- package/dist/business/line-items.js +133 -0
- package/dist/business/payment-capture.d.ts +21 -0
- package/dist/business/payment-capture.d.ts.map +1 -0
- package/dist/business/payment-capture.js +24 -0
- package/dist/business/payment.d.ts +61 -0
- package/dist/business/payment.d.ts.map +1 -0
- package/dist/business/payment.js +74 -0
- package/dist/business/product-picker.d.ts +24 -0
- package/dist/business/product-picker.d.ts.map +1 -0
- package/dist/business/product-picker.js +48 -0
- package/dist/business/product-search.d.ts +70 -0
- package/dist/business/product-search.d.ts.map +1 -0
- package/dist/business/product-search.js +51 -0
- package/dist/business/refund-destination.d.ts +19 -0
- package/dist/business/refund-destination.d.ts.map +1 -0
- package/dist/business/refund-destination.js +31 -0
- package/dist/business/refund.d.ts +29 -0
- package/dist/business/refund.d.ts.map +1 -0
- package/dist/business/refund.js +44 -0
- package/dist/business/return-wizard.d.ts +23 -0
- package/dist/business/return-wizard.d.ts.map +1 -0
- package/dist/business/return-wizard.js +59 -0
- package/dist/business/return.d.ts +90 -0
- package/dist/business/return.d.ts.map +1 -0
- package/dist/business/return.js +112 -0
- package/dist/business/use-async-search.d.ts +10 -0
- package/dist/business/use-async-search.d.ts.map +1 -0
- package/dist/business/use-async-search.js +38 -0
- package/dist/document/action-bar.d.ts +12 -0
- package/dist/document/action-bar.d.ts.map +1 -0
- package/dist/document/action-bar.js +39 -0
- package/dist/document/cancel-with-reason.d.ts +47 -0
- package/dist/document/cancel-with-reason.d.ts.map +1 -0
- package/dist/document/cancel-with-reason.js +52 -0
- package/dist/document/document-header.d.ts +41 -0
- package/dist/document/document-header.d.ts.map +1 -0
- package/dist/document/document-header.js +30 -0
- package/dist/document/document-page.d.ts +37 -0
- package/dist/document/document-page.d.ts.map +1 -0
- package/dist/document/document-page.js +137 -0
- package/dist/document/document-timeline.d.ts +20 -0
- package/dist/document/document-timeline.d.ts.map +1 -0
- package/dist/document/document-timeline.js +26 -0
- package/dist/document/evaluate.d.ts +31 -0
- package/dist/document/evaluate.d.ts.map +1 -0
- package/dist/document/evaluate.js +170 -0
- package/dist/document/format.d.ts +9 -0
- package/dist/document/format.d.ts.map +1 -0
- package/dist/document/format.js +52 -0
- package/dist/document/index.d.ts +13 -0
- package/dist/document/index.d.ts.map +1 -0
- package/dist/document/index.js +12 -0
- package/dist/document/registry.d.ts +20 -0
- package/dist/document/registry.d.ts.map +1 -0
- package/dist/document/registry.js +20 -0
- package/dist/document/smart-buttons.d.ts +16 -0
- package/dist/document/smart-buttons.d.ts.map +1 -0
- package/dist/document/smart-buttons.js +16 -0
- package/dist/document/status-badge.d.ts +16 -0
- package/dist/document/status-badge.d.ts.map +1 -0
- package/dist/document/status-badge.js +23 -0
- package/dist/document/types.d.ts +196 -0
- package/dist/document/types.d.ts.map +1 -0
- package/dist/document/types.js +1 -0
- package/dist/document/use-document-sources.d.ts +14 -0
- package/dist/document/use-document-sources.d.ts.map +1 -0
- package/dist/document/use-document-sources.js +72 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +3 -1
- package/dist/dynamic-relation.d.ts +4 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +10 -1
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +14 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +28 -3
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/reason-prompt.d.ts +59 -0
- package/dist/reason-prompt.d.ts.map +1 -0
- package/dist/reason-prompt.js +111 -0
- package/dist/server-error.d.ts.map +1 -1
- package/dist/server-error.js +19 -0
- package/dist/use-addon-settings.d.ts +17 -3
- package/dist/use-addon-settings.d.ts.map +1 -1
- package/dist/use-addon-settings.js +20 -8
- package/package.json +1 -1
- package/src/__tests__/approval-gate.test.tsx +134 -0
- package/src/__tests__/prefill-from-record.test.ts +69 -0
- package/src/__tests__/reason-prompt.test.tsx +121 -0
- package/src/__tests__/use-addon-settings.test.ts +16 -1
- package/src/action-modal-dispatcher.tsx +145 -3
- package/src/activity-diff.tsx +10 -1
- package/src/approval-events.ts +39 -0
- package/src/approval-gate.tsx +180 -0
- package/src/approval-pin-dialog.tsx +162 -0
- package/src/business/business.test.ts +210 -0
- package/src/business/catalog.ts +68 -0
- package/src/business/customer-picker.tsx +201 -0
- package/src/business/feedback.tsx +141 -0
- package/src/business/field-errors.ts +92 -0
- package/src/business/format.ts +151 -0
- package/src/business/index.ts +15 -0
- package/src/business/line-items-editor.tsx +259 -0
- package/src/business/line-items.ts +190 -0
- package/src/business/payment-capture.tsx +158 -0
- package/src/business/payment.ts +125 -0
- package/src/business/product-picker.tsx +171 -0
- package/src/business/product-search.ts +108 -0
- package/src/business/refund-destination.tsx +121 -0
- package/src/business/refund.ts +72 -0
- package/src/business/return-wizard.tsx +223 -0
- package/src/business/return.ts +181 -0
- package/src/business/use-async-search.ts +43 -0
- package/src/document/__tests__/document-page.test.tsx +107 -0
- package/src/document/__tests__/evaluate.test.ts +140 -0
- package/src/document/action-bar.tsx +140 -0
- package/src/document/cancel-with-reason.tsx +214 -0
- package/src/document/document-header.tsx +140 -0
- package/src/document/document-page.tsx +268 -0
- package/src/document/document-timeline.tsx +66 -0
- package/src/document/evaluate.ts +180 -0
- package/src/document/format.ts +59 -0
- package/src/document/index.ts +28 -0
- package/src/document/registry.ts +39 -0
- package/src/document/smart-buttons.tsx +54 -0
- package/src/document/status-badge.tsx +44 -0
- package/src/document/types.ts +203 -0
- package/src/document/use-document-sources.ts +91 -0
- package/src/dynamic-columns.tsx +5 -1
- package/src/dynamic-relation.tsx +14 -1
- package/src/dynamic-row-actions.tsx +13 -1
- package/src/dynamic-table.tsx +22 -2
- package/src/index.ts +33 -0
- package/src/reason-prompt.tsx +247 -0
- package/src/server-error.ts +20 -0
- package/src/use-addon-settings.ts +26 -7
|
@@ -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,210 @@
|
|
|
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
|
+
})
|
|
165
|
+
|
|
166
|
+
import { canTransitionRma, computeReturnTotals, creditNoteRelation, returnSteps, serializeReturn, validateReturnChoices, type ReturnableLine } from './return'
|
|
167
|
+
|
|
168
|
+
describe('return', () => {
|
|
169
|
+
const lines: ReturnableLine[] = [
|
|
170
|
+
{ key: 'a', product_id: 'p1', description: 'Llanta', sold: 4, alreadyReturned: 1, unit_price: 1000, tax_rate: 0.16 },
|
|
171
|
+
{ key: 'b', description: 'Servicio', sold: 1, unit_price: 200 },
|
|
172
|
+
]
|
|
173
|
+
const ch = (q: number) => ({ a: { quantity: q, condition: 'sellable' as const, destination: 'stock' as const } })
|
|
174
|
+
it('tope por devolver = vendido − ya devuelto', () => {
|
|
175
|
+
expect(validateReturnChoices(lines, ch(4), 'x').errors['a.quantity']).toBeTruthy()
|
|
176
|
+
expect(validateReturnChoices(lines, ch(3), 'x').valid).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
it('exige renglón y motivo; destino coherente con la condición', () => {
|
|
179
|
+
expect(validateReturnChoices(lines, {}, 'x').valid).toBe(false)
|
|
180
|
+
expect(validateReturnChoices(lines, ch(1), '').valid).toBe(false)
|
|
181
|
+
const bad = { a: { quantity: 1, condition: 'sellable' as const, destination: 'scrap' as const } }
|
|
182
|
+
expect(validateReturnChoices(lines, bad, 'x').errors['a.destination']).toBeTruthy()
|
|
183
|
+
})
|
|
184
|
+
it('totales con IVA y relación 03/01', () => {
|
|
185
|
+
expect(computeReturnTotals(lines, ch(2)).total).toBe(2320)
|
|
186
|
+
expect(creditNoteRelation(lines, ch(2))).toBe('03')
|
|
187
|
+
expect(creditNoteRelation(lines, { b: { quantity: 1, condition: 'sellable', destination: 'stock' } })).toBe('01')
|
|
188
|
+
})
|
|
189
|
+
it('sin almacén, Recibir es un paso; con almacén no', () => {
|
|
190
|
+
expect(returnSteps({ warehouseConnected: false })).toContain('receive')
|
|
191
|
+
expect(returnSteps({ warehouseConnected: true })).not.toContain('receive')
|
|
192
|
+
})
|
|
193
|
+
it('serializeReturn omite renglones en 0 y añade NC solo si hay reembolso a NC', () => {
|
|
194
|
+
const p = serializeReturn({ lines, choices: ch(2), reason: ' defecto ', received: true, refund: [{ kind: 'credit_note', amount: 2320 }] })
|
|
195
|
+
expect(p.lines).toHaveLength(1)
|
|
196
|
+
expect(p.reason).toBe('defecto')
|
|
197
|
+
expect(p.credit_note).toEqual({ relation: '03' })
|
|
198
|
+
expect(serializeReturn({ lines, choices: ch(2), reason: 'x', received: true, refund: [{ kind: 'cash', amount: 2320 }] }).credit_note).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('rma states', () => {
|
|
203
|
+
it('flujo feliz y motivo obligatorio al rechazar/cancelar', () => {
|
|
204
|
+
expect(canTransitionRma('draft', 'authorized').ok).toBe(true)
|
|
205
|
+
expect(canTransitionRma('received', 'settled').ok).toBe(true)
|
|
206
|
+
expect(canTransitionRma('authorized', 'rejected').ok).toBe(false)
|
|
207
|
+
expect(canTransitionRma('authorized', 'rejected', 'no aplica').ok).toBe(true)
|
|
208
|
+
expect(canTransitionRma('closed', 'draft').ok).toBe(false)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
{
|
|
61
|
+
name: 'ReturnWizard',
|
|
62
|
+
value: 'lines: ReturnableLine[]',
|
|
63
|
+
events: ['onSubmit', 'onCancel'],
|
|
64
|
+
permissions: [],
|
|
65
|
+
usedBy: ['POS', 'Pedido', 'Factura', 'RMA', 'OT'],
|
|
66
|
+
helpers: ['computeReturnTotals', 'validateReturnChoices', 'creditNoteRelation', 'returnSteps', 'serializeReturn'],
|
|
67
|
+
},
|
|
68
|
+
] 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
|
+
}
|