@asteby/metacore-runtime-react 45.0.0 → 45.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- 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 +42 -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 +14 -0
- package/dist/business/index.d.ts.map +1 -0
- package/dist/business/index.js +13 -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 +43 -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/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/server-error.d.ts.map +1 -1
- package/dist/server-error.js +19 -0
- package/package.json +1 -1
- package/src/__tests__/approval-gate.test.tsx +134 -0
- 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 +164 -0
- package/src/business/catalog.ts +60 -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 +13 -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 +71 -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/index.ts +23 -0
- package/src/server-error.ts +20 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Retroalimentación que no estorba (benchmark §6.4/§6.5, PIT-005):
|
|
2
|
+
// - notify.success: toast breve «sustantivo + verbo», arriba y con duración corta.
|
|
3
|
+
// - <SystemNoticeBanner>: avisos del sistema («Hay novedades») en banner no
|
|
4
|
+
// bloqueante, nunca encima de las acciones primarias.
|
|
5
|
+
// - <FormErrorBanner>: error accionable persistente (qué pasó + cómo corregirlo).
|
|
6
|
+
// - <EmptyState>: estado vacío con explicación y una acción primaria.
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import { toast } from 'sonner'
|
|
9
|
+
import { AlertCircle, Info, X, type LucideIcon } from 'lucide-react'
|
|
10
|
+
import { Button } from '@asteby/metacore-ui'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Props recomendadas para `<Toaster>` de metacore-ui: los toasts viven arriba a
|
|
14
|
+
* la derecha (el área de acciones primarias suele estar abajo) y dejan hueco
|
|
15
|
+
* bajo la cabecera. Se propagan tal cual: `<Toaster {...ACTION_SAFE_TOASTER_PROPS} />`.
|
|
16
|
+
*/
|
|
17
|
+
export const ACTION_SAFE_TOASTER_PROPS = {
|
|
18
|
+
position: 'top-right' as const,
|
|
19
|
+
offset: 72,
|
|
20
|
+
mobileOffset: 72,
|
|
21
|
+
visibleToasts: 3,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Duración de un toast de éxito (ms): confirma y se va. */
|
|
25
|
+
export const SUCCESS_TOAST_MS = 3000
|
|
26
|
+
|
|
27
|
+
export const notify = {
|
|
28
|
+
/** Confirmación breve de un éxito («Pago registrado»). Admite «Deshacer». */
|
|
29
|
+
success(message: string, opts?: { undo?: { label?: string; onUndo: () => void }; description?: string }) {
|
|
30
|
+
return toast.success(message, {
|
|
31
|
+
duration: SUCCESS_TOAST_MS,
|
|
32
|
+
description: opts?.description,
|
|
33
|
+
action: opts?.undo
|
|
34
|
+
? { label: opts.undo.label ?? 'Deshacer', onClick: opts.undo.onUndo }
|
|
35
|
+
: undefined,
|
|
36
|
+
})
|
|
37
|
+
},
|
|
38
|
+
/**
|
|
39
|
+
* Error inesperado (red, 5xx) sin campo al que atribuirlo. Los errores de
|
|
40
|
+
* validación NO van aquí: usa `mapApiError` + `<FormErrorBanner>`/error del input.
|
|
41
|
+
*/
|
|
42
|
+
error(message: string, description?: string) {
|
|
43
|
+
return toast.error(message, { description, duration: 8000 })
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SystemNoticeBannerProps {
|
|
48
|
+
message: ReactNode
|
|
49
|
+
/** Acción opcional («Actualizar»). */
|
|
50
|
+
action?: { label: string; onClick: () => void }
|
|
51
|
+
onDismiss?: () => void
|
|
52
|
+
dismissLabel?: string
|
|
53
|
+
className?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Aviso del sistema en flujo normal de la página (no flota sobre botones). */
|
|
57
|
+
export function SystemNoticeBanner({ message, action, onDismiss, dismissLabel = 'Descartar', className }: SystemNoticeBannerProps) {
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
role="status"
|
|
61
|
+
data-slot="system-notice"
|
|
62
|
+
className={`flex items-center gap-3 rounded-md border bg-muted px-3 py-2 text-sm ${className ?? ''}`}
|
|
63
|
+
>
|
|
64
|
+
<Info className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
65
|
+
<span className="flex-1">{message}</span>
|
|
66
|
+
{action && (
|
|
67
|
+
<Button type="button" size="sm" variant="outline" onClick={action.onClick}>
|
|
68
|
+
{action.label}
|
|
69
|
+
</Button>
|
|
70
|
+
)}
|
|
71
|
+
{onDismiss && (
|
|
72
|
+
<Button type="button" size="icon" variant="ghost" onClick={onDismiss} aria-label={dismissLabel}>
|
|
73
|
+
<X className="size-4" />
|
|
74
|
+
</Button>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface FormErrorBannerProps {
|
|
81
|
+
/** Qué pasó y cómo corregirlo (típicamente `mapApiError(...).form`). */
|
|
82
|
+
message?: string
|
|
83
|
+
/** Botón a la solución («Ir al campo», «Reintentar»). */
|
|
84
|
+
action?: { label: string; onClick: () => void }
|
|
85
|
+
className?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Error persistente de formulario; no renderiza nada sin `message`. */
|
|
89
|
+
export function FormErrorBanner({ message, action, className }: FormErrorBannerProps) {
|
|
90
|
+
if (!message) return null
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
role="alert"
|
|
94
|
+
data-slot="form-error"
|
|
95
|
+
className={`flex items-start gap-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive ${className ?? ''}`}
|
|
96
|
+
>
|
|
97
|
+
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
98
|
+
<span className="flex-1">{message}</span>
|
|
99
|
+
{action && (
|
|
100
|
+
<Button type="button" size="sm" variant="outline" onClick={action.onClick}>
|
|
101
|
+
{action.label}
|
|
102
|
+
</Button>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface EmptyStateProps {
|
|
109
|
+
icon?: LucideIcon
|
|
110
|
+
/** Qué falta («Aún no hay movimientos para este producto»). */
|
|
111
|
+
title: string
|
|
112
|
+
description?: string
|
|
113
|
+
/** Acción primaria («Registrar saldo inicial»). Se oculta si `hidden`. */
|
|
114
|
+
action?: { label: string; onClick?: () => void; href?: string; hidden?: boolean }
|
|
115
|
+
className?: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Estado vacío accionable: explicación + una acción primaria. */
|
|
119
|
+
export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) {
|
|
120
|
+
const showAction = action && !action.hidden
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
data-slot="empty-state"
|
|
124
|
+
className={`flex flex-col items-center gap-2 px-4 py-10 text-center ${className ?? ''}`}
|
|
125
|
+
>
|
|
126
|
+
{Icon && <Icon className="size-8 text-muted-foreground" aria-hidden />}
|
|
127
|
+
<p className="text-sm font-medium">{title}</p>
|
|
128
|
+
{description && <p className="max-w-sm text-sm text-muted-foreground">{description}</p>}
|
|
129
|
+
{showAction &&
|
|
130
|
+
(action.href ? (
|
|
131
|
+
<Button asChild size="sm" className="mt-2">
|
|
132
|
+
<a href={action.href}>{action.label}</a>
|
|
133
|
+
</Button>
|
|
134
|
+
) : (
|
|
135
|
+
<Button type="button" size="sm" className="mt-2" onClick={action.onClick}>
|
|
136
|
+
{action.label}
|
|
137
|
+
</Button>
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Mapeo de errores de API (409/422/…) a mensajes por campo (PIT-002/003/013/021/029).
|
|
2
|
+
//
|
|
3
|
+
// Un 422 trae `errors: {campo: [{code, params}]}`; un 409 (duplicado, enum,
|
|
4
|
+
// conflicto de estado) suele traer solo `message`/`details`. Ambos se resuelven
|
|
5
|
+
// aquí a `{ fields, form }`: los campos se pintan junto al input y `form` (lo que
|
|
6
|
+
// no se pudo atribuir a un campo) va a un banner persistente — el toast solo
|
|
7
|
+
// confirma éxitos.
|
|
8
|
+
import {
|
|
9
|
+
extractFieldErrors,
|
|
10
|
+
extractServerError,
|
|
11
|
+
localizeFieldErrorMap,
|
|
12
|
+
type Translate,
|
|
13
|
+
} from '../server-error'
|
|
14
|
+
|
|
15
|
+
export interface MappedApiError {
|
|
16
|
+
/** HTTP status si el error lo trae (axios `response.status`). */
|
|
17
|
+
status?: number
|
|
18
|
+
/** Mensaje ya localizado por campo, listo para el `error` de cada input. */
|
|
19
|
+
fields: Record<string, string>
|
|
20
|
+
/** Mensaje general (qué pasó + cómo corregirlo) para un banner; sin jerga. */
|
|
21
|
+
form?: string
|
|
22
|
+
/** true si hay al menos un mensaje atribuible a un campo. */
|
|
23
|
+
hasFieldErrors: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MapApiErrorOptions {
|
|
27
|
+
t?: Translate
|
|
28
|
+
language?: string
|
|
29
|
+
/** Etiquetas traducidas por clave de campo (para «El campo {{label}}…»). */
|
|
30
|
+
labels?: Record<string, string>
|
|
31
|
+
/** Campos conocidos del formulario: un 409 se atribuye al que la causa nombra. */
|
|
32
|
+
knownFields?: string[]
|
|
33
|
+
fallback?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CONFLICT_HINTS: Array<{ re: RegExp; es: string }> = [
|
|
37
|
+
{ re: /duplicate|already exists|unique|23505|ya existe/i, es: 'Ya existe un registro con ese valor' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** Traductor sin i18next: devuelve el default con `{{var}}` interpolado. */
|
|
41
|
+
const defaultTranslate: Translate = (key, o) =>
|
|
42
|
+
(o?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_, k: string) => String(o?.[k] ?? ''))
|
|
43
|
+
|
|
44
|
+
function statusOf(err: unknown): number | undefined {
|
|
45
|
+
const s = (err as { response?: { status?: unknown } } | undefined)?.response?.status
|
|
46
|
+
return typeof s === 'number' ? s : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Encuentra el campo que un texto de causa nombra (`Key (sku)=(A1) already exists`). */
|
|
50
|
+
function fieldNamedBy(text: string, known: string[] | undefined): string | undefined {
|
|
51
|
+
if (!known?.length) return undefined
|
|
52
|
+
const lower = text.toLowerCase()
|
|
53
|
+
return known.find((k) => new RegExp(`(^|[^a-z0-9_])${k.toLowerCase()}([^a-z0-9_]|$)`).test(lower))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Convierte cualquier error de mutación en mensajes por campo + mensaje general.
|
|
58
|
+
* Pura salvo por `t`; no muestra toasts.
|
|
59
|
+
*/
|
|
60
|
+
export function mapApiError(err: unknown, opts: MapApiErrorOptions = {}): MappedApiError {
|
|
61
|
+
const t: Translate = opts.t ?? defaultTranslate
|
|
62
|
+
const status = statusOf(err)
|
|
63
|
+
const raw = extractFieldErrors(err)
|
|
64
|
+
if (raw) {
|
|
65
|
+
const fields = localizeFieldErrorMap(raw, t, { labels: opts.labels, language: opts.language })
|
|
66
|
+
return { status, fields, hasFieldErrors: Object.keys(fields).length > 0 }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Sin respuesta HTTP (red caída, CORS, excepción): el texto crudo es jerga.
|
|
70
|
+
const noResponse = !(err as { response?: unknown } | undefined)?.response && err instanceof Error
|
|
71
|
+
const fallback = opts.fallback ?? t('common.error', { defaultValue: 'No se pudo completar la operación' })
|
|
72
|
+
if (noResponse) return { status, fields: {}, form: fallback, hasFieldErrors: false }
|
|
73
|
+
const { title, description } = extractServerError(err, fallback)
|
|
74
|
+
const text = [title, description].filter(Boolean).join(' — ')
|
|
75
|
+
|
|
76
|
+
if (status === 409 || status === 422) {
|
|
77
|
+
const hint = CONFLICT_HINTS.find((h) => h.re.test(text))
|
|
78
|
+
const field = fieldNamedBy(text, opts.knownFields)
|
|
79
|
+
if (field) {
|
|
80
|
+
const label = opts.labels?.[field] ?? field
|
|
81
|
+
const msg = hint
|
|
82
|
+
? t('validation.duplicate', { defaultValue: 'Ya existe un registro con ese {{label}}', label })
|
|
83
|
+
: t(title, { defaultValue: title })
|
|
84
|
+
return { status, fields: { [field]: msg }, hasFieldErrors: true }
|
|
85
|
+
}
|
|
86
|
+
const form = hint ? hint.es : t(title, { defaultValue: title })
|
|
87
|
+
return { status, fields: {}, form: description && !hint ? `${form}. ${description}` : form, hasFieldErrors: false }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const form = t(title, { defaultValue: title })
|
|
91
|
+
return { status, fields: {}, form: description ? `${form}. ${description}` : form, hasFieldErrors: false }
|
|
92
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Formateador único de moneda, número y fecha (benchmark PIT-023 / PIT-025).
|
|
2
|
+
//
|
|
3
|
+
// Toda la UI de negocio (toasts, tickets, PDFs, pickers) formatea por aquí para
|
|
4
|
+
// no mezclar locale y moneda (el «MX$1,06.00» del toast de cobro) ni desfasar
|
|
5
|
+
// fechas por zona horaria. La moneda y la zona son config de la org — nunca se
|
|
6
|
+
// hardcodean: salen de <CurrencyContext>/<TimeZoneContext> o de `opts`.
|
|
7
|
+
import { useMemo } from 'react'
|
|
8
|
+
import { useCurrency, useTimeZone } from '../org-runtime-context'
|
|
9
|
+
|
|
10
|
+
/** Locale por defecto del producto. */
|
|
11
|
+
export const DEFAULT_LOCALE = 'es-MX'
|
|
12
|
+
/** Moneda cuando ni la org ni el llamador dan una (mismo fallback que las celdas). */
|
|
13
|
+
export const FALLBACK_CURRENCY = 'USD'
|
|
14
|
+
|
|
15
|
+
export interface FormatOptions {
|
|
16
|
+
/** BCP-47. Default `es-MX`. */
|
|
17
|
+
locale?: string
|
|
18
|
+
/** ISO-4217 de la org. Default: contexto → `USD`. */
|
|
19
|
+
currency?: string
|
|
20
|
+
/** IANA de la org. Default: contexto → zona del navegador. */
|
|
21
|
+
timeZone?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Redondea a centavos evitando el error binario (1.005 → 1.01). */
|
|
25
|
+
export function roundMoney(n: number, decimals = 2): number {
|
|
26
|
+
if (!Number.isFinite(n)) return 0
|
|
27
|
+
const f = 10 ** decimals
|
|
28
|
+
return Math.round((n + Number.EPSILON) * f) / f
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Número (string con símbolo/separadores, o number) → number; NaN → 0. */
|
|
32
|
+
export function toAmount(v: unknown): number {
|
|
33
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : 0
|
|
34
|
+
if (typeof v !== 'string') return 0
|
|
35
|
+
const s = v.replace(/[^0-9.,-]/g, '')
|
|
36
|
+
if (!s) return 0
|
|
37
|
+
const lastComma = s.lastIndexOf(',')
|
|
38
|
+
const lastDot = s.lastIndexOf('.')
|
|
39
|
+
const normalized =
|
|
40
|
+
lastComma > lastDot ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '')
|
|
41
|
+
const n = Number(normalized)
|
|
42
|
+
return Number.isFinite(n) ? n : 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Monto con símbolo de la moneda de la org: `formatMoney(1060, {currency:'MXN'})`
|
|
47
|
+
* → `$1,060.00`. Un valor no numérico devuelve `''` (nunca «NaN»).
|
|
48
|
+
*/
|
|
49
|
+
export function formatMoney(amount: unknown, opts: FormatOptions = {}): string {
|
|
50
|
+
const n = typeof amount === 'number' ? amount : toAmount(amount)
|
|
51
|
+
if (!Number.isFinite(n)) return ''
|
|
52
|
+
const currency = opts.currency || FALLBACK_CURRENCY
|
|
53
|
+
try {
|
|
54
|
+
return new Intl.NumberFormat(opts.locale || DEFAULT_LOCALE, {
|
|
55
|
+
style: 'currency',
|
|
56
|
+
currency,
|
|
57
|
+
currencyDisplay: 'narrowSymbol',
|
|
58
|
+
}).format(n)
|
|
59
|
+
} catch {
|
|
60
|
+
return `${currency} ${n.toFixed(2)}`
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Número plano localizado (`1,234.5`). */
|
|
65
|
+
export function formatQuantity(n: unknown, opts: { locale?: string; maximumFractionDigits?: number } = {}): string {
|
|
66
|
+
const v = typeof n === 'number' ? n : toAmount(n)
|
|
67
|
+
if (!Number.isFinite(v)) return ''
|
|
68
|
+
return new Intl.NumberFormat(opts.locale || DEFAULT_LOCALE, {
|
|
69
|
+
maximumFractionDigits: opts.maximumFractionDigits ?? 3,
|
|
70
|
+
}).format(v)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/
|
|
74
|
+
|
|
75
|
+
function toDate(input: unknown): { date: Date; dateOnly: boolean } | null {
|
|
76
|
+
if (input == null || input === '') return null
|
|
77
|
+
if (input instanceof Date) return Number.isNaN(input.getTime()) ? null : { date: input, dateOnly: false }
|
|
78
|
+
if (typeof input === 'string' || typeof input === 'number') {
|
|
79
|
+
const dateOnly = typeof input === 'string' && DATE_ONLY.test(input)
|
|
80
|
+
const d = new Date(input)
|
|
81
|
+
return Number.isNaN(d.getTime()) ? null : { date: d, dateOnly }
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fecha (`29 sep 2026`). Un `YYYY-MM-DD` es fecha de calendario: se formatea en
|
|
88
|
+
* UTC para que no retroceda un día en zonas con offset negativo.
|
|
89
|
+
*/
|
|
90
|
+
export function formatDate(input: unknown, opts: FormatOptions & { style?: Intl.DateTimeFormatOptions['dateStyle'] } = {}): string {
|
|
91
|
+
const parsed = toDate(input)
|
|
92
|
+
if (!parsed) return ''
|
|
93
|
+
return new Intl.DateTimeFormat(opts.locale || DEFAULT_LOCALE, {
|
|
94
|
+
dateStyle: opts.style ?? 'medium',
|
|
95
|
+
timeZone: parsed.dateOnly ? 'UTC' : opts.timeZone || undefined,
|
|
96
|
+
}).format(parsed.date)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Fecha y hora en la zona de la org (`29 sep 2026, 14:05`). */
|
|
100
|
+
export function formatDateTime(input: unknown, opts: FormatOptions = {}): string {
|
|
101
|
+
const parsed = toDate(input)
|
|
102
|
+
if (!parsed) return ''
|
|
103
|
+
if (parsed.dateOnly) return formatDate(input, opts)
|
|
104
|
+
return new Intl.DateTimeFormat(opts.locale || DEFAULT_LOCALE, {
|
|
105
|
+
dateStyle: 'medium',
|
|
106
|
+
timeStyle: 'short',
|
|
107
|
+
timeZone: opts.timeZone || undefined,
|
|
108
|
+
}).format(parsed.date)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface Formatter {
|
|
112
|
+
locale: string
|
|
113
|
+
currency: string
|
|
114
|
+
timeZone: string | undefined
|
|
115
|
+
money: (amount: unknown, currency?: string) => string
|
|
116
|
+
quantity: (n: unknown) => string
|
|
117
|
+
date: (input: unknown) => string
|
|
118
|
+
dateTime: (input: unknown) => string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Formateador ligado a un locale/moneda/zona fijos (para tickets y PDFs fuera de React). */
|
|
122
|
+
export function createFormatter(opts: FormatOptions = {}): Formatter {
|
|
123
|
+
const locale = opts.locale || DEFAULT_LOCALE
|
|
124
|
+
const currency = opts.currency || FALLBACK_CURRENCY
|
|
125
|
+
const timeZone = opts.timeZone || undefined
|
|
126
|
+
return {
|
|
127
|
+
locale,
|
|
128
|
+
currency,
|
|
129
|
+
timeZone,
|
|
130
|
+
money: (amount, c) => formatMoney(amount, { locale, currency: c || currency }),
|
|
131
|
+
quantity: (n) => formatQuantity(n, { locale }),
|
|
132
|
+
date: (input) => formatDate(input, { locale, timeZone }),
|
|
133
|
+
dateTime: (input) => formatDateTime(input, { locale, timeZone }),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Formateador con la moneda y zona de la org tomadas del runtime provider. */
|
|
138
|
+
export function useFormatter(overrides: FormatOptions = {}): Formatter {
|
|
139
|
+
const orgCurrency = useCurrency()
|
|
140
|
+
const orgZone = useTimeZone()
|
|
141
|
+
const { locale, currency, timeZone } = overrides
|
|
142
|
+
return useMemo(
|
|
143
|
+
() =>
|
|
144
|
+
createFormatter({
|
|
145
|
+
locale,
|
|
146
|
+
currency: currency ?? orgCurrency,
|
|
147
|
+
timeZone: timeZone ?? orgZone,
|
|
148
|
+
}),
|
|
149
|
+
[locale, currency, timeZone, orgCurrency, orgZone],
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './format'
|
|
2
|
+
export * from './field-errors'
|
|
3
|
+
export * from './feedback'
|
|
4
|
+
export * from './line-items'
|
|
5
|
+
export * from './line-items-editor'
|
|
6
|
+
export * from './payment'
|
|
7
|
+
export * from './payment-capture'
|
|
8
|
+
export * from './refund'
|
|
9
|
+
export * from './refund-destination'
|
|
10
|
+
export * from './product-search'
|
|
11
|
+
export * from './product-picker'
|
|
12
|
+
export * from './customer-picker'
|
|
13
|
+
export * from './catalog'
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// LineItemsEditor — editor de renglones compartido (benchmark §7, COT-1/PIT-018).
|
|
2
|
+
// Controlado: `value`/`onChange` con `LineItem[]`. Guardar = `serializeLineItems`.
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { Plus, Trash2, StickyNote, Heading } from 'lucide-react'
|
|
6
|
+
import { Button, Input } from '@asteby/metacore-ui'
|
|
7
|
+
import { useCan } from '../permissions-context'
|
|
8
|
+
import { useFormatter } from './format'
|
|
9
|
+
import {
|
|
10
|
+
computeLine,
|
|
11
|
+
computeTotals,
|
|
12
|
+
makeLine,
|
|
13
|
+
validateLineItems,
|
|
14
|
+
type LineItem,
|
|
15
|
+
type LineItemsPolicy,
|
|
16
|
+
type LineItemsValidation,
|
|
17
|
+
} from './line-items'
|
|
18
|
+
|
|
19
|
+
export type LineItemsColumn = 'discount' | 'tax' | 'technician' | 'lot' | 'dot'
|
|
20
|
+
|
|
21
|
+
export interface LineItemsEditorProps {
|
|
22
|
+
value: LineItem[]
|
|
23
|
+
/** Se emite con cada cambio; las líneas siempre traen `key`. */
|
|
24
|
+
onChange: (lines: LineItem[]) => void
|
|
25
|
+
/** Columnas opcionales visibles. Default: `['discount']`. */
|
|
26
|
+
columns?: LineItemsColumn[]
|
|
27
|
+
policy?: LineItemsPolicy
|
|
28
|
+
/** Errores externos del servidor `"<índice>.<campo>"` (de `mapApiError().fields`). */
|
|
29
|
+
serverErrors?: Record<string, string>
|
|
30
|
+
/** Se emite tras cada cambio con el resultado de validar (para bloquear «Guardar»). */
|
|
31
|
+
onValidate?: (v: LineItemsValidation) => void
|
|
32
|
+
/** Slot: abre el ProductPicker; al elegir, el llamador hace `onChange([...value, makeLine(...)])`. */
|
|
33
|
+
onRequestProduct?: () => void
|
|
34
|
+
/** Permite secciones y notas (Odoo-style). Default true. */
|
|
35
|
+
allowSections?: boolean
|
|
36
|
+
readOnly?: boolean
|
|
37
|
+
/** Permiso requerido para editar (default: sin gate). Sin él → solo lectura. */
|
|
38
|
+
editPermission?: string
|
|
39
|
+
/** Moneda ISO (default: la de la org). */
|
|
40
|
+
currency?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const num = (s: string): number => (s.trim() === '' ? 0 : Number(s.replace(',', '.')) || 0)
|
|
44
|
+
|
|
45
|
+
export function LineItemsEditor({
|
|
46
|
+
value,
|
|
47
|
+
onChange,
|
|
48
|
+
columns = ['discount'],
|
|
49
|
+
policy,
|
|
50
|
+
serverErrors,
|
|
51
|
+
onValidate,
|
|
52
|
+
onRequestProduct,
|
|
53
|
+
allowSections = true,
|
|
54
|
+
readOnly = false,
|
|
55
|
+
editPermission,
|
|
56
|
+
currency,
|
|
57
|
+
}: LineItemsEditorProps) {
|
|
58
|
+
const { t } = useTranslation()
|
|
59
|
+
const can = useCan()
|
|
60
|
+
const fmt = useFormatter({ currency })
|
|
61
|
+
const locked = readOnly || (editPermission ? !can(editPermission) : false)
|
|
62
|
+
const validation = useMemo(() => validateLineItems(value, policy), [value, policy])
|
|
63
|
+
const totals = useMemo(() => computeTotals(value), [value])
|
|
64
|
+
const errors = { ...validation.errors, ...serverErrors }
|
|
65
|
+
const show = (c: LineItemsColumn) => columns.includes(c)
|
|
66
|
+
|
|
67
|
+
const emit = (next: LineItem[]) => {
|
|
68
|
+
onChange(next)
|
|
69
|
+
onValidate?.(validateLineItems(next, policy))
|
|
70
|
+
}
|
|
71
|
+
const patch = (idx: number, p: Partial<LineItem>) =>
|
|
72
|
+
emit(value.map((l, i) => (i === idx ? { ...l, ...p } : l)))
|
|
73
|
+
const remove = (idx: number) => emit(value.filter((_, i) => i !== idx))
|
|
74
|
+
|
|
75
|
+
const cell = (i: number, field: keyof LineItem) => errors[`${i}.${field}`]
|
|
76
|
+
const warn = (i: number, field: string) => validation.warnings[`${i}.${field}`]
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div data-slot="line-items-editor" className="space-y-2">
|
|
80
|
+
<div className="overflow-x-auto rounded-md border">
|
|
81
|
+
<table className="w-full text-sm">
|
|
82
|
+
<thead className="bg-muted/50 text-left text-xs text-muted-foreground">
|
|
83
|
+
<tr>
|
|
84
|
+
<th className="px-2 py-2">{t('lineItems.description', { defaultValue: 'Descripción' })}</th>
|
|
85
|
+
<th className="w-24 px-2 py-2 text-right">{t('lineItems.quantity', { defaultValue: 'Cant.' })}</th>
|
|
86
|
+
<th className="w-32 px-2 py-2 text-right">{t('lineItems.unitPrice', { defaultValue: 'Precio' })}</th>
|
|
87
|
+
{show('discount') && <th className="w-20 px-2 py-2 text-right">{t('lineItems.discount', { defaultValue: 'Desc. %' })}</th>}
|
|
88
|
+
{show('tax') && <th className="w-20 px-2 py-2 text-right">{t('lineItems.tax', { defaultValue: 'IVA %' })}</th>}
|
|
89
|
+
{show('technician') && <th className="w-32 px-2 py-2">{t('lineItems.technician', { defaultValue: 'Técnico' })}</th>}
|
|
90
|
+
{show('lot') && <th className="w-28 px-2 py-2">{t('lineItems.lot', { defaultValue: 'Lote' })}</th>}
|
|
91
|
+
{show('dot') && <th className="w-24 px-2 py-2">DOT</th>}
|
|
92
|
+
<th className="w-32 px-2 py-2 text-right">{t('lineItems.total', { defaultValue: 'Importe' })}</th>
|
|
93
|
+
<th className="w-10" />
|
|
94
|
+
</tr>
|
|
95
|
+
</thead>
|
|
96
|
+
<tbody>
|
|
97
|
+
{value.map((l, i) => {
|
|
98
|
+
if (l.kind !== 'item') {
|
|
99
|
+
return (
|
|
100
|
+
<tr key={l.key} className="border-t bg-muted/30">
|
|
101
|
+
<td colSpan={99} className="px-2 py-1.5">
|
|
102
|
+
<div className="flex items-center gap-2">
|
|
103
|
+
<Input
|
|
104
|
+
value={l.description}
|
|
105
|
+
disabled={locked}
|
|
106
|
+
aria-invalid={!!cell(i, 'description')}
|
|
107
|
+
placeholder={l.kind === 'section' ? t('lineItems.sectionPlaceholder', { defaultValue: 'Sección' }) : t('lineItems.notePlaceholder', { defaultValue: 'Nota' })}
|
|
108
|
+
className={l.kind === 'section' ? 'font-semibold' : 'italic'}
|
|
109
|
+
onChange={(e) => patch(i, { description: e.target.value })}
|
|
110
|
+
/>
|
|
111
|
+
{!locked && (
|
|
112
|
+
<Button type="button" size="icon" variant="ghost" onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
|
|
113
|
+
<Trash2 className="size-4" />
|
|
114
|
+
</Button>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
{cell(i, 'description') && <p className="mt-1 text-xs text-destructive">{cell(i, 'description')}</p>}
|
|
118
|
+
</td>
|
|
119
|
+
</tr>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
const a = computeLine(l)
|
|
123
|
+
return (
|
|
124
|
+
<tr key={l.key} className="border-t align-top">
|
|
125
|
+
<td className="px-2 py-1.5">
|
|
126
|
+
<Input
|
|
127
|
+
value={l.description}
|
|
128
|
+
disabled={locked}
|
|
129
|
+
aria-invalid={!!cell(i, 'description')}
|
|
130
|
+
onChange={(e) => patch(i, { description: e.target.value })}
|
|
131
|
+
/>
|
|
132
|
+
{l.sku && <p className="mt-0.5 text-xs text-muted-foreground">{l.sku}</p>}
|
|
133
|
+
{cell(i, 'description') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'description')}</p>}
|
|
134
|
+
</td>
|
|
135
|
+
<td className="px-2 py-1.5">
|
|
136
|
+
<Input
|
|
137
|
+
inputMode="decimal"
|
|
138
|
+
className="text-right"
|
|
139
|
+
value={String(l.quantity)}
|
|
140
|
+
disabled={locked}
|
|
141
|
+
aria-invalid={!!cell(i, 'quantity')}
|
|
142
|
+
onChange={(e) => patch(i, { quantity: num(e.target.value) })}
|
|
143
|
+
/>
|
|
144
|
+
{cell(i, 'quantity') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'quantity')}</p>}
|
|
145
|
+
{!cell(i, 'quantity') && warn(i, 'quantity') && <p className="mt-0.5 text-xs text-muted-foreground">{warn(i, 'quantity')}</p>}
|
|
146
|
+
</td>
|
|
147
|
+
<td className="px-2 py-1.5">
|
|
148
|
+
<Input
|
|
149
|
+
inputMode="decimal"
|
|
150
|
+
className="text-right"
|
|
151
|
+
value={String(l.unit_price)}
|
|
152
|
+
disabled={locked}
|
|
153
|
+
aria-invalid={!!cell(i, 'unit_price')}
|
|
154
|
+
onChange={(e) => patch(i, { unit_price: num(e.target.value) })}
|
|
155
|
+
/>
|
|
156
|
+
{cell(i, 'unit_price') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'unit_price')}</p>}
|
|
157
|
+
</td>
|
|
158
|
+
{show('discount') && (
|
|
159
|
+
<td className="px-2 py-1.5">
|
|
160
|
+
<Input
|
|
161
|
+
inputMode="decimal"
|
|
162
|
+
className="text-right"
|
|
163
|
+
value={String(l.discount)}
|
|
164
|
+
disabled={locked}
|
|
165
|
+
aria-invalid={!!cell(i, 'discount')}
|
|
166
|
+
onChange={(e) => patch(i, { discount: num(e.target.value) })}
|
|
167
|
+
/>
|
|
168
|
+
{cell(i, 'discount') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'discount')}</p>}
|
|
169
|
+
</td>
|
|
170
|
+
)}
|
|
171
|
+
{show('tax') && (
|
|
172
|
+
<td className="px-2 py-1.5">
|
|
173
|
+
<Input
|
|
174
|
+
inputMode="decimal"
|
|
175
|
+
className="text-right"
|
|
176
|
+
value={String(Math.round(l.tax_rate * 10000) / 100)}
|
|
177
|
+
disabled={locked}
|
|
178
|
+
onChange={(e) => patch(i, { tax_rate: num(e.target.value) / 100 })}
|
|
179
|
+
/>
|
|
180
|
+
</td>
|
|
181
|
+
)}
|
|
182
|
+
{show('technician') && (
|
|
183
|
+
<td className="px-2 py-1.5">
|
|
184
|
+
<Input value={l.technician_id ?? ''} disabled={locked} onChange={(e) => patch(i, { technician_id: e.target.value })} />
|
|
185
|
+
</td>
|
|
186
|
+
)}
|
|
187
|
+
{show('lot') && (
|
|
188
|
+
<td className="px-2 py-1.5">
|
|
189
|
+
<Input value={l.lot ?? ''} disabled={locked} onChange={(e) => patch(i, { lot: e.target.value })} />
|
|
190
|
+
</td>
|
|
191
|
+
)}
|
|
192
|
+
{show('dot') && (
|
|
193
|
+
<td className="px-2 py-1.5">
|
|
194
|
+
<Input value={l.dot ?? ''} disabled={locked} onChange={(e) => patch(i, { dot: e.target.value })} />
|
|
195
|
+
</td>
|
|
196
|
+
)}
|
|
197
|
+
<td className="px-2 py-2 text-right tabular-nums">{fmt.money(a.net)}</td>
|
|
198
|
+
<td className="px-1 py-1.5">
|
|
199
|
+
{!locked && (
|
|
200
|
+
<Button type="button" size="icon" variant="ghost" onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
|
|
201
|
+
<Trash2 className="size-4" />
|
|
202
|
+
</Button>
|
|
203
|
+
)}
|
|
204
|
+
</td>
|
|
205
|
+
</tr>
|
|
206
|
+
)
|
|
207
|
+
})}
|
|
208
|
+
{value.length === 0 && (
|
|
209
|
+
<tr>
|
|
210
|
+
<td colSpan={99} className="px-3 py-6 text-center text-muted-foreground">
|
|
211
|
+
{t('lineItems.empty', { defaultValue: 'Aún no hay renglones. Agrega un producto o un renglón libre.' })}
|
|
212
|
+
</td>
|
|
213
|
+
</tr>
|
|
214
|
+
)}
|
|
215
|
+
</tbody>
|
|
216
|
+
</table>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{validation.form && value.length > 0 && <p role="alert" className="text-sm text-destructive">{validation.form}</p>}
|
|
220
|
+
|
|
221
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
222
|
+
{!locked ? (
|
|
223
|
+
<div className="flex flex-wrap gap-2">
|
|
224
|
+
{onRequestProduct && (
|
|
225
|
+
<Button type="button" size="sm" variant="outline" onClick={onRequestProduct}>
|
|
226
|
+
<Plus className="mr-1 size-4" />
|
|
227
|
+
{t('lineItems.addProduct', { defaultValue: 'Agregar producto' })}
|
|
228
|
+
</Button>
|
|
229
|
+
)}
|
|
230
|
+
<Button type="button" size="sm" variant="outline" onClick={() => emit([...value, makeLine()])}>
|
|
231
|
+
<Plus className="mr-1 size-4" />
|
|
232
|
+
{t('lineItems.addLine', { defaultValue: 'Renglón libre' })}
|
|
233
|
+
</Button>
|
|
234
|
+
{allowSections && (
|
|
235
|
+
<>
|
|
236
|
+
<Button type="button" size="sm" variant="ghost" onClick={() => emit([...value, makeLine({ kind: 'section' })])}>
|
|
237
|
+
<Heading className="mr-1 size-4" />
|
|
238
|
+
{t('lineItems.addSection', { defaultValue: 'Sección' })}
|
|
239
|
+
</Button>
|
|
240
|
+
<Button type="button" size="sm" variant="ghost" onClick={() => emit([...value, makeLine({ kind: 'note' })])}>
|
|
241
|
+
<StickyNote className="mr-1 size-4" />
|
|
242
|
+
{t('lineItems.addNote', { defaultValue: 'Nota' })}
|
|
243
|
+
</Button>
|
|
244
|
+
</>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
) : (
|
|
248
|
+
<span />
|
|
249
|
+
)}
|
|
250
|
+
<dl className="min-w-56 space-y-0.5 text-sm tabular-nums">
|
|
251
|
+
<div className="flex justify-between gap-6"><dt className="text-muted-foreground">{t('lineItems.subtotal', { defaultValue: 'Subtotal' })}</dt><dd>{fmt.money(totals.subtotal)}</dd></div>
|
|
252
|
+
{totals.discount > 0 && <div className="flex justify-between gap-6"><dt className="text-muted-foreground">{t('lineItems.discountTotal', { defaultValue: 'Descuento' })}</dt><dd>-{fmt.money(totals.discount)}</dd></div>}
|
|
253
|
+
{totals.tax > 0 && <div className="flex justify-between gap-6"><dt className="text-muted-foreground">{t('lineItems.taxTotal', { defaultValue: 'Impuestos' })}</dt><dd>{fmt.money(totals.tax)}</dd></div>}
|
|
254
|
+
<div className="flex justify-between gap-6 font-semibold"><dt>{t('lineItems.total', { defaultValue: 'Total' })}</dt><dd>{fmt.money(totals.total)}</dd></div>
|
|
255
|
+
</dl>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
}
|