@asteby/metacore-runtime-react 34.1.0 → 35.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 +7 -86
- package/dist/action-modal-dispatcher.d.ts +4 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +102 -23
- package/dist/addon-loader.d.ts +1 -1
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +8 -1
- package/dist/color-picker-field.d.ts +13 -0
- package/dist/color-picker-field.d.ts.map +1 -0
- package/dist/color-picker-field.js +181 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +99 -61
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +23 -8
- package/dist/dynamic-form.d.ts +1 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +3 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +6 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/permissions-manager.d.ts +5 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +67 -23
- package/dist/use-print-document.d.ts +10 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +28 -1
- package/package.json +1 -1
- package/src/__tests__/color-picker-field.test.tsx +18 -0
- package/src/__tests__/filename-from-disposition.test.ts +30 -0
- package/src/__tests__/prefill-scalar-from-record.test.ts +37 -0
- package/src/__tests__/record-detail-display.test.tsx +30 -0
- package/src/action-modal-dispatcher.tsx +110 -22
- package/src/addon-loader.tsx +7 -1
- package/src/color-picker-field.tsx +296 -0
- package/src/dialogs/dynamic-record.tsx +139 -65
- package/src/dynamic-columns.tsx +28 -12
- package/src/dynamic-form.tsx +9 -1
- package/src/dynamic-select-field.tsx +7 -3
- package/src/index.ts +2 -0
- package/src/permissions-manager.tsx +98 -39
- package/src/use-print-document.ts +38 -2
package/src/dynamic-columns.tsx
CHANGED
|
@@ -44,9 +44,10 @@ import {
|
|
|
44
44
|
import { Progress } from './dialogs/_primitives'
|
|
45
45
|
import { humanizeToken } from './dynamic-columns-helpers'
|
|
46
46
|
import { objectLabel } from './dynamic-relation-helpers'
|
|
47
|
-
import {
|
|
47
|
+
import {
|
|
48
48
|
OptionBadge,
|
|
49
49
|
RelationThumbnail,
|
|
50
|
+
ImageStack,
|
|
50
51
|
statusColorFor,
|
|
51
52
|
useIsDarkTheme,
|
|
52
53
|
} from './display-value'
|
|
@@ -603,6 +604,10 @@ export const resolveRelationSubtitle = (col: ColumnDefinition, row: any): string
|
|
|
603
604
|
* carries an `image`. Falls back to the raw id when no sibling was resolved, and
|
|
604
605
|
* to an empty marker when there is no value at all. Domain-agnostic: works for
|
|
605
606
|
* every `belongs_to` column (category, supplier, brand, …) without per-addon code.
|
|
607
|
+
*
|
|
608
|
+
* When `stack` is true (column `display: "image_stack"`), the landscape mark
|
|
609
|
+
* sits ON TOP of the label — wide logos fit without cropping and the cell
|
|
610
|
+
* stays readable in dense tables.
|
|
606
611
|
*/
|
|
607
612
|
const RelationCell: React.FC<{
|
|
608
613
|
col: ColumnDefinition
|
|
@@ -944,7 +949,15 @@ export function makeDefaultGetDynamicColumns(
|
|
|
944
949
|
}
|
|
945
950
|
|
|
946
951
|
// Landscape stack: wide image ON TOP, label UNDERNEATH.
|
|
952
|
+
// Declared via `display: "image_stack"` on an image column
|
|
953
|
+
// or on a belongs_to FK whose sibling carries a logo/photo
|
|
954
|
+
// (brand marks, product cards). Fits logos that are wider
|
|
955
|
+
// than tall without cropping into a square thumb.
|
|
947
956
|
if (renderAs === 'image_stack') {
|
|
957
|
+
// FK relation (brand_id → brands) OR any column that
|
|
958
|
+
// already resolved a sibling with an image — stack it.
|
|
959
|
+
// Don't require `col.ref` alone: enrichment sometimes
|
|
960
|
+
// leaves type=text while cellStyle carries image_stack.
|
|
948
961
|
const looksRelation =
|
|
949
962
|
!!col.ref ||
|
|
950
963
|
(typeof col.key === 'string' &&
|
|
@@ -1332,30 +1345,33 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1332
1345
|
)
|
|
1333
1346
|
}
|
|
1334
1347
|
|
|
1335
|
-
case '
|
|
1348
|
+
case 'image': {
|
|
1336
1349
|
const imageValue =
|
|
1337
1350
|
value ||
|
|
1338
1351
|
(Array.isArray(row.original.media)
|
|
1339
1352
|
? row.original.media.find((m: any) => m.type === 'image')?.url
|
|
1340
1353
|
: null)
|
|
1354
|
+
return <ImageCell value={imageValue} getImageUrl={getImageUrl} />
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
case 'image_stack': {
|
|
1358
|
+
// Defensive: normally handled above before the
|
|
1359
|
+
// switch; kept so a late `type: image_stack` without
|
|
1360
|
+
// cellStyle still stacks.
|
|
1361
|
+
const labelField = styleCfg(col, 'label_field', 'labelField')
|
|
1362
|
+
const caption = labelField
|
|
1363
|
+
? String(getNestedValue(row.original, labelField) ?? '')
|
|
1364
|
+
: undefined
|
|
1341
1365
|
return (
|
|
1342
1366
|
<ImageCell
|
|
1343
|
-
value={
|
|
1367
|
+
value={value}
|
|
1344
1368
|
getImageUrl={getImageUrl}
|
|
1369
|
+
label={caption || undefined}
|
|
1345
1370
|
stack
|
|
1346
1371
|
/>
|
|
1347
1372
|
)
|
|
1348
1373
|
}
|
|
1349
1374
|
|
|
1350
|
-
case 'image': {
|
|
1351
|
-
const imageValue =
|
|
1352
|
-
value ||
|
|
1353
|
-
(Array.isArray(row.original.media)
|
|
1354
|
-
? row.original.media.find((m: any) => m.type === 'image')?.url
|
|
1355
|
-
: null)
|
|
1356
|
-
return <ImageCell value={imageValue} getImageUrl={getImageUrl} />
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
1375
|
default: {
|
|
1360
1376
|
if (typeof value === 'object' && value !== null) {
|
|
1361
1377
|
return (
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -35,6 +35,7 @@ import { DynamicSelectField } from './dynamic-select-field'
|
|
|
35
35
|
import { DynamicDateField } from './dynamic-date-field'
|
|
36
36
|
import { UploadField } from './upload-field'
|
|
37
37
|
import { IconPickerField } from './icon-picker-field'
|
|
38
|
+
import { ColorPickerField } from './color-picker-field'
|
|
38
39
|
|
|
39
40
|
export { buildZodSchema, resolveWidget }
|
|
40
41
|
export { DynamicLineItems } from './dynamic-line-items'
|
|
@@ -42,6 +43,7 @@ export { DynamicSelectField } from './dynamic-select-field'
|
|
|
42
43
|
export { DynamicDateField } from './dynamic-date-field'
|
|
43
44
|
export { UploadField } from './upload-field'
|
|
44
45
|
export { IconPickerField } from './icon-picker-field'
|
|
46
|
+
export { ColorPickerField, DEFAULT_ROLE_COLOR, normalizeHex } from './color-picker-field'
|
|
45
47
|
|
|
46
48
|
export interface DynamicFormProps {
|
|
47
49
|
fields: ActionFieldDef[]
|
|
@@ -399,7 +401,13 @@ function FieldRenderer({
|
|
|
399
401
|
// / future MDX editor pick it up without breaking the contract.
|
|
400
402
|
return <Textarea id={field.key} data-widget="richtext" value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
401
403
|
case 'color':
|
|
402
|
-
return
|
|
404
|
+
return (
|
|
405
|
+
<ColorPickerField
|
|
406
|
+
aria-label={field.label || field.key}
|
|
407
|
+
value={typeof value === 'string' ? value : ''}
|
|
408
|
+
onChange={onChange}
|
|
409
|
+
/>
|
|
410
|
+
)
|
|
403
411
|
case 'select':
|
|
404
412
|
return (
|
|
405
413
|
<Select value={value || ''} onValueChange={onChange}>
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
|
|
23
23
|
// case — start empty and never hit this.
|
|
24
24
|
import { useEffect, useRef, useState } from 'react'
|
|
25
|
+
import { useTranslation } from 'react-i18next'
|
|
25
26
|
import {
|
|
26
27
|
Badge,
|
|
27
28
|
Button,
|
|
@@ -205,6 +206,9 @@ export function DynamicSelectField({
|
|
|
205
206
|
descriptionAsBadge = false,
|
|
206
207
|
hideCreate = false,
|
|
207
208
|
}: DynamicSelectFieldProps) {
|
|
209
|
+
const { t } = useTranslation()
|
|
210
|
+
const ph = (fallback: string) =>
|
|
211
|
+
field.placeholder ? t(field.placeholder, { defaultValue: field.placeholder }) : fallback
|
|
208
212
|
const [open, setOpen] = useState(false)
|
|
209
213
|
const [search, setSearch] = useState('')
|
|
210
214
|
const [scanOpen, setScanOpen] = useState(false)
|
|
@@ -353,7 +357,7 @@ export function DynamicSelectField({
|
|
|
353
357
|
<span className={'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground')}>
|
|
354
358
|
{/* Never flash the raw id: until the eager fetch resolves the
|
|
355
359
|
option, show a loading hint instead of String(value). */}
|
|
356
|
-
{selectedOption?.label ?? (loading ? 'Cargando…' :
|
|
360
|
+
{selectedOption?.label ?? (loading ? 'Cargando…' : ph('—'))}
|
|
357
361
|
</span>
|
|
358
362
|
</span>
|
|
359
363
|
</Button>
|
|
@@ -386,7 +390,7 @@ export function DynamicSelectField({
|
|
|
386
390
|
<span className={'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
|
|
387
391
|
{blockedByDependency
|
|
388
392
|
? (dependsHint || DEFAULT_DEPENDS_HINT)
|
|
389
|
-
: selectedLabel ||
|
|
393
|
+
: selectedLabel || ph('Buscar…')}
|
|
390
394
|
</span>
|
|
391
395
|
{descriptionAsBadge && selectedOption?.description ? (
|
|
392
396
|
<Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
|
|
@@ -406,7 +410,7 @@ export function DynamicSelectField({
|
|
|
406
410
|
>
|
|
407
411
|
<Command shouldFilter={false}>
|
|
408
412
|
<CommandInput
|
|
409
|
-
placeholder={
|
|
413
|
+
placeholder={ph('Buscar…')}
|
|
410
414
|
value={search}
|
|
411
415
|
onValueChange={setSearch}
|
|
412
416
|
/>
|
package/src/index.ts
CHANGED
|
@@ -178,6 +178,7 @@ export {
|
|
|
178
178
|
type UseDynamicRowActionsParams,
|
|
179
179
|
type DynamicRowActions,
|
|
180
180
|
} from './dynamic-row-actions'
|
|
181
|
+
export { ColorPickerField, DEFAULT_ROLE_COLOR, normalizeHex } from './color-picker-field'
|
|
181
182
|
export {
|
|
182
183
|
PermissionsManager,
|
|
183
184
|
moduleActionCapability,
|
|
@@ -185,6 +186,7 @@ export {
|
|
|
185
186
|
grantedCountForModule,
|
|
186
187
|
capabilitySetsEqual,
|
|
187
188
|
defaultActionIcon,
|
|
189
|
+
suggestRoleIcon,
|
|
188
190
|
normalizeCatalogGroups,
|
|
189
191
|
flattenGroups,
|
|
190
192
|
filterModuleGroups,
|
|
@@ -79,6 +79,9 @@ import {
|
|
|
79
79
|
CollapsibleTrigger,
|
|
80
80
|
} from '@asteby/metacore-ui/primitives'
|
|
81
81
|
import { DynamicIcon } from './dynamic-icon'
|
|
82
|
+
import { IconPickerField } from './icon-picker-field'
|
|
83
|
+
import { ColorPickerField, DEFAULT_ROLE_COLOR } from './color-picker-field'
|
|
84
|
+
import type { ActionFieldDef } from './types'
|
|
82
85
|
|
|
83
86
|
// ---------------------------------------------------------------------------
|
|
84
87
|
// Types (mirror of `GET /api/permissions/modules` + the host sidebar nav)
|
|
@@ -183,12 +186,15 @@ export interface RoleDef {
|
|
|
183
186
|
label?: string
|
|
184
187
|
/** Accent color (hex) for the role chip. */
|
|
185
188
|
color?: string
|
|
189
|
+
/** Lucide PascalCase name (or image path) for the role chip. */
|
|
190
|
+
icon?: string
|
|
186
191
|
}
|
|
187
192
|
|
|
188
193
|
export interface RoleInput {
|
|
189
194
|
name: string
|
|
190
195
|
label?: string
|
|
191
196
|
color?: string
|
|
197
|
+
icon?: string
|
|
192
198
|
}
|
|
193
199
|
|
|
194
200
|
export interface PermissionsManagerProps {
|
|
@@ -330,17 +336,28 @@ function slugify(label: string): string {
|
|
|
330
336
|
.replace(/^_+|_+$/g, '')
|
|
331
337
|
}
|
|
332
338
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
]
|
|
339
|
+
/** Suggest a Lucide icon from the role label (create-dialog default). */
|
|
340
|
+
export function suggestRoleIcon(label: string): string {
|
|
341
|
+
const s = fold(label)
|
|
342
|
+
const rules: Array<[RegExp, string]> = [
|
|
343
|
+
[/cajer|cash|teller|bank/, 'Banknote'],
|
|
344
|
+
[/vend|seller|sales|shop|mostrador/, 'ShoppingBag'],
|
|
345
|
+
[/mecanic|technic|wrench|taller/, 'Wrench'],
|
|
346
|
+
[/jefe|lead|manager|gerente|supervisor/, 'HardHat'],
|
|
347
|
+
[/almacen|ware|stock|invent/, 'Warehouse'],
|
|
348
|
+
[/compr|purch|buyer/, 'Truck'],
|
|
349
|
+
[/contad|account|ledger/, 'Calculator'],
|
|
350
|
+
[/nomina|payroll|sueldo/, 'Wallet'],
|
|
351
|
+
[/rh|hr|human|emplead|people/, 'Users'],
|
|
352
|
+
[/fleet|flota|vehic/, 'Car'],
|
|
353
|
+
[/admin|dueño|owner/, 'ShieldCheck'],
|
|
354
|
+
[/view|observa|lectura|read/, 'Eye'],
|
|
355
|
+
]
|
|
356
|
+
for (const [re, icon] of rules) {
|
|
357
|
+
if (re.test(s)) return icon
|
|
358
|
+
}
|
|
359
|
+
return 'Shield'
|
|
360
|
+
}
|
|
344
361
|
|
|
345
362
|
/**
|
|
346
363
|
* Normalize whatever `loadModules` returned into the canonical grouped shape.
|
|
@@ -548,8 +565,16 @@ export function PermissionsManager({
|
|
|
548
565
|
mode: 'create' | 'edit'
|
|
549
566
|
label: string
|
|
550
567
|
color: string
|
|
568
|
+
icon: string
|
|
551
569
|
grantAll: boolean
|
|
552
|
-
}>({
|
|
570
|
+
}>({
|
|
571
|
+
open: false,
|
|
572
|
+
mode: 'create',
|
|
573
|
+
label: '',
|
|
574
|
+
color: DEFAULT_ROLE_COLOR,
|
|
575
|
+
icon: 'Shield',
|
|
576
|
+
grantAll: false,
|
|
577
|
+
})
|
|
553
578
|
const [roleSaving, setRoleSaving] = React.useState(false)
|
|
554
579
|
const [deleteOpen, setDeleteOpen] = React.useState(false)
|
|
555
580
|
const [deleting, setDeleting] = React.useState(false)
|
|
@@ -711,6 +736,7 @@ export function PermissionsManager({
|
|
|
711
736
|
name: slugify(label),
|
|
712
737
|
label,
|
|
713
738
|
color: roleDialog.color,
|
|
739
|
+
icon: roleDialog.icon || suggestRoleIcon(label),
|
|
714
740
|
})
|
|
715
741
|
const rs = await loadRoles()
|
|
716
742
|
setRoles(rs)
|
|
@@ -732,6 +758,7 @@ export function PermissionsManager({
|
|
|
732
758
|
name: activeRole.name,
|
|
733
759
|
label,
|
|
734
760
|
color: roleDialog.color,
|
|
761
|
+
icon: roleDialog.icon || suggestRoleIcon(label),
|
|
735
762
|
})
|
|
736
763
|
await refreshRoles(activeRole.id)
|
|
737
764
|
toast.success('Rol actualizado')
|
|
@@ -769,7 +796,8 @@ export function PermissionsManager({
|
|
|
769
796
|
open: true,
|
|
770
797
|
mode: 'edit',
|
|
771
798
|
label: activeRole.label || activeRole.name,
|
|
772
|
-
color: activeRole.color ||
|
|
799
|
+
color: activeRole.color || DEFAULT_ROLE_COLOR,
|
|
800
|
+
icon: activeRole.icon || suggestRoleIcon(activeRole.label || activeRole.name),
|
|
773
801
|
grantAll: false,
|
|
774
802
|
})
|
|
775
803
|
}
|
|
@@ -843,7 +871,8 @@ export function PermissionsManager({
|
|
|
843
871
|
open: true,
|
|
844
872
|
mode: 'create',
|
|
845
873
|
label: '',
|
|
846
|
-
color:
|
|
874
|
+
color: DEFAULT_ROLE_COLOR,
|
|
875
|
+
icon: 'Shield',
|
|
847
876
|
grantAll: false,
|
|
848
877
|
})
|
|
849
878
|
}
|
|
@@ -901,12 +930,18 @@ export function PermissionsManager({
|
|
|
901
930
|
<span className="flex min-w-0 items-center gap-2">
|
|
902
931
|
{activeRole && (
|
|
903
932
|
<span
|
|
904
|
-
className="h-
|
|
933
|
+
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md"
|
|
905
934
|
style={{
|
|
906
|
-
background: activeRole.color || '#
|
|
935
|
+
background: `${activeRole.color || '#64748b'}22`,
|
|
936
|
+
color: activeRole.color || '#64748b',
|
|
907
937
|
}}
|
|
908
938
|
aria-hidden="true"
|
|
909
|
-
|
|
939
|
+
>
|
|
940
|
+
<DynamicIcon
|
|
941
|
+
name={activeRole.icon || 'Shield'}
|
|
942
|
+
className="h-3.5 w-3.5"
|
|
943
|
+
/>
|
|
944
|
+
</span>
|
|
910
945
|
)}
|
|
911
946
|
<span className="truncate">
|
|
912
947
|
{activeRole
|
|
@@ -933,12 +968,18 @@ export function PermissionsManager({
|
|
|
933
968
|
}}
|
|
934
969
|
>
|
|
935
970
|
<span
|
|
936
|
-
className="mr-2 h-
|
|
971
|
+
className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md"
|
|
937
972
|
style={{
|
|
938
|
-
background: role.color || '#
|
|
973
|
+
background: `${role.color || '#64748b'}22`,
|
|
974
|
+
color: role.color || '#64748b',
|
|
939
975
|
}}
|
|
940
976
|
aria-hidden="true"
|
|
941
|
-
|
|
977
|
+
>
|
|
978
|
+
<DynamicIcon
|
|
979
|
+
name={role.icon || 'Shield'}
|
|
980
|
+
className="h-3.5 w-3.5"
|
|
981
|
+
/>
|
|
982
|
+
</span>
|
|
942
983
|
<span className="truncate">
|
|
943
984
|
{role.label || role.name}
|
|
944
985
|
</span>
|
|
@@ -1224,30 +1265,48 @@ export function PermissionsManager({
|
|
|
1224
1265
|
id="pm-role-name"
|
|
1225
1266
|
value={roleDialog.label}
|
|
1226
1267
|
placeholder="Ej. Cajero"
|
|
1227
|
-
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
1228
|
-
|
|
1268
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
1269
|
+
const label = e.target.value
|
|
1270
|
+
setRoleDialog((d) => ({
|
|
1271
|
+
...d,
|
|
1272
|
+
label,
|
|
1273
|
+
icon:
|
|
1274
|
+
d.icon === suggestRoleIcon(d.label) ||
|
|
1275
|
+
d.icon === 'Shield' ||
|
|
1276
|
+
!d.icon
|
|
1277
|
+
? suggestRoleIcon(label)
|
|
1278
|
+
: d.icon,
|
|
1279
|
+
}))
|
|
1280
|
+
}}
|
|
1281
|
+
/>
|
|
1282
|
+
</div>
|
|
1283
|
+
<div className="flex flex-col gap-2">
|
|
1284
|
+
<Label>Ícono</Label>
|
|
1285
|
+
<IconPickerField
|
|
1286
|
+
field={
|
|
1287
|
+
{
|
|
1288
|
+
key: 'icon',
|
|
1289
|
+
label: 'Ícono',
|
|
1290
|
+
type: 'text',
|
|
1291
|
+
widget: 'icon',
|
|
1292
|
+
} as ActionFieldDef
|
|
1293
|
+
}
|
|
1294
|
+
value={roleDialog.icon}
|
|
1295
|
+
onChange={(v) =>
|
|
1296
|
+
setRoleDialog((d) => ({
|
|
1297
|
+
...d,
|
|
1298
|
+
icon: typeof v === 'string' && v ? v : 'Shield',
|
|
1299
|
+
}))
|
|
1229
1300
|
}
|
|
1230
1301
|
/>
|
|
1231
1302
|
</div>
|
|
1232
1303
|
<div className="flex flex-col gap-2">
|
|
1233
1304
|
<Label>Color</Label>
|
|
1234
|
-
<
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
aria-label={`Color ${c}`}
|
|
1240
|
-
onClick={() => setRoleDialog((d) => ({ ...d, color: c }))}
|
|
1241
|
-
className={cn(
|
|
1242
|
-
'h-7 w-7 rounded-full border-2 transition-transform',
|
|
1243
|
-
roleDialog.color === c
|
|
1244
|
-
? 'scale-110 border-foreground'
|
|
1245
|
-
: 'border-transparent hover:scale-105',
|
|
1246
|
-
)}
|
|
1247
|
-
style={{ background: c }}
|
|
1248
|
-
/>
|
|
1249
|
-
))}
|
|
1250
|
-
</div>
|
|
1305
|
+
<ColorPickerField
|
|
1306
|
+
aria-label="Color del rol"
|
|
1307
|
+
value={roleDialog.color}
|
|
1308
|
+
onChange={(color) => setRoleDialog((d) => ({ ...d, color }))}
|
|
1309
|
+
/>
|
|
1251
1310
|
</div>
|
|
1252
1311
|
{roleDialog.mode === 'create' && (
|
|
1253
1312
|
<label className="flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-sm hover:bg-muted/40">
|
|
@@ -33,10 +33,36 @@ export interface PrintDocumentArgs {
|
|
|
33
33
|
* open → open the PDF in a new tab (user prints from the viewer).
|
|
34
34
|
*/
|
|
35
35
|
mode?: 'print' | 'download' | 'open'
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Hint filename for download mode. Prefer leaving this unset: the server
|
|
38
|
+
* expands `{{record.*}}` into Content-Disposition. A raw template string
|
|
39
|
+
* (e.g. "cfdi-{{record.number}}.pdf") must NOT be used as a.download —
|
|
40
|
+
* that is how downloads end up literally named with mustache braces.
|
|
41
|
+
*/
|
|
37
42
|
filename?: string
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
/** Parse filename from Content-Disposition (RFC 5987 / quoted). */
|
|
46
|
+
export function filenameFromContentDisposition(header: string | undefined | null): string | undefined {
|
|
47
|
+
if (!header) return undefined
|
|
48
|
+
const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)
|
|
49
|
+
if (star?.[1]) {
|
|
50
|
+
try {
|
|
51
|
+
return decodeURIComponent(star[1].trim().replace(/^"|"$/g, ''))
|
|
52
|
+
} catch {
|
|
53
|
+
return star[1].trim().replace(/^"|"$/g, '')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const plain = /filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i.exec(header)
|
|
57
|
+
const raw = (plain?.[1] ?? plain?.[2] ?? '').trim()
|
|
58
|
+
return raw || undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when a caller passed an unexpanded mustache template as filename. */
|
|
62
|
+
export function looksLikeFilenameTemplate(name: string | undefined): boolean {
|
|
63
|
+
return !!name && /\{\{/.test(name)
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
/**
|
|
41
67
|
* Returns a `printDocument(args)` callback. Resolves once the PDF has been
|
|
42
68
|
* fetched and the browser action (print/download/open) has been kicked off;
|
|
@@ -65,9 +91,19 @@ export function usePrintDocument() {
|
|
|
65
91
|
const cleanup = () => setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
|
|
66
92
|
|
|
67
93
|
if (mode === 'download') {
|
|
94
|
+
const headers = (res as { headers?: Record<string, string> }).headers || {}
|
|
95
|
+
const fromHeader =
|
|
96
|
+
filenameFromContentDisposition(
|
|
97
|
+
headers['content-disposition'] || headers['Content-Disposition'],
|
|
98
|
+
) || undefined
|
|
99
|
+
// Prefer server-expanded name; never use a raw {{record.*}} template.
|
|
100
|
+
const downloadName =
|
|
101
|
+
fromHeader ||
|
|
102
|
+
(!looksLikeFilenameTemplate(filename) ? filename : undefined) ||
|
|
103
|
+
`${key}.pdf`
|
|
68
104
|
const a = document.createElement('a')
|
|
69
105
|
a.href = blobUrl
|
|
70
|
-
a.download =
|
|
106
|
+
a.download = downloadName
|
|
71
107
|
document.body.appendChild(a)
|
|
72
108
|
a.click()
|
|
73
109
|
a.remove()
|