@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.
- package/CHANGELOG.md +38 -0
- package/dist/action-modal-dispatcher.d.ts +10 -1
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +107 -19
- package/dist/audit-info.d.ts +54 -0
- package/dist/audit-info.d.ts.map +1 -0
- package/dist/audit-info.js +142 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +24 -0
- package/dist/business/index.d.ts +3 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +3 -0
- package/dist/business/print-send-dialog.d.ts +27 -0
- package/dist/business/print-send-dialog.d.ts.map +1 -0
- package/dist/business/print-send-dialog.js +55 -0
- package/dist/business/relate-documents.d.ts +41 -0
- package/dist/business/relate-documents.d.ts.map +1 -0
- package/dist/business/relate-documents.js +42 -0
- package/dist/business/vehicle-picker.d.ts +36 -0
- package/dist/business/vehicle-picker.d.ts.map +1 -0
- package/dist/business/vehicle-picker.js +61 -0
- package/dist/cfdi-stamp-panel.d.ts +29 -0
- package/dist/cfdi-stamp-panel.d.ts.map +1 -0
- package/dist/cfdi-stamp-panel.js +53 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +10 -1
- package/dist/document-form-dialog.d.ts +23 -0
- package/dist/document-form-dialog.d.ts.map +1 -0
- package/dist/document-form-dialog.js +196 -0
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +4 -2
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +3 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +11 -2
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +3 -0
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +2 -0
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +4 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +6 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/nav-permissions.d.ts +19 -0
- package/dist/nav-permissions.d.ts.map +1 -0
- package/dist/nav-permissions.js +51 -0
- package/dist/navigation-builder.d.ts +6 -0
- package/dist/navigation-builder.d.ts.map +1 -1
- package/dist/navigation-builder.js +26 -1
- package/dist/option-filter.d.ts +31 -0
- package/dist/option-filter.d.ts.map +1 -0
- package/dist/option-filter.js +49 -0
- package/dist/permissions-manager.d.ts +9 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +50 -17
- package/dist/print-document-button.d.ts.map +1 -1
- package/dist/print-document-button.js +9 -1
- package/dist/record-mutation-events.d.ts +14 -0
- package/dist/record-mutation-events.d.ts.map +1 -0
- package/dist/record-mutation-events.js +37 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +8 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +8 -2
- package/dist/use-print-document.d.ts +15 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +52 -1
- package/dist/use-record-mutation-tick.d.ts +6 -0
- package/dist/use-record-mutation-tick.d.ts.map +1 -0
- package/dist/use-record-mutation-tick.js +11 -0
- package/package.json +3 -3
- package/src/__tests__/action-modal-feedback.test.tsx +116 -0
- package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
- package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
- package/src/__tests__/audit-info.test.tsx +103 -0
- package/src/__tests__/business-pickers.test.tsx +157 -0
- package/src/__tests__/dependent-options.test.tsx +12 -5
- package/src/__tests__/document-form-dialog.test.tsx +190 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
- package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
- package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
- package/src/__tests__/nav-permissions.test.tsx +76 -0
- package/src/__tests__/option-filter.test.tsx +115 -0
- package/src/__tests__/permissions-manager.test.tsx +21 -4
- package/src/__tests__/structured-view-value.test.tsx +5 -1
- package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
- package/src/__tests__/wizard-action-modal.test.tsx +1 -1
- package/src/action-modal-dispatcher.tsx +124 -6
- package/src/audit-info.tsx +219 -0
- package/src/business/catalog.ts +24 -0
- package/src/business/index.ts +3 -0
- package/src/business/print-send-dialog.tsx +175 -0
- package/src/business/relate-documents.tsx +182 -0
- package/src/business/vehicle-picker.tsx +188 -0
- package/src/cfdi-stamp-panel.tsx +146 -0
- package/src/dialogs/dynamic-record.tsx +18 -0
- package/src/document-form-dialog.tsx +357 -0
- package/src/dropdown-menu-entry.test.ts +23 -0
- package/src/dynamic-crud-page.tsx +21 -2
- package/src/dynamic-form.tsx +3 -0
- package/src/dynamic-kanban.tsx +10 -4
- package/src/dynamic-line-items.tsx +3 -0
- package/src/dynamic-multi-select-field.tsx +2 -0
- package/src/dynamic-select-field.tsx +4 -0
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +41 -0
- package/src/nav-permissions.ts +58 -0
- package/src/navigation-builder.tsx +27 -1
- package/src/option-filter.ts +88 -0
- package/src/permissions-manager.tsx +39 -26
- package/src/print-document-button.tsx +9 -1
- package/src/record-mutation-events.ts +50 -0
- package/src/types.ts +90 -0
- package/src/use-options-resolver.ts +19 -1
- package/src/use-print-document.ts +66 -3
- package/src/use-record-mutation-tick.ts +12 -0
- package/vitest.config.ts +1 -0
- 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
|
+
}
|
package/src/business/catalog.ts
CHANGED
|
@@ -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
|
package/src/business/index.ts
CHANGED
|
@@ -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
|
+
}
|