@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.
- package/CHANGELOG.md +25 -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/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 +5 -0
- 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-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +3 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +6 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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/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 +46 -0
- package/dist/types.d.ts.map +1 -1
- 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__/business-pickers.test.tsx +157 -0
- package/src/__tests__/document-form-dialog.test.tsx +190 -0
- package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
- package/src/__tests__/nav-permissions.test.tsx +76 -0
- package/src/__tests__/permissions-manager.test.tsx +21 -4
- package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
- package/src/action-modal-dispatcher.tsx +124 -6
- 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 +5 -0
- package/src/document-form-dialog.tsx +357 -0
- package/src/dynamic-crud-page.tsx +21 -2
- package/src/dynamic-kanban.tsx +3 -1
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +28 -0
- package/src/nav-permissions.ts +58 -0
- package/src/navigation-builder.tsx +27 -1
- 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 +49 -0
- package/src/use-print-document.ts +66 -3
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
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
|
+
}
|