@asteby/metacore-runtime-react 46.4.0 → 47.0.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 (87) hide show
  1. package/CHANGELOG.md +25 -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/business/catalog.d.ts.map +1 -1
  6. package/dist/business/catalog.js +24 -0
  7. package/dist/business/index.d.ts +3 -0
  8. package/dist/business/index.d.ts.map +1 -1
  9. package/dist/business/index.js +3 -0
  10. package/dist/business/print-send-dialog.d.ts +27 -0
  11. package/dist/business/print-send-dialog.d.ts.map +1 -0
  12. package/dist/business/print-send-dialog.js +55 -0
  13. package/dist/business/relate-documents.d.ts +41 -0
  14. package/dist/business/relate-documents.d.ts.map +1 -0
  15. package/dist/business/relate-documents.js +42 -0
  16. package/dist/business/vehicle-picker.d.ts +36 -0
  17. package/dist/business/vehicle-picker.d.ts.map +1 -0
  18. package/dist/business/vehicle-picker.js +61 -0
  19. package/dist/cfdi-stamp-panel.d.ts +29 -0
  20. package/dist/cfdi-stamp-panel.d.ts.map +1 -0
  21. package/dist/cfdi-stamp-panel.js +53 -0
  22. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  23. package/dist/dialogs/dynamic-record.js +5 -0
  24. package/dist/document-form-dialog.d.ts +23 -0
  25. package/dist/document-form-dialog.d.ts.map +1 -0
  26. package/dist/document-form-dialog.js +196 -0
  27. package/dist/dynamic-crud-page.d.ts +6 -0
  28. package/dist/dynamic-crud-page.d.ts.map +1 -1
  29. package/dist/dynamic-crud-page.js +4 -2
  30. package/dist/dynamic-kanban.d.ts.map +1 -1
  31. package/dist/dynamic-kanban.js +3 -1
  32. package/dist/dynamic-table.d.ts.map +1 -1
  33. package/dist/dynamic-table.js +6 -2
  34. package/dist/index.d.ts +5 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/nav-permissions.d.ts +19 -0
  38. package/dist/nav-permissions.d.ts.map +1 -0
  39. package/dist/nav-permissions.js +51 -0
  40. package/dist/navigation-builder.d.ts +6 -0
  41. package/dist/navigation-builder.d.ts.map +1 -1
  42. package/dist/navigation-builder.js +26 -1
  43. package/dist/permissions-manager.d.ts +9 -0
  44. package/dist/permissions-manager.d.ts.map +1 -1
  45. package/dist/permissions-manager.js +50 -17
  46. package/dist/print-document-button.d.ts.map +1 -1
  47. package/dist/print-document-button.js +9 -1
  48. package/dist/record-mutation-events.d.ts +14 -0
  49. package/dist/record-mutation-events.d.ts.map +1 -0
  50. package/dist/record-mutation-events.js +37 -0
  51. package/dist/types.d.ts +46 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/use-print-document.d.ts +15 -1
  54. package/dist/use-print-document.d.ts.map +1 -1
  55. package/dist/use-print-document.js +52 -1
  56. package/dist/use-record-mutation-tick.d.ts +6 -0
  57. package/dist/use-record-mutation-tick.d.ts.map +1 -0
  58. package/dist/use-record-mutation-tick.js +11 -0
  59. package/package.json +3 -3
  60. package/src/__tests__/action-modal-feedback.test.tsx +116 -0
  61. package/src/__tests__/business-pickers.test.tsx +157 -0
  62. package/src/__tests__/document-form-dialog.test.tsx +190 -0
  63. package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
  64. package/src/__tests__/nav-permissions.test.tsx +76 -0
  65. package/src/__tests__/permissions-manager.test.tsx +21 -4
  66. package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
  67. package/src/action-modal-dispatcher.tsx +124 -6
  68. package/src/business/catalog.ts +24 -0
  69. package/src/business/index.ts +3 -0
  70. package/src/business/print-send-dialog.tsx +175 -0
  71. package/src/business/relate-documents.tsx +182 -0
  72. package/src/business/vehicle-picker.tsx +188 -0
  73. package/src/cfdi-stamp-panel.tsx +146 -0
  74. package/src/dialogs/dynamic-record.tsx +5 -0
  75. package/src/document-form-dialog.tsx +357 -0
  76. package/src/dynamic-crud-page.tsx +21 -2
  77. package/src/dynamic-kanban.tsx +3 -1
  78. package/src/dynamic-table.tsx +6 -2
  79. package/src/index.ts +28 -0
  80. package/src/nav-permissions.ts +58 -0
  81. package/src/navigation-builder.tsx +27 -1
  82. package/src/permissions-manager.tsx +39 -26
  83. package/src/print-document-button.tsx +9 -1
  84. package/src/record-mutation-events.ts +50 -0
  85. package/src/types.ts +49 -0
  86. package/src/use-print-document.ts +66 -3
  87. package/src/use-record-mutation-tick.ts +12 -0
@@ -14,7 +14,8 @@
14
14
  //
15
15
  // The host injects its axios-like client via <ApiProvider>; we no longer
16
16
  // depend on a bundler alias to `@/lib/api`.
17
- import { useState, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
17
+ import { emitRecordMutation } from './record-mutation-events'
18
+ import { useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
18
19
  import { useTranslation } from 'react-i18next'
19
20
  import {
20
21
  Dialog,
@@ -44,7 +45,9 @@ import {
44
45
  import { Loader2 } from 'lucide-react'
45
46
  import { ProcessStepper } from '@asteby/metacore-ui/wizard'
46
47
  import { toast } from 'sonner'
47
- import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldErrorMap } from './server-error'
48
+ import { toastServerError, toastServerSuccess, extractFieldErrors, extractServerError, localizeFieldErrorMap } from './server-error'
49
+ import { FormErrorBanner } from './business/feedback'
50
+ import { CfdiStampResultDialog, extractStampResult, type CfdiStampResult } from './cfdi-stamp-panel'
48
51
  import { useBranchCreateGate } from './branch-create-gate'
49
52
  import type { Translate } from './server-error'
50
53
  import { validateValues, bagHasErrors } from './validator'
@@ -358,8 +361,15 @@ export function ActionModalDispatcher({
358
361
  model,
359
362
  record,
360
363
  endpoint,
361
- onSuccess,
364
+ onSuccess: onSuccessProp,
362
365
  }: ActionModalProps) {
366
+ // Every successful action may have created/updated/deleted a record of
367
+ // `model` (federated component, wizard, generic form or confirmation), so
368
+ // announce it: mounted lists of the model reload on their own (#1020).
369
+ const onSuccess = useCallback(() => {
370
+ emitRecordMutation(model, 'update')
371
+ onSuccessProp?.()
372
+ }, [model, onSuccessProp])
363
373
  // Reactive read: a federated remote usually registers AFTER the first
364
374
  // render, and a memoized lookup would pin the fallback forever.
365
375
  const readComponent = () => getActionComponent(model, action.key)
@@ -794,10 +804,34 @@ function toastActionError(
794
804
  toastServerError(err, { t, language })
795
805
  }
796
806
 
807
+ /** Text for the in-dialog error banner of a failed action (PIT-046, #1023): a
808
+ * 422 with per-field `errors` -> "Revisa los campos" + each localized line; any
809
+ * other failure (500, network) -> the server's own message and cause. Unlike
810
+ * the toast it stays on screen until the next attempt. */
811
+ export function actionErrorBannerMessage(
812
+ err: unknown,
813
+ fields: readonly ActionFieldDef[] | undefined,
814
+ t: Translate,
815
+ language?: string,
816
+ ): string {
817
+ const localized = localizeActionFieldErrors(err, fields, t, language)
818
+ if (localized) {
819
+ const head = t('validation.failed', { defaultValue: validationCatalog(language).failed })
820
+ const lines = Object.values(localized).filter(Boolean)
821
+ return lines.length ? `${head}: ${lines.join(' · ')}` : head
822
+ }
823
+ const fallback = t('common.error', { defaultValue: 'Algo salió mal. Intenta de nuevo.' })
824
+ const ex = extractServerError(err, fallback)
825
+ const title = t(ex.title, { defaultValue: ex.title })
826
+ return ex.description && ex.description !== title ? `${title} — ${ex.description}` : title
827
+ }
828
+
797
829
  function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
798
830
  const { t, i18n } = useTranslation()
799
831
  const api = useApi()
800
832
  const [executing, setExecuting] = useState(false)
833
+ const [formError, setFormError] = useState<string | undefined>()
834
+ const [stamp, setStamp] = useState<CfdiStampResult | undefined>()
801
835
  // Actions listed in the model's `reason_required.actions` (cancel, void…)
802
836
  // answer 422 errors.reason without a motive; the prompt asks and retries.
803
837
  const reasonPrompt = useReasonPrompt()
@@ -809,6 +843,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
809
843
 
810
844
  const execute = async () => {
811
845
  setExecuting(true)
846
+ setFormError(undefined)
812
847
  try {
813
848
  const url = buildActionUrl(endpoint, model, record.id, action.key)
814
849
  // Supervisor PIN first (action.supervisorPolicy); null = cancelled.
@@ -823,18 +858,41 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
823
858
  if (!res) return
824
859
  if (res.data.success) {
825
860
  toastServerSuccess(res.data, { t })
861
+ const result = extractStampResult(res.data)
862
+ if (result) {
863
+ setStamp(result)
864
+ return
865
+ }
826
866
  onOpenChange(false)
827
867
  onSuccess()
828
868
  } else {
869
+ setFormError(actionErrorBannerMessage({ response: { data: res.data } }, action.fields, t, i18n.language))
829
870
  toastActionError({ response: { data: res.data } }, action.fields, t, i18n.language)
830
871
  }
831
872
  } catch (err: any) {
873
+ setFormError(actionErrorBannerMessage(err, action.fields, t, i18n.language))
832
874
  toastActionError(err, action.fields, t, i18n.language)
833
875
  } finally {
834
876
  setExecuting(false)
835
877
  }
836
878
  }
837
879
 
880
+ if (stamp) {
881
+ return (
882
+ <CfdiStampResultDialog
883
+ open={open}
884
+ result={stamp}
885
+ title={label}
886
+ onOpenChange={(o) => {
887
+ if (o) return
888
+ setStamp(undefined)
889
+ onOpenChange(false)
890
+ onSuccess()
891
+ }}
892
+ />
893
+ )
894
+ }
895
+
838
896
  return (
839
897
  <>
840
898
  {reasonPrompt.dialog}
@@ -850,6 +908,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
850
908
  </AlertDialogDescription>
851
909
  </AlertDialogHeader>
852
910
  {record ? <RecordPreview model={model} record={record} /> : null}
911
+ <FormErrorBanner message={formError} />
853
912
  <AlertDialogFooter>
854
913
  <AlertDialogCancel disabled={executing}>{t('common.cancel')}</AlertDialogCancel>
855
914
  <AlertDialogAction
@@ -881,6 +940,10 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
881
940
  const [executing, setExecuting] = useState(false)
882
941
  // Per-field validation errors (localized), shown inline under each input.
883
942
  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
943
+ // Persistent in-dialog banner for a failed submit (422 / 500) — the toast
944
+ // alone vanished and, for some failures, only the console had the cause.
945
+ const [formError, setFormError] = useState<string | undefined>()
946
+ const [stamp, setStamp] = useState<CfdiStampResult | undefined>()
884
947
  // Related records to surface BELOW the form, as read-only context for the
885
948
  // record being acted on — e.g. the reception history of a transfer while
886
949
  // receiving against it. Sourced from the model's metadata.relations (the
@@ -912,6 +975,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
912
975
  if (open && action.fields) {
913
976
  setFormData(buildFieldDefaults(action.fields, record))
914
977
  setFieldErrors({})
978
+ setFormError(undefined)
915
979
  }
916
980
  }, [open, action.fields, record])
917
981
 
@@ -999,6 +1063,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
999
1063
 
1000
1064
  const lang = i18n.language
1001
1065
  const handleActionError = (err: unknown) => {
1066
+ setFormError(actionErrorBannerMessage(err, action.fields, t, lang))
1002
1067
  const labels = labelsForValidationFields(action.fields, t)
1003
1068
  const localized = localizeActionFieldErrors(err, action.fields, t, lang)
1004
1069
  if (localized) {
@@ -1035,6 +1100,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1035
1100
  }
1036
1101
  }
1037
1102
  setFieldErrors({})
1103
+ setFormError(undefined)
1038
1104
  setExecuting(true)
1039
1105
  try {
1040
1106
  let payload: Record<string, any> = { ...formData }
@@ -1069,6 +1135,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1069
1135
  if (!res) return
1070
1136
  if (res.data.success) {
1071
1137
  toastServerSuccess(res.data, { t })
1138
+ const result = extractStampResult(res.data)
1139
+ if (result) {
1140
+ setStamp(result)
1141
+ return
1142
+ }
1072
1143
  onOpenChange(false)
1073
1144
  onSuccess()
1074
1145
  } else {
@@ -1102,6 +1173,22 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1102
1173
  ? '820px'
1103
1174
  : undefined
1104
1175
 
1176
+ if (stamp) {
1177
+ return (
1178
+ <CfdiStampResultDialog
1179
+ open={open}
1180
+ result={stamp}
1181
+ title={tl(action.label)}
1182
+ onOpenChange={(o) => {
1183
+ if (o) return
1184
+ setStamp(undefined)
1185
+ onOpenChange(false)
1186
+ onSuccess()
1187
+ }}
1188
+ />
1189
+ )
1190
+ }
1191
+
1105
1192
  return (
1106
1193
  <Dialog open={open} onOpenChange={onOpenChange}>
1107
1194
  {/* Sticky header + footer, scrollable body: the form can grow tall
@@ -1127,6 +1214,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1127
1214
  (in FieldCell) keeps a long select/input value from blowing the
1128
1215
  grid past the dialog and spawning a horizontal scrollbar. */}
1129
1216
  <div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4">
1217
+ <FormErrorBanner message={formError} className="mb-3" />
1130
1218
  <FieldGrid>
1131
1219
  {action.fields?.map((field) => {
1132
1220
  const fullWidth =
@@ -1183,7 +1271,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1183
1271
  // buildFieldDefaults seeds formData for a set of action fields, honoring the
1184
1272
  // same line-items prefill spec + boolean/empty rules GenericActionModal uses, so
1185
1273
  // wizard steps and single-page forms initialize identically.
1186
- function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<string, any> {
1274
+ export function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<string, any> {
1187
1275
  const defaults: Record<string, any> = {}
1188
1276
  for (const field of fields) {
1189
1277
  if (isLineItemsField(field)) {
@@ -1235,6 +1323,8 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1235
1323
  const [stepIndex, setStepIndex] = useState(0)
1236
1324
  const [formData, setFormData] = useState<Record<string, any>>({})
1237
1325
  const [executing, setExecuting] = useState(false)
1326
+ const [formError, setFormError] = useState<string | undefined>()
1327
+ const [stamp, setStamp] = useState<CfdiStampResult | undefined>()
1238
1328
 
1239
1329
  // Reset to the first step and seed defaults for EVERY step's fields whenever
1240
1330
  // the modal (re)opens, so accumulated values from a prior run don't leak.
@@ -1243,6 +1333,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1243
1333
  const allFields = steps.flatMap((s) => s.fields ?? [])
1244
1334
  setFormData(buildFieldDefaults(allFields, record))
1245
1335
  setStepIndex(0)
1336
+ setFormError(undefined)
1246
1337
  }, [open, action.steps, record])
1247
1338
 
1248
1339
  const updateField = (key: string, value: any) =>
@@ -1284,6 +1375,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1284
1375
  }
1285
1376
  }
1286
1377
  setExecuting(true)
1378
+ setFormError(undefined)
1287
1379
  try {
1288
1380
  const url = buildActionUrl(endpoint, model, record?.id, action.key)
1289
1381
  const auth = await supervisor.authorize(
@@ -1297,14 +1389,22 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1297
1389
  request: (reason) => api.post(url, withApproval(reason ? { ...formData, reason } : formData, auth)),
1298
1390
  })
1299
1391
  if (!res) return
1392
+ const allFields = steps.flatMap(s => s.fields ?? [])
1300
1393
  if (res.data.success) {
1301
1394
  toastServerSuccess(res.data, { t })
1395
+ const result = extractStampResult(res.data)
1396
+ if (result) {
1397
+ setStamp(result)
1398
+ return
1399
+ }
1302
1400
  onOpenChange(false)
1303
1401
  onSuccess()
1304
1402
  } else {
1305
- toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t, i18n.language)
1403
+ setFormError(actionErrorBannerMessage({ response: { data: res.data } }, allFields, t, i18n.language))
1404
+ toastActionError({ response: { data: res.data } }, allFields, t, i18n.language)
1306
1405
  }
1307
1406
  } catch (err: any) {
1407
+ setFormError(actionErrorBannerMessage(err, steps.flatMap(s => s.fields ?? []), t, i18n.language))
1308
1408
  toastActionError(err, steps.flatMap(s => s.fields ?? []), t, i18n.language)
1309
1409
  } finally {
1310
1410
  setExecuting(false)
@@ -1313,6 +1413,22 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1313
1413
 
1314
1414
  if (steps.length === 0) return null
1315
1415
 
1416
+ if (stamp) {
1417
+ return (
1418
+ <CfdiStampResultDialog
1419
+ open={open}
1420
+ result={stamp}
1421
+ title={tl(action.label)}
1422
+ onOpenChange={(o) => {
1423
+ if (o) return
1424
+ setStamp(undefined)
1425
+ onOpenChange(false)
1426
+ onSuccess()
1427
+ }}
1428
+ />
1429
+ )
1430
+ }
1431
+
1316
1432
  return (
1317
1433
  <Dialog open={open} onOpenChange={onOpenChange}>
1318
1434
  <DialogContent
@@ -1347,6 +1463,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1347
1463
  persist in the one formData object, so navigating back and
1348
1464
  forth keeps entries intact. */}
1349
1465
  <div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4">
1466
+ <FormErrorBanner message={formError} className="mb-3" />
1350
1467
  <FieldGrid>
1351
1468
  {stepFields.map((field) => {
1352
1469
  const fullWidth =
@@ -1423,7 +1540,8 @@ function seedOptionFromRecord(
1423
1540
  }
1424
1541
  }
1425
1542
 
1426
- function renderField(
1543
+ /** Renders one action field (same widgets as the action modals). Shared with DocumentFormDialog. */
1544
+ export function renderField(
1427
1545
  field: ActionFieldDef,
1428
1546
  value: any,
1429
1547
  onChange: (value: any) => void,
@@ -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
+ }