@asteby/metacore-runtime-react 46.4.0 → 47.1.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 (123) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/action-modal-dispatcher.d.ts +10 -1
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +107 -19
  5. package/dist/audit-info.d.ts +54 -0
  6. package/dist/audit-info.d.ts.map +1 -0
  7. package/dist/audit-info.js +142 -0
  8. package/dist/business/catalog.d.ts.map +1 -1
  9. package/dist/business/catalog.js +24 -0
  10. package/dist/business/index.d.ts +3 -0
  11. package/dist/business/index.d.ts.map +1 -1
  12. package/dist/business/index.js +3 -0
  13. package/dist/business/print-send-dialog.d.ts +27 -0
  14. package/dist/business/print-send-dialog.d.ts.map +1 -0
  15. package/dist/business/print-send-dialog.js +55 -0
  16. package/dist/business/relate-documents.d.ts +41 -0
  17. package/dist/business/relate-documents.d.ts.map +1 -0
  18. package/dist/business/relate-documents.js +42 -0
  19. package/dist/business/vehicle-picker.d.ts +36 -0
  20. package/dist/business/vehicle-picker.d.ts.map +1 -0
  21. package/dist/business/vehicle-picker.js +61 -0
  22. package/dist/cfdi-stamp-panel.d.ts +29 -0
  23. package/dist/cfdi-stamp-panel.d.ts.map +1 -0
  24. package/dist/cfdi-stamp-panel.js +53 -0
  25. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  26. package/dist/dialogs/dynamic-record.js +10 -1
  27. package/dist/document-form-dialog.d.ts +23 -0
  28. package/dist/document-form-dialog.d.ts.map +1 -0
  29. package/dist/document-form-dialog.js +196 -0
  30. package/dist/dynamic-crud-page.d.ts +6 -0
  31. package/dist/dynamic-crud-page.d.ts.map +1 -1
  32. package/dist/dynamic-crud-page.js +4 -2
  33. package/dist/dynamic-form.d.ts.map +1 -1
  34. package/dist/dynamic-form.js +3 -0
  35. package/dist/dynamic-kanban.d.ts.map +1 -1
  36. package/dist/dynamic-kanban.js +11 -2
  37. package/dist/dynamic-line-items.d.ts.map +1 -1
  38. package/dist/dynamic-line-items.js +3 -0
  39. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  40. package/dist/dynamic-multi-select-field.js +2 -0
  41. package/dist/dynamic-select-field.d.ts.map +1 -1
  42. package/dist/dynamic-select-field.js +4 -0
  43. package/dist/dynamic-table.d.ts.map +1 -1
  44. package/dist/dynamic-table.js +6 -2
  45. package/dist/index.d.ts +8 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -0
  48. package/dist/nav-permissions.d.ts +19 -0
  49. package/dist/nav-permissions.d.ts.map +1 -0
  50. package/dist/nav-permissions.js +51 -0
  51. package/dist/navigation-builder.d.ts +6 -0
  52. package/dist/navigation-builder.d.ts.map +1 -1
  53. package/dist/navigation-builder.js +26 -1
  54. package/dist/option-filter.d.ts +31 -0
  55. package/dist/option-filter.d.ts.map +1 -0
  56. package/dist/option-filter.js +49 -0
  57. package/dist/permissions-manager.d.ts +9 -0
  58. package/dist/permissions-manager.d.ts.map +1 -1
  59. package/dist/permissions-manager.js +50 -17
  60. package/dist/print-document-button.d.ts.map +1 -1
  61. package/dist/print-document-button.js +9 -1
  62. package/dist/record-mutation-events.d.ts +14 -0
  63. package/dist/record-mutation-events.d.ts.map +1 -0
  64. package/dist/record-mutation-events.js +37 -0
  65. package/dist/types.d.ts +86 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/use-options-resolver.d.ts +8 -0
  68. package/dist/use-options-resolver.d.ts.map +1 -1
  69. package/dist/use-options-resolver.js +8 -2
  70. package/dist/use-print-document.d.ts +15 -1
  71. package/dist/use-print-document.d.ts.map +1 -1
  72. package/dist/use-print-document.js +52 -1
  73. package/dist/use-record-mutation-tick.d.ts +6 -0
  74. package/dist/use-record-mutation-tick.d.ts.map +1 -0
  75. package/dist/use-record-mutation-tick.js +11 -0
  76. package/package.json +3 -3
  77. package/src/__tests__/action-modal-feedback.test.tsx +116 -0
  78. package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
  79. package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
  80. package/src/__tests__/audit-info.test.tsx +103 -0
  81. package/src/__tests__/business-pickers.test.tsx +157 -0
  82. package/src/__tests__/dependent-options.test.tsx +12 -5
  83. package/src/__tests__/document-form-dialog.test.tsx +190 -0
  84. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
  85. package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
  86. package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
  87. package/src/__tests__/nav-permissions.test.tsx +76 -0
  88. package/src/__tests__/option-filter.test.tsx +115 -0
  89. package/src/__tests__/permissions-manager.test.tsx +21 -4
  90. package/src/__tests__/structured-view-value.test.tsx +5 -1
  91. package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
  92. package/src/__tests__/wizard-action-modal.test.tsx +1 -1
  93. package/src/action-modal-dispatcher.tsx +124 -6
  94. package/src/audit-info.tsx +219 -0
  95. package/src/business/catalog.ts +24 -0
  96. package/src/business/index.ts +3 -0
  97. package/src/business/print-send-dialog.tsx +175 -0
  98. package/src/business/relate-documents.tsx +182 -0
  99. package/src/business/vehicle-picker.tsx +188 -0
  100. package/src/cfdi-stamp-panel.tsx +146 -0
  101. package/src/dialogs/dynamic-record.tsx +18 -0
  102. package/src/document-form-dialog.tsx +357 -0
  103. package/src/dropdown-menu-entry.test.ts +23 -0
  104. package/src/dynamic-crud-page.tsx +21 -2
  105. package/src/dynamic-form.tsx +3 -0
  106. package/src/dynamic-kanban.tsx +10 -4
  107. package/src/dynamic-line-items.tsx +3 -0
  108. package/src/dynamic-multi-select-field.tsx +2 -0
  109. package/src/dynamic-select-field.tsx +4 -0
  110. package/src/dynamic-table.tsx +6 -2
  111. package/src/index.ts +41 -0
  112. package/src/nav-permissions.ts +58 -0
  113. package/src/navigation-builder.tsx +27 -1
  114. package/src/option-filter.ts +88 -0
  115. package/src/permissions-manager.tsx +39 -26
  116. package/src/print-document-button.tsx +9 -1
  117. package/src/record-mutation-events.ts +50 -0
  118. package/src/types.ts +90 -0
  119. package/src/use-options-resolver.ts +19 -1
  120. package/src/use-print-document.ts +66 -3
  121. package/src/use-record-mutation-tick.ts +12 -0
  122. package/vitest.config.ts +1 -0
  123. package/vitest.setup.ts +7 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * audit-info.tsx
3
+ *
4
+ * <AuditInfo> — discreet "when and who" footer for a record: created, modified
5
+ * and (if tombstoned) deleted, each with the actor and a relative + absolute
6
+ * date. Driven only by the kernel's `TableMetadata.audit` (column names) and
7
+ * the record row — manifests declare nothing.
8
+ *
9
+ * Actor resolution (no extra requests), in order:
10
+ * 1. the host's `resolveActor(id)` when it returns a name;
11
+ * 2. the record's expanded sibling (`created_by_id` → `row.created_by`,
12
+ * `{ label | name | title }` or a plain string);
13
+ * 3. the system actor id → "Sistema" / "System";
14
+ * 4. a short id (first 8 chars) — never a long uuid.
15
+ *
16
+ * Renders nothing when `audit` is absent or the record has none of its values.
17
+ */
18
+ import * as React from 'react'
19
+ import { useTranslation } from 'react-i18next'
20
+ import { formatDistanceToNow } from 'date-fns'
21
+ import { es, enUS } from 'date-fns/locale'
22
+ import { ChevronDown, ChevronRight, History } from 'lucide-react'
23
+ import { cn } from '@asteby/metacore-ui/lib'
24
+ import { formatDateCell } from './dynamic-columns'
25
+ import { objectLabel } from './dynamic-relation-helpers'
26
+ import { isNilUuid } from './nil-uuid'
27
+ import { useTimeZone } from './org-runtime-context'
28
+ import type { AuditMeta } from './types'
29
+
30
+ /** The kernel's actor for unattended work (schedules, webhooks, connectors). */
31
+ export const SYSTEM_ACTOR_ID = '00000000-0000-0000-0000-000000000001'
32
+
33
+ const AUDIT_KEYS: (keyof AuditMeta)[] = [
34
+ 'created_at',
35
+ 'created_by',
36
+ 'updated_at',
37
+ 'updated_by',
38
+ 'deleted_at',
39
+ 'deleted_by',
40
+ ]
41
+
42
+ /**
43
+ * Defensive reader for the served `audit` block: keeps only known keys whose
44
+ * value is a non-empty string; returns `undefined` when nothing usable remains.
45
+ */
46
+ export function readAuditMeta(raw: unknown): AuditMeta | undefined {
47
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
48
+ const src = raw as Record<string, unknown>
49
+ const out: AuditMeta = {}
50
+ for (const k of AUDIT_KEYS) {
51
+ const v = src[k]
52
+ if (typeof v === 'string' && v.trim() !== '') out[k] = v
53
+ }
54
+ return Object.keys(out).length ? out : undefined
55
+ }
56
+
57
+ export interface AuditActor {
58
+ /** Display text. */
59
+ name: string
60
+ /** True when the name is a real resolved label (not a short id). */
61
+ resolved: boolean
62
+ }
63
+
64
+ /** Resolves the actor stored in `row[idColumn]` to something human. */
65
+ export function resolveAuditActor(
66
+ row: Record<string, unknown>,
67
+ idColumn: string | undefined,
68
+ opts: { resolveActor?: (id: string) => string | undefined; systemLabel: string },
69
+ ): AuditActor | null {
70
+ if (!idColumn) return null
71
+ const raw = row[idColumn]
72
+ // The column itself may already be an expanded object.
73
+ const own = objectLabel(raw)
74
+ const id = typeof raw === 'string' ? raw.trim() : ''
75
+ if (!own && (!id || isNilUuid(id))) return null
76
+ if (id.toLowerCase() === SYSTEM_ACTOR_ID) return { name: opts.systemLabel, resolved: true }
77
+ if (id) {
78
+ const fromHost = opts.resolveActor?.(id)
79
+ if (fromHost) return { name: fromHost, resolved: true }
80
+ }
81
+ if (own) return { name: own, resolved: true }
82
+ const base = idColumn.endsWith('_id') ? idColumn.slice(0, -3) : idColumn
83
+ const sibling = base === idColumn ? undefined : row[base]
84
+ const siblingName =
85
+ objectLabel(sibling) ?? (typeof sibling === 'string' && sibling.trim() !== '' ? sibling : undefined)
86
+ if (siblingName) return { name: siblingName, resolved: true }
87
+ return { name: id.length > 8 ? id.slice(0, 8) : id, resolved: false }
88
+ }
89
+
90
+ const COPY = {
91
+ es: {
92
+ title: 'Historial del registro',
93
+ created: 'Creado por {{who}}',
94
+ updated: 'Modificado por {{who}}',
95
+ deleted: 'Eliminado por {{who}}',
96
+ createdAt: 'Creado',
97
+ updatedAt: 'Modificado',
98
+ deletedAt: 'Eliminado',
99
+ system: 'Sistema',
100
+ },
101
+ en: {
102
+ title: 'Record history',
103
+ created: 'Created by {{who}}',
104
+ updated: 'Modified by {{who}}',
105
+ deleted: 'Deleted by {{who}}',
106
+ createdAt: 'Created',
107
+ updatedAt: 'Modified',
108
+ deletedAt: 'Deleted',
109
+ system: 'System',
110
+ },
111
+ } as const
112
+
113
+ export interface AuditInfoProps {
114
+ /** The record row (keys are the column names `audit` points at). */
115
+ record: Record<string, unknown> | null | undefined
116
+ /** `TableMetadata.audit`. Absent → renders nothing. */
117
+ audit: AuditMeta | null | undefined
118
+ /** Optional host resolver uuid → display name (e.g. from a users cache). */
119
+ resolveActor?: (id: string) => string | undefined
120
+ /** IANA timezone; defaults to the org runtime timezone. */
121
+ timeZone?: string
122
+ /** BCP-47 locale; defaults to the i18n language. */
123
+ locale?: string
124
+ /** Start expanded. Default: collapsed. */
125
+ defaultOpen?: boolean
126
+ className?: string
127
+ }
128
+
129
+ interface Line {
130
+ key: string
131
+ text: string
132
+ absolute: string
133
+ title?: string
134
+ relative: string
135
+ }
136
+
137
+ export function AuditInfo({
138
+ record,
139
+ audit,
140
+ resolveActor,
141
+ timeZone,
142
+ locale,
143
+ defaultOpen = false,
144
+ className,
145
+ }: AuditInfoProps) {
146
+ const { t, i18n } = useTranslation()
147
+ const ctxTz = useTimeZone()
148
+ const [open, setOpen] = React.useState(defaultOpen)
149
+
150
+ const meta = readAuditMeta(audit)
151
+ if (!meta || !record) return null
152
+
153
+ const lang = (locale || i18n?.language || 'es').toLowerCase().startsWith('en') ? 'en' : 'es'
154
+ const copy = COPY[lang]
155
+ const dfLocale = lang === 'en' ? enUS : es
156
+ const tz = timeZone ?? ctxTz
157
+ const tt = (key: keyof typeof copy, vars?: Record<string, string>) =>
158
+ t(`audit.${key}`, { defaultValue: copy[key], ...vars }) as string
159
+
160
+ const build = (
161
+ key: string,
162
+ atCol: string | undefined,
163
+ byCol: string | undefined,
164
+ whoKey: 'created' | 'updated' | 'deleted',
165
+ whenKey: 'createdAt' | 'updatedAt' | 'deletedAt',
166
+ ): Line | null => {
167
+ const f = atCol ? formatDateCell(record[atCol], undefined, dfLocale, tz) : null
168
+ if (!f) return null
169
+ const actor = resolveAuditActor(record, byCol, { resolveActor, systemLabel: tt('system') })
170
+ const date = new Date(record[atCol as string] as string)
171
+ const relative = formatDistanceToNow(date, { addSuffix: true, locale: dfLocale })
172
+ return {
173
+ key,
174
+ text: actor ? tt(whoKey, { who: actor.name }) : tt(whenKey),
175
+ absolute: f.display,
176
+ title: f.title,
177
+ relative,
178
+ }
179
+ }
180
+
181
+ const lines = [
182
+ build('created', meta.created_at, meta.created_by, 'created', 'createdAt'),
183
+ // Hide "modified" when it is the creation instant itself (never edited).
184
+ meta.updated_at && record[meta.updated_at] !== record[meta.created_at ?? '']
185
+ ? build('updated', meta.updated_at, meta.updated_by, 'updated', 'updatedAt')
186
+ : null,
187
+ build('deleted', meta.deleted_at, meta.deleted_by, 'deleted', 'deletedAt'),
188
+ ].filter((l): l is Line => l !== null)
189
+
190
+ if (lines.length === 0) return null
191
+
192
+ const Chevron = open ? ChevronDown : ChevronRight
193
+ return (
194
+ <div className={cn('text-xs text-muted-foreground', className)} data-testid="audit-info">
195
+ <button
196
+ type="button"
197
+ className="flex items-center gap-1.5 hover:text-foreground"
198
+ aria-expanded={open}
199
+ onClick={() => setOpen(o => !o)}
200
+ >
201
+ <Chevron className="h-3.5 w-3.5" />
202
+ <History className="h-3.5 w-3.5" />
203
+ <span>{tt('title')}</span>
204
+ </button>
205
+ {open && (
206
+ <ul className="mt-2 space-y-1 pl-6">
207
+ {lines.map(l => (
208
+ <li key={l.key} data-audit-line={l.key}>
209
+ <span>{l.text}</span>
210
+ {' · '}
211
+ <time title={l.title}>{l.absolute}</time>
212
+ <span className="opacity-70"> ({l.relative})</span>
213
+ </li>
214
+ ))}
215
+ </ul>
216
+ )}
217
+ </div>
218
+ )
219
+ }
@@ -94,4 +94,28 @@ export const BUSINESS_COMPONENTS: readonly BusinessComponentSpec[] = [
94
94
  'recommendationsToLines',
95
95
  ],
96
96
  },
97
+ {
98
+ name: 'VehiclePicker',
99
+ value: 'value: VehicleResult | null',
100
+ events: ['onChange'],
101
+ permissions: ['<model>.create (alta rápida; default Vehicle → vehicle.create)'],
102
+ usedBy: ['POS', 'Recepción de vehículo', 'OT', 'Cotización', 'Pedido'],
103
+ helpers: ['describeVehicle'],
104
+ },
105
+ {
106
+ name: 'RelateDocuments',
107
+ value: 'value: RelatedDocument[] (related_document_id + relation_type)',
108
+ events: ['onChange'],
109
+ permissions: [],
110
+ usedBy: ['Nota de crédito', 'Devolución', 'Sustitución de CFDI', 'REP'],
111
+ helpers: ['serializeRelatedDocuments'],
112
+ },
113
+ {
114
+ name: 'PrintSendDialog',
115
+ value: 'document: PrintableDocument (+ open)',
116
+ events: ['onSend', 'onPrint', 'onOpenChange'],
117
+ permissions: [],
118
+ usedBy: ['Cotización', 'Factura', 'Nota de crédito', 'Pedido', 'OC'],
119
+ helpers: [],
120
+ },
97
121
  ] as const
@@ -16,3 +16,6 @@ export * from './return'
16
16
  export * from './return-wizard'
17
17
  export * from './inspection'
18
18
  export * from './inspection-checklist'
19
+ export * from './vehicle-picker'
20
+ export * from './relate-documents'
21
+ export * from './print-send-dialog'
@@ -0,0 +1,175 @@
1
+ // PrintSendDialog — imprimir o enviar un documento ya emitido (cotización,
2
+ // factura, nota de crédito…). El SDK NO llama a un proveedor de mensajería: el
3
+ // host recibe `onSend(channel, destination, document)` y decide qué hacer.
4
+ // Imprimir usa la URL del PDF que pasa el host (o su propio `onPrint`).
5
+ import { useEffect, useState } from 'react'
6
+ import { useTranslation } from 'react-i18next'
7
+ import { Mail, MessageCircle, Printer, Loader2 } from 'lucide-react'
8
+ import {
9
+ Button,
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ Input,
17
+ } from '@asteby/metacore-ui/primitives'
18
+ import { FormErrorBanner } from './feedback'
19
+
20
+ export type SendChannel = 'email' | 'whatsapp'
21
+
22
+ export interface PrintableDocument {
23
+ folio: string
24
+ /** URL del PDF ya generado. Sin ella no hay «Imprimir». */
25
+ pdfUrl?: string
26
+ /** Texto de cabecera («Factura», «Cotización»). */
27
+ title?: string
28
+ /** Destinos sugeridos (datos del cliente). */
29
+ email?: string
30
+ phone?: string
31
+ }
32
+
33
+ export interface PrintSendDialogProps {
34
+ open: boolean
35
+ onOpenChange: (open: boolean) => void
36
+ document: PrintableDocument
37
+ /** Canales de envío disponibles. Default: ambos. Vacío = solo imprimir. */
38
+ channels?: SendChannel[]
39
+ /**
40
+ * Envío a cargo del host. Si rechaza, el mensaje se muestra en el diálogo.
41
+ * Sin `onSend` no se ofrece enviar.
42
+ */
43
+ onSend?: (channel: SendChannel, destination: string, document: PrintableDocument) => Promise<void> | void
44
+ /** Reemplaza la impresión por defecto (abrir `pdfUrl` en otra pestaña). */
45
+ onPrint?: (document: PrintableDocument) => void
46
+ }
47
+
48
+ function defaultPrint(doc: PrintableDocument): void {
49
+ if (!doc.pdfUrl || typeof window === 'undefined') return
50
+ window.open(doc.pdfUrl, '_blank', 'noopener,noreferrer')
51
+ }
52
+
53
+ export function PrintSendDialog({
54
+ open,
55
+ onOpenChange,
56
+ document: doc,
57
+ channels = ['email', 'whatsapp'],
58
+ onSend,
59
+ onPrint,
60
+ }: PrintSendDialogProps) {
61
+ const { t } = useTranslation()
62
+ const [channel, setChannel] = useState<SendChannel>(channels[0] ?? 'email')
63
+ const [destination, setDestination] = useState('')
64
+ const [sending, setSending] = useState(false)
65
+ const [sent, setSent] = useState(false)
66
+ const [error, setError] = useState<string | undefined>()
67
+
68
+ const suggested = channel === 'email' ? doc.email : doc.phone
69
+ useEffect(() => {
70
+ if (open) {
71
+ setDestination(suggested ?? '')
72
+ setSent(false)
73
+ setError(undefined)
74
+ }
75
+ }, [open, channel, suggested])
76
+
77
+ const canSend = !!onSend && channels.length > 0
78
+ const send = async () => {
79
+ const dest = destination.trim()
80
+ if (!onSend || !dest) return
81
+ setSending(true)
82
+ setError(undefined)
83
+ setSent(false)
84
+ try {
85
+ await onSend(channel, dest, doc)
86
+ setSent(true)
87
+ } catch (e) {
88
+ const msg = (e as { message?: unknown } | undefined)?.message
89
+ setError(
90
+ typeof msg === 'string' && msg
91
+ ? msg
92
+ : t('printSend.error', { defaultValue: 'No se pudo enviar el documento. Inténtalo de nuevo.' }),
93
+ )
94
+ } finally {
95
+ setSending(false)
96
+ }
97
+ }
98
+
99
+ const channelLabel = (c: SendChannel) =>
100
+ c === 'email' ? t('printSend.email', { defaultValue: 'Correo' }) : t('printSend.whatsapp', { defaultValue: 'WhatsApp' })
101
+
102
+ return (
103
+ <Dialog open={open} onOpenChange={onOpenChange}>
104
+ <DialogContent className="sm:max-w-md">
105
+ <DialogHeader>
106
+ <DialogTitle>
107
+ {t('printSend.title', { defaultValue: 'Imprimir o enviar' })}
108
+ {doc.title ? ` · ${doc.title}` : ''}
109
+ </DialogTitle>
110
+ <DialogDescription>{doc.folio}</DialogDescription>
111
+ </DialogHeader>
112
+
113
+ <div className="space-y-4">
114
+ <Button
115
+ type="button"
116
+ variant="outline"
117
+ className="w-full justify-start"
118
+ disabled={!doc.pdfUrl && !onPrint}
119
+ onClick={() => (onPrint ?? defaultPrint)(doc)}
120
+ >
121
+ <Printer className="mr-2 size-4" aria-hidden />
122
+ {t('printSend.print', { defaultValue: 'Imprimir' })}
123
+ </Button>
124
+
125
+ {canSend && (
126
+ <div className="space-y-2">
127
+ {channels.length > 1 && (
128
+ <div className="flex gap-2" role="group" aria-label={t('printSend.channel', { defaultValue: 'Canal de envío' })}>
129
+ {channels.map((c) => (
130
+ <Button
131
+ key={c}
132
+ type="button"
133
+ size="sm"
134
+ variant={c === channel ? 'default' : 'outline'}
135
+ aria-pressed={c === channel}
136
+ onClick={() => setChannel(c)}
137
+ >
138
+ {c === 'email' ? <Mail className="mr-1.5 size-4" aria-hidden /> : <MessageCircle className="mr-1.5 size-4" aria-hidden />}
139
+ {channelLabel(c)}
140
+ </Button>
141
+ ))}
142
+ </div>
143
+ )}
144
+ <Input
145
+ value={destination}
146
+ type={channel === 'email' ? 'email' : 'tel'}
147
+ aria-label={t('printSend.destination', { defaultValue: 'Destino' })}
148
+ placeholder={channel === 'email' ? 'cliente@ejemplo.com' : '+52 55 0000 0000'}
149
+ onChange={(e) => setDestination(e.target.value)}
150
+ />
151
+ <FormErrorBanner message={error} />
152
+ {sent && (
153
+ <p role="status" className="text-sm text-emerald-600 dark:text-emerald-400">
154
+ {t('printSend.sent', { defaultValue: 'Enviado.' })}
155
+ </p>
156
+ )}
157
+ </div>
158
+ )}
159
+ </div>
160
+
161
+ <DialogFooter>
162
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
163
+ {t('common.close', { defaultValue: 'Cerrar' })}
164
+ </Button>
165
+ {canSend && (
166
+ <Button type="button" onClick={send} disabled={sending || !destination.trim()}>
167
+ {sending && <Loader2 className="mr-2 size-4 animate-spin" aria-hidden />}
168
+ {t('printSend.send', { defaultValue: 'Enviar' })}
169
+ </Button>
170
+ )}
171
+ </DialogFooter>
172
+ </DialogContent>
173
+ </Dialog>
174
+ )
175
+ }
@@ -0,0 +1,182 @@
1
+ // RelateDocuments — relaciona uno o más documentos origen (CFDI) desde un
2
+ // formulario: nota de crédito, devolución, sustitución. El valor es DATO
3
+ // (`related_document_id`, `relation_type`, UUID/folio para mostrar), no un flujo
4
+ // de un solo módulo. El SDK no embebe catálogos fiscales: los tipos de relación
5
+ // (p. ej. SAT 01/03/04) llegan por la prop `relationTypes`.
6
+ import { useState } from 'react'
7
+ import { useTranslation } from 'react-i18next'
8
+ import { Link2, Search, X } from 'lucide-react'
9
+ import { Badge, Button, Input } from '@asteby/metacore-ui'
10
+ import { useAsyncSearch } from './use-async-search'
11
+
12
+ export interface RelatableDocument {
13
+ id: string
14
+ /** UUID fiscal (CFDI), cuando ya está timbrado. */
15
+ uuid?: string
16
+ folio?: string
17
+ /** Texto libre para mostrar («Factura A-120 · Cliente X»). */
18
+ label?: string
19
+ total?: number
20
+ }
21
+
22
+ export interface RelationTypeOption {
23
+ /** Clave que viaja en `relation_type` (p. ej. `"01"`). */
24
+ value: string
25
+ label: string
26
+ }
27
+
28
+ /** Un documento relacionado: lo que el formulario guarda. */
29
+ export interface RelatedDocument {
30
+ related_document_id: string
31
+ relation_type: string
32
+ /** Solo para mostrar. */
33
+ uuid?: string
34
+ folio?: string
35
+ label?: string
36
+ }
37
+
38
+ export interface RelateDocumentsProps {
39
+ value: RelatedDocument[]
40
+ onChange: (value: RelatedDocument[]) => void
41
+ /** Catálogo de tipos de relación (lo aporta el addon). */
42
+ relationTypes: RelationTypeOption[]
43
+ /** Búsqueda de documentos origen. Estable (useCallback). */
44
+ search: (q: string, signal: AbortSignal) => Promise<RelatableDocument[]>
45
+ /** Tipo preseleccionado al agregar. Default: el primero del catálogo. */
46
+ defaultRelationType?: string
47
+ /** Máximo de documentos relacionados. Sin tope por defecto. */
48
+ max?: number
49
+ disabled?: boolean
50
+ placeholder?: string
51
+ }
52
+
53
+ function docTitle(d: { folio?: string; label?: string; uuid?: string; id?: string }): string {
54
+ return d.label || d.folio || d.uuid || d.id || ''
55
+ }
56
+
57
+ export function RelateDocuments({
58
+ value,
59
+ onChange,
60
+ relationTypes,
61
+ search,
62
+ defaultRelationType,
63
+ max,
64
+ disabled,
65
+ placeholder,
66
+ }: RelateDocumentsProps) {
67
+ const { t } = useTranslation()
68
+ const [text, setText] = useState('')
69
+ const [pendingType, setPendingType] = useState<string>(defaultRelationType ?? relationTypes[0]?.value ?? '')
70
+ const { results, loading, error } = useAsyncSearch(text, search, { minChars: 2 })
71
+ const full = max != null && value.length >= max
72
+ const typeLabel = (v: string) => relationTypes.find((r) => r.value === v)?.label ?? v
73
+
74
+ const add = (d: RelatableDocument) => {
75
+ if (full || value.some((x) => x.related_document_id === d.id)) return
76
+ onChange([
77
+ ...value,
78
+ { related_document_id: d.id, relation_type: pendingType, uuid: d.uuid, folio: d.folio, label: d.label },
79
+ ])
80
+ setText('')
81
+ }
82
+ const remove = (id: string) => onChange(value.filter((x) => x.related_document_id !== id))
83
+ const setType = (id: string, relation_type: string) =>
84
+ onChange(value.map((x) => (x.related_document_id === id ? { ...x, relation_type } : x)))
85
+
86
+ const selectCls = 'h-9 rounded-md border bg-background px-2 text-sm'
87
+
88
+ return (
89
+ <div data-slot="relate-documents" className="space-y-2">
90
+ {value.length > 0 && (
91
+ <ul className="divide-y rounded-md border">
92
+ {value.map((d) => (
93
+ <li key={d.related_document_id} className="flex flex-wrap items-center gap-2 px-2 py-1.5">
94
+ <Link2 className="size-4 text-muted-foreground" aria-hidden />
95
+ <div className="min-w-0 flex-1">
96
+ <p className="truncate text-sm font-medium">{docTitle({ folio: d.folio, label: d.label, id: d.related_document_id })}</p>
97
+ {d.uuid && <p className="break-all font-mono text-xs text-muted-foreground">{d.uuid}</p>}
98
+ </div>
99
+ <select
100
+ className={selectCls}
101
+ disabled={disabled}
102
+ value={d.relation_type}
103
+ aria-label={t('relateDocuments.type', { defaultValue: 'Tipo de relación' })}
104
+ onChange={(e) => setType(d.related_document_id, e.target.value)}
105
+ >
106
+ {!relationTypes.some((r) => r.value === d.relation_type) && (
107
+ <option value={d.relation_type}>{typeLabel(d.relation_type)}</option>
108
+ )}
109
+ {relationTypes.map((r) => (
110
+ <option key={r.value} value={r.value}>{r.label}</option>
111
+ ))}
112
+ </select>
113
+ {!disabled && (
114
+ <Button type="button" size="icon" variant="ghost" onClick={() => remove(d.related_document_id)} aria-label={t('relateDocuments.remove', { defaultValue: 'Quitar relación' })}>
115
+ <X className="size-4" />
116
+ </Button>
117
+ )}
118
+ </li>
119
+ ))}
120
+ </ul>
121
+ )}
122
+
123
+ {!full && !disabled && (
124
+ <div className="space-y-2">
125
+ <div className="flex items-center gap-1.5">
126
+ <div className="relative flex-1">
127
+ <Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
128
+ <Input
129
+ className="pl-8"
130
+ value={text}
131
+ placeholder={placeholder ?? t('relateDocuments.placeholder', { defaultValue: 'Buscar por folio o UUID' })}
132
+ onChange={(e) => setText(e.target.value)}
133
+ />
134
+ </div>
135
+ <select
136
+ className={selectCls}
137
+ value={pendingType}
138
+ aria-label={t('relateDocuments.newType', { defaultValue: 'Tipo para el documento nuevo' })}
139
+ onChange={(e) => setPendingType(e.target.value)}
140
+ >
141
+ {relationTypes.map((r) => (
142
+ <option key={r.value} value={r.value}>{r.label}</option>
143
+ ))}
144
+ </select>
145
+ </div>
146
+ {loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
147
+ {error && !loading && (
148
+ <p role="alert" className="text-sm text-destructive">
149
+ {t('relateDocuments.error', { defaultValue: 'No se pudo buscar documentos. Inténtalo de nuevo.' })}
150
+ </p>
151
+ )}
152
+ {!loading && !error && text.trim().length >= 2 && results.length === 0 && (
153
+ <p className="text-sm text-muted-foreground">{t('relateDocuments.empty', { defaultValue: 'No encontramos ese documento.' })}</p>
154
+ )}
155
+ {results.length > 0 && (
156
+ <ul role="listbox" className="max-h-64 divide-y overflow-auto rounded-md border">
157
+ {results.map((d) => {
158
+ const already = value.some((x) => x.related_document_id === d.id)
159
+ return (
160
+ <li key={d.id} role="option" aria-selected={already}>
161
+ <button type="button" disabled={already} className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-accent disabled:opacity-50" onClick={() => add(d)}>
162
+ <span>
163
+ <span className="block text-sm font-medium">{docTitle(d)}</span>
164
+ {d.uuid && <span className="block break-all font-mono text-xs text-muted-foreground">{d.uuid}</span>}
165
+ </span>
166
+ {already && <Badge variant="muted">{t('relateDocuments.added', { defaultValue: 'Agregado' })}</Badge>}
167
+ </button>
168
+ </li>
169
+ )
170
+ })}
171
+ </ul>
172
+ )}
173
+ </div>
174
+ )}
175
+ </div>
176
+ )
177
+ }
178
+
179
+ /** Valor a enviar al servidor: solo los datos, sin los campos de visualización. */
180
+ export function serializeRelatedDocuments(value: RelatedDocument[]): Array<Pick<RelatedDocument, 'related_document_id' | 'relation_type'>> {
181
+ return value.map(({ related_document_id, relation_type }) => ({ related_document_id, relation_type }))
182
+ }