@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.
- package/CHANGELOG.md +12 -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/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +8 -0
- package/dist/business/index.d.ts +2 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +2 -0
- package/dist/business/refund-destination.js +1 -1
- package/dist/business/refund.d.ts +1 -1
- package/dist/business/refund.d.ts.map +1 -1
- package/dist/business/refund.js +1 -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/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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -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/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__/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/business/business.test.ts +46 -0
- package/src/business/catalog.ts +8 -0
- package/src/business/index.ts +2 -0
- package/src/business/refund-destination.tsx +2 -2
- package/src/business/refund.ts +2 -1
- package/src/business/return-wizard.tsx +223 -0
- package/src/business/return.ts +181 -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 +10 -0
- package/src/reason-prompt.tsx +247 -0
- package/src/use-addon-settings.ts +26 -7
package/src/business/index.ts
CHANGED
|
@@ -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)))}
|
package/src/business/refund.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -1312,7 +1312,11 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1312
1312
|
|
|
1313
1313
|
case 'tags': {
|
|
1314
1314
|
const list: string[] = Array.isArray(value)
|
|
1315
|
-
? value.map(
|
|
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(',')
|
package/src/dynamic-relation.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
|
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>
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -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
|
|
1139
|
-
|
|
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,
|