@asteby/metacore-runtime-react 45.2.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/action-modal-dispatcher.d.ts +29 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +115 -6
  5. package/dist/activity-diff.d.ts +2 -0
  6. package/dist/activity-diff.d.ts.map +1 -1
  7. package/dist/activity-diff.js +2 -2
  8. package/dist/business/catalog.d.ts.map +1 -1
  9. package/dist/business/catalog.js +8 -0
  10. package/dist/business/index.d.ts +2 -0
  11. package/dist/business/index.d.ts.map +1 -1
  12. package/dist/business/index.js +2 -0
  13. package/dist/business/refund-destination.js +1 -1
  14. package/dist/business/refund.d.ts +1 -1
  15. package/dist/business/refund.d.ts.map +1 -1
  16. package/dist/business/refund.js +1 -0
  17. package/dist/business/return-wizard.d.ts +23 -0
  18. package/dist/business/return-wizard.d.ts.map +1 -0
  19. package/dist/business/return-wizard.js +59 -0
  20. package/dist/business/return.d.ts +90 -0
  21. package/dist/business/return.d.ts.map +1 -0
  22. package/dist/business/return.js +112 -0
  23. package/dist/dynamic-columns.d.ts.map +1 -1
  24. package/dist/dynamic-columns.js +3 -1
  25. package/dist/dynamic-relation.d.ts +4 -0
  26. package/dist/dynamic-relation.d.ts.map +1 -1
  27. package/dist/dynamic-relation.js +10 -1
  28. package/dist/dynamic-row-actions.d.ts.map +1 -1
  29. package/dist/dynamic-row-actions.js +14 -2
  30. package/dist/dynamic-table.d.ts.map +1 -1
  31. package/dist/dynamic-table.js +28 -3
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/reason-prompt.d.ts +59 -0
  36. package/dist/reason-prompt.d.ts.map +1 -0
  37. package/dist/reason-prompt.js +111 -0
  38. package/dist/use-addon-settings.d.ts +17 -3
  39. package/dist/use-addon-settings.d.ts.map +1 -1
  40. package/dist/use-addon-settings.js +20 -8
  41. package/package.json +1 -1
  42. package/src/__tests__/prefill-from-record.test.ts +69 -0
  43. package/src/__tests__/reason-prompt.test.tsx +121 -0
  44. package/src/__tests__/use-addon-settings.test.ts +16 -1
  45. package/src/action-modal-dispatcher.tsx +145 -3
  46. package/src/activity-diff.tsx +10 -1
  47. package/src/business/business.test.ts +46 -0
  48. package/src/business/catalog.ts +8 -0
  49. package/src/business/index.ts +2 -0
  50. package/src/business/refund-destination.tsx +2 -2
  51. package/src/business/refund.ts +2 -1
  52. package/src/business/return-wizard.tsx +223 -0
  53. package/src/business/return.ts +181 -0
  54. package/src/dynamic-columns.tsx +5 -1
  55. package/src/dynamic-relation.tsx +14 -1
  56. package/src/dynamic-row-actions.tsx +13 -1
  57. package/src/dynamic-table.tsx +22 -2
  58. package/src/index.ts +10 -0
  59. package/src/reason-prompt.tsx +247 -0
  60. package/src/use-addon-settings.ts +26 -7
@@ -11,3 +11,5 @@ export * from './product-search'
11
11
  export * from './product-picker'
12
12
  export * from './customer-picker'
13
13
  export * from './catalog'
14
+ export * from './return'
15
+ export * from './return-wizard'
@@ -103,9 +103,9 @@ export function RefundDestination({
103
103
  />
104
104
  {errors[`${i}.amount`] && <p className="mt-0.5 text-xs text-destructive">{errors[`${i}.amount`]}</p>}
105
105
  </div>
106
- {(a.kind === 'card' || a.kind === 'credit_note') && (
106
+ {(a.kind === 'card' || a.kind === 'transfer' || a.kind === 'credit_note') && (
107
107
  <Input
108
- placeholder={a.kind === 'card' ? t('refund.cardRef', { defaultValue: 'Referencia' }) : t('refund.ncRef', { defaultValue: 'Folio / UUID relacionado' })}
108
+ placeholder={a.kind === 'card' || a.kind === 'transfer' ? t('refund.cardRef', { defaultValue: 'Referencia' }) : t('refund.ncRef', { defaultValue: 'Folio / UUID relacionado' })}
109
109
  value={a.reference ?? ''}
110
110
  disabled={disabled}
111
111
  onChange={(e) => emit(value.map((x, j) => (j === i ? { ...x, reference: e.target.value } : x)))}
@@ -2,7 +2,7 @@
2
2
  // Lógica pura de <RefundDestination>.
3
3
  import { roundMoney, toAmount } from './format'
4
4
 
5
- export type RefundDestinationKind = 'cash' | 'card' | 'wallet' | 'store_credit' | 'credit_note'
5
+ export type RefundDestinationKind = 'cash' | 'card' | 'transfer' | 'wallet' | 'store_credit' | 'credit_note'
6
6
 
7
7
  export interface RefundDestinationOption {
8
8
  kind: RefundDestinationKind
@@ -25,6 +25,7 @@ export interface RefundAllocation {
25
25
  export const DEFAULT_REFUND_DESTINATIONS: RefundDestinationOption[] = [
26
26
  { kind: 'cash', label: 'Efectivo' },
27
27
  { kind: 'card', label: 'Tarjeta' },
28
+ { kind: 'transfer', label: 'Transferencia' },
28
29
  { kind: 'wallet', label: 'Monedero' },
29
30
  { kind: 'store_credit', label: 'Saldo a favor' },
30
31
  { kind: 'credit_note', label: 'Nota de crédito (CFDI)' },
@@ -0,0 +1,223 @@
1
+ // ReturnWizard («Devolver mercancía») — un solo componente para POS, Pedido,
2
+ // Factura, RMA y OT (benchmark §7, DEV-2). No conoce endpoints: recibe los
3
+ // renglones devolvibles y entrega un payload por `onSubmit`.
4
+ import { useMemo, useState } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { Button, Input } from '@asteby/metacore-ui'
7
+ import { useFormatter } from './format'
8
+ import { RefundDestination } from './refund-destination'
9
+ import {
10
+ DEFAULT_REFUND_DESTINATIONS,
11
+ type RefundAllocation,
12
+ type RefundDestinationOption,
13
+ } from './refund'
14
+ import {
15
+ RETURN_CONDITION_DESTINATIONS,
16
+ computeReturnTotals,
17
+ creditNoteRelation,
18
+ defaultStockDestination,
19
+ returnSteps,
20
+ returnableQty,
21
+ serializeReturn,
22
+ validateReturnChoices,
23
+ validateReturnRefund,
24
+ type ReturnChoices,
25
+ type ReturnCondition,
26
+ type ReturnStep,
27
+ type ReturnStockDestination,
28
+ type ReturnSubmitPayload,
29
+ type ReturnableLine,
30
+ } from './return'
31
+
32
+ export interface ReturnWizardProps {
33
+ lines: ReturnableLine[]
34
+ /** ¿Hay un addon de almacén que confirma la recepción? Sin él, «Recibir» es un paso. */
35
+ warehouseConnected?: boolean
36
+ /** La venta está facturada: habilita la NC CFDI con relación 01/03 automática. */
37
+ invoiced?: boolean
38
+ /** Destinos de reembolso; `credit_note` se deshabilita solo si no está facturada. */
39
+ refundOptions?: RefundDestinationOption[]
40
+ reasons?: { value: string; label: string }[]
41
+ currency?: string
42
+ submitting?: boolean
43
+ /** Errores del servidor (`"<key>.<campo>"`). */
44
+ serverErrors?: Record<string, string>
45
+ onSubmit: (payload: ReturnSubmitPayload) => void | Promise<void>
46
+ onCancel?: () => void
47
+ }
48
+
49
+ const DEFAULT_REASONS = [
50
+ { value: 'not_wanted', label: 'No lo quiso' },
51
+ { value: 'defective', label: 'Defectuoso' },
52
+ { value: 'wrong_item', label: 'Producto equivocado' },
53
+ { value: 'warranty', label: 'Garantía' },
54
+ ]
55
+
56
+ const STOCK_LABEL: Record<ReturnStockDestination, string> = { stock: 'Regresa a stock', scrap: 'Merma', warranty: 'Garantía con proveedor' }
57
+ const STEP_LABEL: Record<ReturnStep, string> = { lines: 'Renglones', condition: 'Condición', receive: 'Recibir', refund: 'Reembolso' }
58
+
59
+ export function ReturnWizard({
60
+ lines,
61
+ warehouseConnected = false,
62
+ invoiced = false,
63
+ refundOptions = DEFAULT_REFUND_DESTINATIONS,
64
+ reasons = DEFAULT_REASONS,
65
+ currency,
66
+ submitting,
67
+ serverErrors,
68
+ onSubmit,
69
+ onCancel,
70
+ }: ReturnWizardProps) {
71
+ const { t } = useTranslation()
72
+ const fmt = useFormatter({ currency })
73
+ const steps = useMemo(() => returnSteps({ warehouseConnected }), [warehouseConnected])
74
+ const [stepIdx, setStepIdx] = useState(0)
75
+ const [choices, setChoices] = useState<ReturnChoices>({})
76
+ const [reason, setReason] = useState('')
77
+ const [notes, setNotes] = useState('')
78
+ const [received, setReceived] = useState(false)
79
+ const [refund, setRefund] = useState<RefundAllocation[]>([])
80
+
81
+ const step = steps[stepIdx]
82
+ const totals = useMemo(() => computeReturnTotals(lines, choices), [lines, choices])
83
+ const validation = useMemo(() => validateReturnChoices(lines, choices, reason), [lines, choices, reason])
84
+ const relation = useMemo(() => creditNoteRelation(lines, choices), [lines, choices])
85
+ const options = useMemo(
86
+ () =>
87
+ refundOptions.map((o) =>
88
+ o.kind === 'credit_note' && !invoiced
89
+ ? { ...o, available: false, disabledReason: 'La venta no está facturada' }
90
+ : o.kind === 'credit_note'
91
+ ? { ...o, label: `${o.label} · relación ${relation}` }
92
+ : o,
93
+ ),
94
+ [refundOptions, invoiced, relation],
95
+ )
96
+ const refundValidation = useMemo(() => validateReturnRefund(totals.total, refund, options), [totals.total, refund, options])
97
+ const errors = { ...validation.errors, ...serverErrors }
98
+
99
+ const setChoice = (key: string, patch: Partial<ReturnChoices[string]>) =>
100
+ setChoices((prev) => {
101
+ const cur = prev[key] ?? { quantity: 0, condition: 'sellable' as ReturnCondition, destination: 'stock' as ReturnStockDestination }
102
+ const next = { ...cur, ...patch }
103
+ if (patch.condition && !patch.destination) next.destination = defaultStockDestination(patch.condition)
104
+ return { ...prev, [key]: next }
105
+ })
106
+
107
+ const canNext =
108
+ step === 'lines' ? validation.valid
109
+ : step === 'condition' ? validation.valid
110
+ : step === 'receive' ? received
111
+ : refundValidation.valid
112
+
113
+ const submit = () =>
114
+ onSubmit(serializeReturn({ lines, choices, reason, notes, received: warehouseConnected ? false : received, refund }))
115
+
116
+ return (
117
+ <div data-slot="return-wizard" className="space-y-4">
118
+ <ol className="flex flex-wrap gap-2 text-xs">
119
+ {steps.map((s, i) => (
120
+ <li key={s} aria-current={i === stepIdx ? 'step' : undefined} className={i === stepIdx ? 'font-semibold text-foreground' : 'text-muted-foreground'}>
121
+ {i + 1}. {t(`returns.step.${s}`, { defaultValue: STEP_LABEL[s] })}
122
+ </li>
123
+ ))}
124
+ {warehouseConnected && (
125
+ <li className="text-muted-foreground">{t('returns.step.warehouse', { defaultValue: 'Almacén confirma la recepción' })}</li>
126
+ )}
127
+ </ol>
128
+
129
+ {(step === 'lines' || step === 'condition') && (
130
+ <div className="space-y-2">
131
+ {lines.map((l) => {
132
+ const c = choices[l.key]
133
+ const max = returnableQty(l)
134
+ return (
135
+ <div key={l.key} className="grid gap-2 rounded-md border p-2 sm:grid-cols-[1fr_6rem_auto]">
136
+ <div>
137
+ <p className="text-sm font-medium">{l.description}</p>
138
+ <p className="text-xs text-muted-foreground">
139
+ {t('returns.sold', { defaultValue: 'Vendido' })}: {l.sold} · {t('returns.returnable', { defaultValue: 'Por devolver' })}: {max} · {fmt.money(l.unit_price)}
140
+ </p>
141
+ </div>
142
+ {step === 'lines' ? (
143
+ <div>
144
+ <Input
145
+ inputMode="decimal"
146
+ className="text-right"
147
+ aria-label={l.description}
148
+ disabled={max === 0}
149
+ value={c ? String(c.quantity) : ''}
150
+ placeholder="0"
151
+ onChange={(e) => setChoice(l.key, { quantity: Number(e.target.value.replace(',', '.')) || 0 })}
152
+ />
153
+ {errors[`${l.key}.quantity`] && <p className="mt-0.5 text-xs text-destructive">{errors[`${l.key}.quantity`]}</p>}
154
+ </div>
155
+ ) : (
156
+ (c?.quantity ?? 0) > 0 && (
157
+ <div className="sm:col-span-2 flex flex-wrap items-center gap-2">
158
+ {(['sellable', 'defective'] as ReturnCondition[]).map((cond) => (
159
+ <Button key={cond} type="button" size="sm" variant={c!.condition === cond ? 'default' : 'outline'} onClick={() => setChoice(l.key, { condition: cond })}>
160
+ {cond === 'sellable' ? t('returns.sellable', { defaultValue: 'Vendible' }) : t('returns.defective', { defaultValue: 'Defectuoso' })}
161
+ </Button>
162
+ ))}
163
+ {RETURN_CONDITION_DESTINATIONS[c!.condition].length > 1 &&
164
+ RETURN_CONDITION_DESTINATIONS[c!.condition].map((d) => (
165
+ <Button key={d} type="button" size="sm" variant={c!.destination === d ? 'secondary' : 'ghost'} onClick={() => setChoice(l.key, { destination: d })}>
166
+ {STOCK_LABEL[d]}
167
+ </Button>
168
+ ))}
169
+ {RETURN_CONDITION_DESTINATIONS[c!.condition].length === 1 && (
170
+ <span className="text-xs text-muted-foreground">{STOCK_LABEL[c!.destination]}</span>
171
+ )}
172
+ {errors[`${l.key}.destination`] && <p className="w-full text-xs text-destructive">{errors[`${l.key}.destination`]}</p>}
173
+ </div>
174
+ )
175
+ )}
176
+ </div>
177
+ )
178
+ })}
179
+ {step === 'lines' && (
180
+ <div className="grid gap-2 sm:grid-cols-2">
181
+ <select className="h-9 rounded-md border bg-background px-2 text-sm" value={reason} onChange={(e) => setReason(e.target.value)} aria-label={t('returns.reason', { defaultValue: 'Motivo' })}>
182
+ <option value="">{t('returns.reason', { defaultValue: 'Motivo' })}…</option>
183
+ {reasons.map((r) => (
184
+ <option key={r.value} value={r.value}>{r.label}</option>
185
+ ))}
186
+ </select>
187
+ <Input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('returns.notes', { defaultValue: 'Notas (opcional)' })} />
188
+ </div>
189
+ )}
190
+ {validation.form && stepIdx === 0 && Object.keys(choices).length > 0 && <p role="alert" className="text-sm text-destructive">{validation.form}</p>}
191
+ </div>
192
+ )}
193
+
194
+ {step === 'receive' && (
195
+ <label className="flex items-start gap-2 rounded-md border p-3 text-sm">
196
+ <input type="checkbox" className="mt-1" checked={received} onChange={(e) => setReceived(e.target.checked)} />
197
+ <span>{t('returns.receiveHere', { defaultValue: 'Confirmo que la mercancía ya está en tienda. No hay almacén que la reciba, así que la recepción se registra aquí.' })}</span>
198
+ </label>
199
+ )}
200
+
201
+ {step === 'refund' && (
202
+ <RefundDestination total={totals.total} value={refund} onChange={setRefund} options={options} currency={currency} allowSplit serverErrors={serverErrors} />
203
+ )}
204
+
205
+ <div className="flex items-center justify-between gap-2 border-t pt-3">
206
+ <span className="text-sm">
207
+ {t('returns.total', { defaultValue: 'Total a devolver' })}: <strong>{fmt.money(totals.total)}</strong>
208
+ </span>
209
+ <div className="flex gap-2">
210
+ {onCancel && stepIdx === 0 && <Button type="button" variant="outline" onClick={onCancel}>{t('common.cancel', { defaultValue: 'Cancelar' })}</Button>}
211
+ {stepIdx > 0 && <Button type="button" variant="outline" onClick={() => setStepIdx(stepIdx - 1)}>{t('common.back', { defaultValue: 'Atrás' })}</Button>}
212
+ {stepIdx < steps.length - 1 ? (
213
+ <Button type="button" disabled={!canNext} onClick={() => setStepIdx(stepIdx + 1)}>{t('common.next', { defaultValue: 'Siguiente' })}</Button>
214
+ ) : (
215
+ <Button type="button" disabled={!canNext || submitting} onClick={submit}>
216
+ {t('returns.confirm', { defaultValue: 'Confirmar devolución' })} {fmt.money(totals.total)}
217
+ </Button>
218
+ )}
219
+ </div>
220
+ </div>
221
+ </div>
222
+ )
223
+ }
@@ -0,0 +1,181 @@
1
+ // Devolución de mercancía (ReturnWizard: POS, Pedido, Factura, RMA, OT, devolución
2
+ // a proveedor). Lógica pura, sin endpoints: cada consumidor cablea el envío.
3
+ // Flujo (benchmark §8.2): renglones → condición y destino → recepción → reembolso o NC.
4
+ import { roundMoney, toAmount } from './format'
5
+ import { validateRefund, type RefundAllocation, type RefundDestinationOption } from './refund'
6
+
7
+ /** Estado físico de lo que regresa. */
8
+ export type ReturnCondition = 'sellable' | 'defective'
9
+ /** A dónde va la mercancía: stock vendible, merma o garantía (reclamo al proveedor). */
10
+ export type ReturnStockDestination = 'stock' | 'scrap' | 'warranty'
11
+
12
+ export const RETURN_CONDITION_DESTINATIONS: Record<ReturnCondition, ReturnStockDestination[]> = {
13
+ sellable: ['stock'],
14
+ defective: ['scrap', 'warranty'],
15
+ }
16
+
17
+ /** Destino por defecto de una condición (vendible → stock, defectuoso → merma). */
18
+ export function defaultStockDestination(condition: ReturnCondition): ReturnStockDestination {
19
+ return RETURN_CONDITION_DESTINATIONS[condition][0]
20
+ }
21
+
22
+ /** Renglón devolvible: lo vendido menos lo ya devuelto. */
23
+ export interface ReturnableLine {
24
+ key: string
25
+ product_id?: string
26
+ description: string
27
+ sold: number
28
+ alreadyReturned?: number
29
+ /** Precio unitario neto de descuento tal como se vendió (congelado). */
30
+ unit_price: number
31
+ /** Fracción (0.16). */
32
+ tax_rate?: number
33
+ }
34
+
35
+ export interface ReturnLineChoice {
36
+ quantity: number
37
+ condition: ReturnCondition
38
+ destination: ReturnStockDestination
39
+ }
40
+
41
+ export type ReturnChoices = Record<string, ReturnLineChoice>
42
+
43
+ export function returnableQty(l: ReturnableLine): number {
44
+ return Math.max(0, roundMoney(l.sold - (l.alreadyReturned ?? 0), 4))
45
+ }
46
+
47
+ export interface ReturnTotals {
48
+ base: number
49
+ tax: number
50
+ total: number
51
+ goodsLines: number
52
+ }
53
+
54
+ export function computeReturnTotals(lines: ReturnableLine[], choices: ReturnChoices): ReturnTotals {
55
+ let base = 0
56
+ let tax = 0
57
+ let goodsLines = 0
58
+ for (const l of lines) {
59
+ const c = choices[l.key]
60
+ const q = c ? toAmount(c.quantity) : 0
61
+ if (q <= 0) continue
62
+ goodsLines += 1
63
+ const b = roundMoney(q * l.unit_price)
64
+ base += b
65
+ tax += roundMoney(b * (l.tax_rate ?? 0))
66
+ }
67
+ base = roundMoney(base)
68
+ tax = roundMoney(tax)
69
+ return { base, tax, total: roundMoney(base + tax), goodsLines }
70
+ }
71
+
72
+ /**
73
+ * Relación SAT (c_TipoRelacion) de la NC: 03 «devolución de mercancía» si regresan
74
+ * renglones con producto; 01 «nota de crédito de los documentos relacionados» si es
75
+ * solo ajuste de importe. Misma regla que aplica el backend al proyectar la NC.
76
+ */
77
+ export function creditNoteRelation(lines: ReturnableLine[], choices: ReturnChoices): '01' | '03' {
78
+ return lines.some((l) => !!l.product_id && toAmount(choices[l.key]?.quantity) > 0) ? '03' : '01'
79
+ }
80
+
81
+ export interface ReturnValidation {
82
+ errors: Record<string, string>
83
+ form?: string
84
+ valid: boolean
85
+ }
86
+
87
+ export function validateReturnChoices(lines: ReturnableLine[], choices: ReturnChoices, reason?: string, requireReason = true): ReturnValidation {
88
+ const errors: Record<string, string> = {}
89
+ let any = false
90
+ for (const l of lines) {
91
+ const c = choices[l.key]
92
+ const q = c ? toAmount(c.quantity) : 0
93
+ if (q === 0) continue
94
+ if (q < 0) errors[`${l.key}.quantity`] = 'La cantidad no puede ser negativa'
95
+ else if (q > returnableQty(l)) errors[`${l.key}.quantity`] = `Máximo ${returnableQty(l)} por devolver`
96
+ else any = true
97
+ if (c && !RETURN_CONDITION_DESTINATIONS[c.condition]?.includes(c.destination)) {
98
+ errors[`${l.key}.destination`] = 'Ese destino no aplica a la condición elegida'
99
+ }
100
+ }
101
+ let form: string | undefined
102
+ if (!any && Object.keys(errors).length === 0) form = 'Elige al menos un renglón a devolver'
103
+ else if (requireReason && !(reason ?? '').trim()) form = 'Indica el motivo de la devolución'
104
+ return { errors, form, valid: !form && Object.keys(errors).length === 0 }
105
+ }
106
+
107
+ /** Pasos visibles. Sin almacén, «Recibir» es un paso del mismo asistente (PIT-021). */
108
+ export type ReturnStep = 'lines' | 'condition' | 'receive' | 'refund'
109
+
110
+ export function returnSteps(opts: { warehouseConnected: boolean }): ReturnStep[] {
111
+ // Con almacén, la recepción la confirma almacén fuera del asistente: no es paso.
112
+ return opts.warehouseConnected ? ['lines', 'condition', 'refund'] : ['lines', 'condition', 'receive', 'refund']
113
+ }
114
+
115
+ export interface ReturnSubmitPayload {
116
+ reason: string
117
+ notes?: string
118
+ /** true cuando el asistente confirmó la recepción (sin almacén). */
119
+ received: boolean
120
+ lines: { key: string; product_id?: string; quantity: number; condition: ReturnCondition; destination: ReturnStockDestination }[]
121
+ refund: RefundAllocation[]
122
+ /** Presente solo si el reembolso incluye una NC CFDI. */
123
+ credit_note?: { relation: '01' | '03' }
124
+ totals: ReturnTotals
125
+ }
126
+
127
+ /** Única puerta de salida hacia el backend: solo renglones con cantidad > 0. */
128
+ export function serializeReturn(args: {
129
+ lines: ReturnableLine[]
130
+ choices: ReturnChoices
131
+ reason: string
132
+ notes?: string
133
+ received: boolean
134
+ refund: RefundAllocation[]
135
+ }): ReturnSubmitPayload {
136
+ const lines = args.lines
137
+ .filter((l) => toAmount(args.choices[l.key]?.quantity) > 0)
138
+ .map((l) => {
139
+ const c = args.choices[l.key]
140
+ return { key: l.key, ...(l.product_id ? { product_id: l.product_id } : {}), quantity: toAmount(c.quantity), condition: c.condition, destination: c.destination }
141
+ })
142
+ const notes = (args.notes ?? '').trim()
143
+ return {
144
+ reason: args.reason.trim(),
145
+ ...(notes ? { notes } : {}),
146
+ received: args.received,
147
+ lines,
148
+ refund: args.refund.map((a) => ({ ...a, amount: roundMoney(a.amount) })),
149
+ ...(args.refund.some((a) => a.kind === 'credit_note') ? { credit_note: { relation: creditNoteRelation(args.lines, args.choices) } } : {}),
150
+ totals: computeReturnTotals(args.lines, args.choices),
151
+ }
152
+ }
153
+
154
+ /** Valida el paso de reembolso contra el total de la devolución. */
155
+ export function validateReturnRefund(total: number, refund: RefundAllocation[], options?: RefundDestinationOption[]) {
156
+ return validateRefund(total, refund, options)
157
+ }
158
+
159
+ // ---- Estados de la RMA ---------------------------------------------------------
160
+ // Borrador → Autorizada → Recibida (total o parcial) → Liquidada → Cerrada, y
161
+ // Rechazada o Cancelada con motivo obligatorio (benchmark §8.2).
162
+ export type RmaState = 'draft' | 'authorized' | 'received' | 'settled' | 'closed' | 'rejected' | 'cancelled'
163
+
164
+ export const RMA_TRANSITIONS: Record<RmaState, RmaState[]> = {
165
+ draft: ['authorized', 'rejected', 'cancelled'],
166
+ authorized: ['received', 'rejected', 'cancelled'],
167
+ received: ['settled', 'cancelled'],
168
+ settled: ['closed'],
169
+ closed: [],
170
+ rejected: [],
171
+ cancelled: [],
172
+ }
173
+
174
+ /** Estados a los que se llega con motivo obligatorio. */
175
+ export const RMA_REASON_REQUIRED: RmaState[] = ['rejected', 'cancelled']
176
+
177
+ export function canTransitionRma(from: RmaState, to: RmaState, reason?: string): { ok: boolean; error?: string } {
178
+ if (!RMA_TRANSITIONS[from]?.includes(to)) return { ok: false, error: `No se puede pasar de ${from} a ${to}` }
179
+ if (RMA_REASON_REQUIRED.includes(to) && !(reason ?? '').trim()) return { ok: false, error: 'Indica el motivo' }
180
+ return { ok: true }
181
+ }
@@ -1312,7 +1312,11 @@ export function makeDefaultGetDynamicColumns(
1312
1312
 
1313
1313
  case 'tags': {
1314
1314
  const list: string[] = Array.isArray(value)
1315
- ? value.map(String)
1315
+ ? value.map((v) =>
1316
+ v && typeof v === 'object'
1317
+ ? String((v as { label?: unknown; name?: unknown; key?: unknown }).label ?? (v as { name?: unknown }).name ?? (v as { key?: unknown }).key ?? '')
1318
+ : String(v),
1319
+ ).filter(Boolean)
1316
1320
  : value
1317
1321
  ? String(value)
1318
1322
  .split(',')
@@ -83,6 +83,10 @@ export {
83
83
  export interface DynamicRelationStrings {
84
84
  title: string
85
85
  emptyState: string
86
+ /** Sub-tabla sin permiso de lectura (HTTP 403). Default: "Sin permiso para ver estos registros." */
87
+ forbiddenState?: string
88
+ /** Falla al cargar la sub-tabla (no 403). Default: "No se pudieron cargar los registros relacionados." */
89
+ errorState?: string
86
90
  addLabel: string
87
91
  editLabel: string
88
92
  removeLabel: string
@@ -252,6 +256,9 @@ function OneToManyRelation({
252
256
  const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
253
257
  const [rows, setRows] = useState<any[]>([])
254
258
  const [loading, setLoading] = useState(true)
259
+ // HTTP status of the last failed fetch (null = ok). Without it a 403/500
260
+ // rendered the empty state and read as "no related rows" (PIT-045).
261
+ const [fetchErrorStatus, setFetchErrorStatus] = useState<number | null>(null)
255
262
  // Paginación server-side + scroll infinito, igual que <DynamicTable>.
256
263
  const [total, setTotal] = useState(0)
257
264
  const [page, setPage] = useState(1)
@@ -341,6 +348,7 @@ function OneToManyRelation({
341
348
  cacheMetadata(model, fresh)
342
349
  }
343
350
  const list = (dataRes as { data: ApiResponse<any[]> }).data
351
+ setFetchErrorStatus(null)
344
352
  if (list.success) {
345
353
  const fetched = list.data || []
346
354
  setRows((prev) => (append ? dedupeById(prev, fetched) : fetched))
@@ -351,6 +359,7 @@ function OneToManyRelation({
351
359
  }
352
360
  } catch (err) {
353
361
  console.error('DynamicRelation fetch error', err)
362
+ setFetchErrorStatus((err as { response?: { status?: number } })?.response?.status ?? 0)
354
363
  } finally {
355
364
  if (append) setLoadingMore(false)
356
365
  else setLoading(false)
@@ -555,7 +564,11 @@ function OneToManyRelation({
555
564
  </div>
556
565
  ) : rows.length === 0 ? (
557
566
  <div className="text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30">
558
- {labels.emptyState}
567
+ {fetchErrorStatus === 403
568
+ ? (labels.forbiddenState ?? 'Sin permiso para ver estos registros.')
569
+ : fetchErrorStatus !== null
570
+ ? (labels.errorState ?? 'No se pudieron cargar los registros relacionados.')
571
+ : labels.emptyState}
559
572
  </div>
560
573
  ) : (
561
574
  // Real metadata-driven table — same metacore-ui primitives and
@@ -34,6 +34,7 @@ import {
34
34
  AlertDialogTitle,
35
35
  } from '@asteby/metacore-ui/primitives'
36
36
  import { useApi } from './api-context'
37
+ import { useReasonPrompt } from './reason-prompt'
37
38
  import { ActionModalDispatcher } from './action-modal-dispatcher'
38
39
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
39
40
  import { RequiresAddonDialog, resolveRequiresAddon } from './requires-addon'
@@ -102,6 +103,9 @@ export function useDynamicRowActions({
102
103
 
103
104
  const [rowToDelete, setRowToDelete] = useState<any | null>(null)
104
105
  const [isDeleting, setIsDeleting] = useState(false)
106
+ // Models that declare `reason_required.delete` answer a bare DELETE with 422
107
+ // errors.reason; the prompt asks once and retries with `?reason=`.
108
+ const reasonPrompt = useReasonPrompt()
105
109
 
106
110
  const [actionModal, setActionModal] = useState<{
107
111
  open: boolean
@@ -157,7 +161,14 @@ export function useDynamicRowActions({
157
161
  setIsDeleting(true)
158
162
  try {
159
163
  const deleteEndpoint = writeBase ? `${writeBase}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
160
- const res = await api.delete(deleteEndpoint)
164
+ const res = await reasonPrompt.run({
165
+ title: t('dynamic.delete_reason_title', { defaultValue: 'Eliminar registro' }),
166
+ description: t('dynamic.delete_reason_desc', { defaultValue: 'Este modelo pide el motivo de la eliminación.' }),
167
+ confirmLabel: t('dynamic.delete_confirm', { defaultValue: 'Eliminar' }),
168
+ request: (reason) => api.delete(deleteEndpoint, reason ? { params: { reason } } : undefined),
169
+ })
170
+ // El operador canceló el motivo: no se eliminó nada.
171
+ if (!res) return
161
172
  // CRUD estándar: no usar res.data.message (el endpoint dinámico
162
173
  // devuelve texto en inglés que se filtraría al toast). String localizado.
163
174
  if (res.data.success) { toast.success(t('dynamic.delete_success', { defaultValue: 'Registro eliminado correctamente' })); onRefresh() }
@@ -173,6 +184,7 @@ export function useDynamicRowActions({
173
184
 
174
185
  const dialogs = (
175
186
  <>
187
+ {reasonPrompt.dialog}
176
188
  <AlertDialog open={!!rowToDelete} onOpenChange={(open: boolean) => !open && setRowToDelete(null)}>
177
189
  <AlertDialogContent>
178
190
  <AlertDialogHeader>
@@ -67,6 +67,7 @@ import { toast } from 'sonner'
67
67
  import { Progress } from './dialogs/_primitives'
68
68
  import { useMetadataCache } from './metadata-cache'
69
69
  import { useApi, useCurrentBranch } from './api-context'
70
+ import { useReasonPrompt } from './reason-prompt'
70
71
  import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
71
72
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
72
73
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
@@ -1123,6 +1124,7 @@ export function DynamicTable({
1123
1124
  onRefresh: handleRefresh,
1124
1125
  })
1125
1126
 
1127
+ const bulkReasonPrompt = useReasonPrompt()
1126
1128
  const confirmBulkDelete = async () => {
1127
1129
  const selectedRows = table.getFilteredSelectedRowModel().rows
1128
1130
  if (selectedRows.length === 0) return
@@ -1130,13 +1132,29 @@ export function DynamicTable({
1130
1132
  setBulkDeleteTotal(selectedRows.length)
1131
1133
  setBulkDeleteProgress(0)
1132
1134
  let successCount = 0, errorCount = 0
1135
+ // One reason for the whole batch: asked the first time a row is refused
1136
+ // for lacking it (model declares reason_required.delete), reused after.
1137
+ let bulkReason: string | undefined
1138
+ let bulkCancelled = false
1133
1139
  for (let i = 0; i < selectedRows.length; i++) {
1134
1140
  const row = selectedRows[i]
1141
+ if (bulkCancelled) { errorCount++; setBulkDeleteProgress(i + 1); continue }
1135
1142
  try {
1136
1143
  const writeBase = mutationEndpoint ?? endpoint
1137
1144
  const deleteEndpoint = writeBase ? `${writeBase}/${row.original.id}` : `/data/${model}/${row.original.id}`
1138
- const res = await api.delete(deleteEndpoint)
1139
- if (res.data.success) successCount++; else errorCount++
1145
+ const res = await bulkReasonPrompt.run({
1146
+ title: t('dynamic.bulk_delete_reason_title', { count: selectedRows.length, defaultValue: 'Eliminar {{count}} registro(s)' }),
1147
+ description: t('dynamic.delete_reason_desc', { defaultValue: 'Este modelo pide el motivo de la eliminación.' }),
1148
+ confirmLabel: t('dynamic.delete_confirm', { defaultValue: 'Eliminar' }),
1149
+ reason: bulkReason,
1150
+ request: (reason) => {
1151
+ if (reason) bulkReason = reason
1152
+ return api.delete(deleteEndpoint, reason ? { params: { reason } } : undefined)
1153
+ },
1154
+ })
1155
+ if (!res) { bulkCancelled = true; errorCount++ }
1156
+ else if (res.data.success) successCount++
1157
+ else errorCount++
1140
1158
  } catch (e) { console.error('Error al eliminar', e); errorCount++ }
1141
1159
  setBulkDeleteProgress(i + 1)
1142
1160
  }
@@ -1863,6 +1881,8 @@ export function DynamicTable({
1863
1881
  </AlertDialogContent>
1864
1882
  </AlertDialog>
1865
1883
 
1884
+ {bulkReasonPrompt.dialog}
1885
+
1866
1886
  {exportEnabled && (
1867
1887
  <ExportDialog open={exportOpen} onOpenChange={setExportOpen} model={model} metadata={metadata} currentFilters={buildFilterParams()} hasActiveFilters={hasActiveFilters} />
1868
1888
  )}
package/src/index.ts CHANGED
@@ -237,6 +237,16 @@ export {
237
237
  type ApprovalRequiredInfo,
238
238
  type RequestApprovalOptions,
239
239
  } from './approval-gate'
240
+ export {
241
+ ReasonPromptDialog,
242
+ useReasonPrompt,
243
+ reasonRequiredInfo,
244
+ DEFAULT_REASON_MIN,
245
+ type ReasonPromptDialogProps,
246
+ type ReasonPromptApi,
247
+ type ReasonRequiredInfo,
248
+ type RunWithReasonOptions,
249
+ } from './reason-prompt'
240
250
  export {
241
251
  useDynamicRowActions,
242
252
  type UseDynamicRowActionsParams,