@asteby/metacore-runtime-react 45.0.0 → 45.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/approval-events.d.ts +16 -0
  3. package/dist/approval-events.d.ts.map +1 -0
  4. package/dist/approval-events.js +27 -0
  5. package/dist/approval-gate.d.ts +39 -0
  6. package/dist/approval-gate.d.ts.map +1 -0
  7. package/dist/approval-gate.js +110 -0
  8. package/dist/approval-pin-dialog.d.ts +25 -0
  9. package/dist/approval-pin-dialog.d.ts.map +1 -0
  10. package/dist/approval-pin-dialog.js +66 -0
  11. package/dist/business/catalog.d.ts +16 -0
  12. package/dist/business/catalog.d.ts.map +1 -0
  13. package/dist/business/catalog.js +42 -0
  14. package/dist/business/customer-picker.d.ts +39 -0
  15. package/dist/business/customer-picker.d.ts.map +1 -0
  16. package/dist/business/customer-picker.js +51 -0
  17. package/dist/business/feedback.d.ts +72 -0
  18. package/dist/business/feedback.d.ts.map +1 -0
  19. package/dist/business/feedback.js +52 -0
  20. package/dist/business/field-errors.d.ts +26 -0
  21. package/dist/business/field-errors.d.ts.map +1 -0
  22. package/dist/business/field-errors.js +59 -0
  23. package/dist/business/format.d.ts +49 -0
  24. package/dist/business/format.d.ts.map +1 -0
  25. package/dist/business/format.js +128 -0
  26. package/dist/business/index.d.ts +14 -0
  27. package/dist/business/index.d.ts.map +1 -0
  28. package/dist/business/index.js +13 -0
  29. package/dist/business/line-items-editor.d.ts +25 -0
  30. package/dist/business/line-items-editor.d.ts.map +1 -0
  31. package/dist/business/line-items-editor.js +36 -0
  32. package/dist/business/line-items.d.ts +69 -0
  33. package/dist/business/line-items.d.ts.map +1 -0
  34. package/dist/business/line-items.js +133 -0
  35. package/dist/business/payment-capture.d.ts +21 -0
  36. package/dist/business/payment-capture.d.ts.map +1 -0
  37. package/dist/business/payment-capture.js +24 -0
  38. package/dist/business/payment.d.ts +61 -0
  39. package/dist/business/payment.d.ts.map +1 -0
  40. package/dist/business/payment.js +74 -0
  41. package/dist/business/product-picker.d.ts +24 -0
  42. package/dist/business/product-picker.d.ts.map +1 -0
  43. package/dist/business/product-picker.js +48 -0
  44. package/dist/business/product-search.d.ts +70 -0
  45. package/dist/business/product-search.d.ts.map +1 -0
  46. package/dist/business/product-search.js +51 -0
  47. package/dist/business/refund-destination.d.ts +19 -0
  48. package/dist/business/refund-destination.d.ts.map +1 -0
  49. package/dist/business/refund-destination.js +31 -0
  50. package/dist/business/refund.d.ts +29 -0
  51. package/dist/business/refund.d.ts.map +1 -0
  52. package/dist/business/refund.js +43 -0
  53. package/dist/business/use-async-search.d.ts +10 -0
  54. package/dist/business/use-async-search.d.ts.map +1 -0
  55. package/dist/business/use-async-search.js +38 -0
  56. package/dist/document/action-bar.d.ts +12 -0
  57. package/dist/document/action-bar.d.ts.map +1 -0
  58. package/dist/document/action-bar.js +39 -0
  59. package/dist/document/cancel-with-reason.d.ts +47 -0
  60. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  61. package/dist/document/cancel-with-reason.js +52 -0
  62. package/dist/document/document-header.d.ts +41 -0
  63. package/dist/document/document-header.d.ts.map +1 -0
  64. package/dist/document/document-header.js +30 -0
  65. package/dist/document/document-page.d.ts +37 -0
  66. package/dist/document/document-page.d.ts.map +1 -0
  67. package/dist/document/document-page.js +137 -0
  68. package/dist/document/document-timeline.d.ts +20 -0
  69. package/dist/document/document-timeline.d.ts.map +1 -0
  70. package/dist/document/document-timeline.js +26 -0
  71. package/dist/document/evaluate.d.ts +31 -0
  72. package/dist/document/evaluate.d.ts.map +1 -0
  73. package/dist/document/evaluate.js +170 -0
  74. package/dist/document/format.d.ts +9 -0
  75. package/dist/document/format.d.ts.map +1 -0
  76. package/dist/document/format.js +52 -0
  77. package/dist/document/index.d.ts +13 -0
  78. package/dist/document/index.d.ts.map +1 -0
  79. package/dist/document/index.js +12 -0
  80. package/dist/document/registry.d.ts +20 -0
  81. package/dist/document/registry.d.ts.map +1 -0
  82. package/dist/document/registry.js +20 -0
  83. package/dist/document/smart-buttons.d.ts +16 -0
  84. package/dist/document/smart-buttons.d.ts.map +1 -0
  85. package/dist/document/smart-buttons.js +16 -0
  86. package/dist/document/status-badge.d.ts +16 -0
  87. package/dist/document/status-badge.d.ts.map +1 -0
  88. package/dist/document/status-badge.js +23 -0
  89. package/dist/document/types.d.ts +196 -0
  90. package/dist/document/types.d.ts.map +1 -0
  91. package/dist/document/types.js +1 -0
  92. package/dist/document/use-document-sources.d.ts +14 -0
  93. package/dist/document/use-document-sources.d.ts.map +1 -0
  94. package/dist/document/use-document-sources.js +72 -0
  95. package/dist/index.d.ts +4 -0
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +5 -0
  98. package/dist/server-error.d.ts.map +1 -1
  99. package/dist/server-error.js +19 -0
  100. package/package.json +1 -1
  101. package/src/__tests__/approval-gate.test.tsx +134 -0
  102. package/src/approval-events.ts +39 -0
  103. package/src/approval-gate.tsx +180 -0
  104. package/src/approval-pin-dialog.tsx +162 -0
  105. package/src/business/business.test.ts +164 -0
  106. package/src/business/catalog.ts +60 -0
  107. package/src/business/customer-picker.tsx +201 -0
  108. package/src/business/feedback.tsx +141 -0
  109. package/src/business/field-errors.ts +92 -0
  110. package/src/business/format.ts +151 -0
  111. package/src/business/index.ts +13 -0
  112. package/src/business/line-items-editor.tsx +259 -0
  113. package/src/business/line-items.ts +190 -0
  114. package/src/business/payment-capture.tsx +158 -0
  115. package/src/business/payment.ts +125 -0
  116. package/src/business/product-picker.tsx +171 -0
  117. package/src/business/product-search.ts +108 -0
  118. package/src/business/refund-destination.tsx +121 -0
  119. package/src/business/refund.ts +71 -0
  120. package/src/business/use-async-search.ts +43 -0
  121. package/src/document/__tests__/document-page.test.tsx +107 -0
  122. package/src/document/__tests__/evaluate.test.ts +140 -0
  123. package/src/document/action-bar.tsx +140 -0
  124. package/src/document/cancel-with-reason.tsx +214 -0
  125. package/src/document/document-header.tsx +140 -0
  126. package/src/document/document-page.tsx +268 -0
  127. package/src/document/document-timeline.tsx +66 -0
  128. package/src/document/evaluate.ts +180 -0
  129. package/src/document/format.ts +59 -0
  130. package/src/document/index.ts +28 -0
  131. package/src/document/registry.ts +39 -0
  132. package/src/document/smart-buttons.tsx +54 -0
  133. package/src/document/status-badge.tsx +44 -0
  134. package/src/document/types.ts +203 -0
  135. package/src/document/use-document-sources.ts +91 -0
  136. package/src/index.ts +23 -0
  137. package/src/server-error.ts +20 -0
@@ -0,0 +1,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
+ }