@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +7 -86
  2. package/dist/action-modal-dispatcher.d.ts +4 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +102 -23
  5. package/dist/addon-loader.d.ts +1 -1
  6. package/dist/addon-loader.d.ts.map +1 -1
  7. package/dist/addon-loader.js +8 -1
  8. package/dist/color-picker-field.d.ts +13 -0
  9. package/dist/color-picker-field.d.ts.map +1 -0
  10. package/dist/color-picker-field.js +181 -0
  11. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  12. package/dist/dialogs/dynamic-record.js +99 -61
  13. package/dist/dynamic-columns.d.ts.map +1 -1
  14. package/dist/dynamic-columns.js +23 -8
  15. package/dist/dynamic-form.d.ts +1 -0
  16. package/dist/dynamic-form.d.ts.map +1 -1
  17. package/dist/dynamic-form.js +3 -1
  18. package/dist/dynamic-select-field.d.ts.map +1 -1
  19. package/dist/dynamic-select-field.js +6 -3
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/permissions-manager.d.ts +5 -0
  24. package/dist/permissions-manager.d.ts.map +1 -1
  25. package/dist/permissions-manager.js +67 -23
  26. package/dist/use-print-document.d.ts +10 -1
  27. package/dist/use-print-document.d.ts.map +1 -1
  28. package/dist/use-print-document.js +28 -1
  29. package/package.json +1 -1
  30. package/src/__tests__/color-picker-field.test.tsx +18 -0
  31. package/src/__tests__/filename-from-disposition.test.ts +30 -0
  32. package/src/__tests__/prefill-scalar-from-record.test.ts +37 -0
  33. package/src/__tests__/record-detail-display.test.tsx +30 -0
  34. package/src/action-modal-dispatcher.tsx +110 -22
  35. package/src/addon-loader.tsx +7 -1
  36. package/src/color-picker-field.tsx +296 -0
  37. package/src/dialogs/dynamic-record.tsx +139 -65
  38. package/src/dynamic-columns.tsx +28 -12
  39. package/src/dynamic-form.tsx +9 -1
  40. package/src/dynamic-select-field.tsx +7 -3
  41. package/src/index.ts +2 -0
  42. package/src/permissions-manager.tsx +98 -39
  43. package/src/use-print-document.ts +38 -2
@@ -30,6 +30,8 @@ import { toast } from 'sonner';
30
30
  import { cn } from '@asteby/metacore-ui/lib';
31
31
  import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Checkbox, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Popover, PopoverContent, PopoverTrigger, Separator, Skeleton, Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@asteby/metacore-ui/primitives';
32
32
  import { DynamicIcon } from './dynamic-icon';
33
+ import { IconPickerField } from './icon-picker-field';
34
+ import { ColorPickerField, DEFAULT_ROLE_COLOR } from './color-picker-field';
33
35
  // ---------------------------------------------------------------------------
34
36
  // Pure helpers (exported for hosts/tests)
35
37
  // ---------------------------------------------------------------------------
@@ -133,17 +135,29 @@ function slugify(label) {
133
135
  .replace(/[^a-z0-9]+/g, '_')
134
136
  .replace(/^_+|_+$/g, '');
135
137
  }
136
- const ROLE_COLORS = [
137
- '#ef4444',
138
- '#f97316',
139
- '#eab308',
140
- '#22c55e',
141
- '#06b6d4',
142
- '#3b82f6',
143
- '#8b5cf6',
144
- '#ec4899',
145
- '#6b7280',
146
- ];
138
+ /** Suggest a Lucide icon from the role label (create-dialog default). */
139
+ export function suggestRoleIcon(label) {
140
+ const s = fold(label);
141
+ const rules = [
142
+ [/cajer|cash|teller|bank/, 'Banknote'],
143
+ [/vend|seller|sales|shop|mostrador/, 'ShoppingBag'],
144
+ [/mecanic|technic|wrench|taller/, 'Wrench'],
145
+ [/jefe|lead|manager|gerente|supervisor/, 'HardHat'],
146
+ [/almacen|ware|stock|invent/, 'Warehouse'],
147
+ [/compr|purch|buyer/, 'Truck'],
148
+ [/contad|account|ledger/, 'Calculator'],
149
+ [/nomina|payroll|sueldo/, 'Wallet'],
150
+ [/rh|hr|human|emplead|people/, 'Users'],
151
+ [/fleet|flota|vehic/, 'Car'],
152
+ [/admin|dueño|owner/, 'ShieldCheck'],
153
+ [/view|observa|lectura|read/, 'Eye'],
154
+ ];
155
+ for (const [re, icon] of rules) {
156
+ if (re.test(s))
157
+ return icon;
158
+ }
159
+ return 'Shield';
160
+ }
147
161
  /**
148
162
  * Normalize whatever `loadModules` returned into the canonical grouped shape.
149
163
  *
@@ -237,7 +251,14 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
237
251
  const [moduleOpen, setModuleOpen] = React.useState(false);
238
252
  // Pending role switch while there are unsaved changes.
239
253
  const [pendingRoleId, setPendingRoleId] = React.useState(null);
240
- const [roleDialog, setRoleDialog] = React.useState({ open: false, mode: 'create', label: '', color: ROLE_COLORS[5], grantAll: false });
254
+ const [roleDialog, setRoleDialog] = React.useState({
255
+ open: false,
256
+ mode: 'create',
257
+ label: '',
258
+ color: DEFAULT_ROLE_COLOR,
259
+ icon: 'Shield',
260
+ grantAll: false,
261
+ });
241
262
  const [roleSaving, setRoleSaving] = React.useState(false);
242
263
  const [deleteOpen, setDeleteOpen] = React.useState(false);
243
264
  const [deleting, setDeleting] = React.useState(false);
@@ -389,6 +410,7 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
389
410
  name: slugify(label),
390
411
  label,
391
412
  color: roleDialog.color,
413
+ icon: roleDialog.icon || suggestRoleIcon(label),
392
414
  });
393
415
  const rs = await loadRoles();
394
416
  setRoles(rs);
@@ -412,6 +434,7 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
412
434
  name: activeRole.name,
413
435
  label,
414
436
  color: roleDialog.color,
437
+ icon: roleDialog.icon || suggestRoleIcon(label),
415
438
  });
416
439
  await refreshRoles(activeRole.id);
417
440
  toast.success('Rol actualizado');
@@ -451,7 +474,8 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
451
474
  open: true,
452
475
  mode: 'edit',
453
476
  label: activeRole.label || activeRole.name,
454
- color: activeRole.color || ROLE_COLORS[5],
477
+ color: activeRole.color || DEFAULT_ROLE_COLOR,
478
+ icon: activeRole.icon || suggestRoleIcon(activeRole.label || activeRole.name),
455
479
  grantAll: false,
456
480
  });
457
481
  };
@@ -476,18 +500,21 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
476
500
  open: true,
477
501
  mode: 'create',
478
502
  label: '',
479
- color: ROLE_COLORS[5],
503
+ color: DEFAULT_ROLE_COLOR,
504
+ icon: 'Shield',
480
505
  grantAll: false,
481
- }), children: [_jsx(Plus, { className: "mr-1.5 h-4 w-4" }), " Nuevo rol"] })), _jsxs(Button, { variant: "outline", onClick: () => setAllPermissions(true), disabled: checksDisabled || !allCatalogCapabilities.length, title: "Marca todos los permisos de todos los m\u00F3dulos", children: [_jsx(CheckCheck, { className: "mr-1.5 h-4 w-4" }), " Otorgar todo"] }), _jsxs(Button, { variant: "outline", onClick: () => setAllPermissions(false), disabled: checksDisabled || !draft?.size, title: "Quita todos los permisos del rol", children: [_jsx(Eraser, { className: "mr-1.5 h-4 w-4" }), " Quitar todo"] }), _jsxs(Button, { onClick: handleSave, disabled: !dirty || saving || !activeRole, className: "bg-emerald-600 text-white hover:bg-emerald-700", children: [_jsx(Save, { className: "mr-1.5 h-4 w-4" }), saving ? 'Guardando…' : 'Guardar permisos'] })] })] }), _jsxs("div", { className: "grid items-start gap-4 lg:grid-cols-[340px_1fr]", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "Rol" }), _jsx(CardDescription, { children: "Selecciona el rol a configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs(Popover, { open: roleOpen, onOpenChange: setRoleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": roleOpen, className: "min-w-0 flex-1 justify-between font-normal", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [activeRole && (_jsx("span", { className: "h-2.5 w-2.5 shrink-0 rounded-full", style: {
482
- background: activeRole.color || '#6b7280',
483
- }, "aria-hidden": "true" })), _jsx("span", { className: "truncate", children: activeRole
506
+ }), children: [_jsx(Plus, { className: "mr-1.5 h-4 w-4" }), " Nuevo rol"] })), _jsxs(Button, { variant: "outline", onClick: () => setAllPermissions(true), disabled: checksDisabled || !allCatalogCapabilities.length, title: "Marca todos los permisos de todos los m\u00F3dulos", children: [_jsx(CheckCheck, { className: "mr-1.5 h-4 w-4" }), " Otorgar todo"] }), _jsxs(Button, { variant: "outline", onClick: () => setAllPermissions(false), disabled: checksDisabled || !draft?.size, title: "Quita todos los permisos del rol", children: [_jsx(Eraser, { className: "mr-1.5 h-4 w-4" }), " Quitar todo"] }), _jsxs(Button, { onClick: handleSave, disabled: !dirty || saving || !activeRole, className: "bg-emerald-600 text-white hover:bg-emerald-700", children: [_jsx(Save, { className: "mr-1.5 h-4 w-4" }), saving ? 'Guardando…' : 'Guardar permisos'] })] })] }), _jsxs("div", { className: "grid items-start gap-4 lg:grid-cols-[340px_1fr]", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "Rol" }), _jsx(CardDescription, { children: "Selecciona el rol a configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs(Popover, { open: roleOpen, onOpenChange: setRoleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": roleOpen, className: "min-w-0 flex-1 justify-between font-normal", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [activeRole && (_jsx("span", { className: "flex h-6 w-6 shrink-0 items-center justify-center rounded-md", style: {
507
+ background: `${activeRole.color || '#64748b'}22`,
508
+ color: activeRole.color || '#64748b',
509
+ }, "aria-hidden": "true", children: _jsx(DynamicIcon, { name: activeRole.icon || 'Shield', className: "h-3.5 w-3.5" }) })), _jsx("span", { className: "truncate", children: activeRole
484
510
  ? activeRole.label || activeRole.name
485
511
  : 'Seleccionar rol…' })] }), _jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[280px] p-0", align: "start", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Buscar rol\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "Sin resultados." }), _jsx(CommandGroup, { children: (roles ?? []).map((role) => (_jsxs(CommandItem, { value: `${role.label || ''} ${role.name}`, onSelect: () => {
486
512
  requestRoleSwitch(role.id);
487
513
  setRoleOpen(false);
488
- }, children: [_jsx("span", { className: "mr-2 h-2 w-2 shrink-0 rounded-full", style: {
489
- background: role.color || '#6b7280',
490
- }, "aria-hidden": "true" }), _jsx("span", { className: "truncate", children: role.label || role.name }), role.id === activeRoleId && (_jsx(Check, { className: "ml-auto h-4 w-4" }))] }, role.id))) })] })] }) })] }), updateRole && (_jsx(Button, { variant: "outline", size: "icon", className: "h-9 w-9 shrink-0", "aria-label": "Editar rol", disabled: !activeRole, onClick: openEditRole, children: _jsx(Pencil, { className: "h-4 w-4" }) })), deleteRole && (_jsx(Button, { variant: "outline", size: "icon", className: "h-9 w-9 shrink-0 text-destructive hover:text-destructive", "aria-label": "Eliminar rol", disabled: !activeRole, onClick: () => setDeleteOpen(true), children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }), (general?.length ?? 0) > 0 && (_jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsxs("div", { children: [_jsx("h3", { className: "mb-2 text-sm font-semibold", children: "Permisos Generales" }), _jsx("div", { className: "flex flex-col gap-2", children: general.map((g) => (_jsx(CapabilityCheck, { checked: draft?.has(g.key) ?? false, disabled: checksDisabled, onToggle: () => toggleCapability(g.key), label: g.label, description: g.description }, g.key))) })] })] }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "M\u00F3dulo" }), _jsx(CardDescription, { children: "Elige el m\u00F3dulo cuyas acciones quieres configurar." })] }), _jsx(CardContent, { children: _jsxs(Popover, { open: moduleOpen, onOpenChange: setModuleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": moduleOpen, className: "w-full justify-between font-normal", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [activeModule && (_jsx(DynamicIcon, { name: activeModule.icon ||
514
+ }, children: [_jsx("span", { className: "mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md", style: {
515
+ background: `${role.color || '#64748b'}22`,
516
+ color: role.color || '#64748b',
517
+ }, "aria-hidden": "true", children: _jsx(DynamicIcon, { name: role.icon || 'Shield', className: "h-3.5 w-3.5" }) }), _jsx("span", { className: "truncate", children: role.label || role.name }), role.id === activeRoleId && (_jsx(Check, { className: "ml-auto h-4 w-4" }))] }, role.id))) })] })] }) })] }), updateRole && (_jsx(Button, { variant: "outline", size: "icon", className: "h-9 w-9 shrink-0", "aria-label": "Editar rol", disabled: !activeRole, onClick: openEditRole, children: _jsx(Pencil, { className: "h-4 w-4" }) })), deleteRole && (_jsx(Button, { variant: "outline", size: "icon", className: "h-9 w-9 shrink-0 text-destructive hover:text-destructive", "aria-label": "Eliminar rol", disabled: !activeRole, onClick: () => setDeleteOpen(true), children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }), (general?.length ?? 0) > 0 && (_jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsxs("div", { children: [_jsx("h3", { className: "mb-2 text-sm font-semibold", children: "Permisos Generales" }), _jsx("div", { className: "flex flex-col gap-2", children: general.map((g) => (_jsx(CapabilityCheck, { checked: draft?.has(g.key) ?? false, disabled: checksDisabled, onToggle: () => toggleCapability(g.key), label: g.label, description: g.description }, g.key))) })] })] }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "M\u00F3dulo" }), _jsx(CardDescription, { children: "Elige el m\u00F3dulo cuyas acciones quieres configurar." })] }), _jsx(CardContent, { children: _jsxs(Popover, { open: moduleOpen, onOpenChange: setModuleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": moduleOpen, className: "w-full justify-between font-normal", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [activeModule && (_jsx(DynamicIcon, { name: activeModule.icon ||
491
518
  (activeModule.kind === 'screen'
492
519
  ? 'Eye'
493
520
  : 'Square'), className: "h-4 w-4 shrink-0 opacity-70" })), _jsx("span", { className: "truncate", children: activeModule
@@ -506,9 +533,26 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
506
533
  : 'Configura los permisos del módulo seleccionado.' })] }), activeRole && activeModule && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Badge, { variant: "secondary", className: "tabular-nums", children: [moduleGranted, "/", moduleTotal] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-8", disabled: checksDisabled || moduleGranted === moduleTotal, onClick: () => setModuleAll(true), children: [_jsx(CheckCheck, { className: "mr-1.5 h-3.5 w-3.5" }), " Marcar todo"] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-8", disabled: checksDisabled || moduleGranted === 0, onClick: () => setModuleAll(false), children: [_jsx(Eraser, { className: "mr-1.5 h-3.5 w-3.5" }), " Limpiar"] })] }))] }) }), _jsx(CardContent, { children: !activeRole ? (_jsx(EmptyHint, { text: "Selecciona un rol para configurar sus permisos." })) : loadingPerms ? (_jsx("div", { className: "grid gap-2 sm:grid-cols-2", children: Array.from({ length: 6 }).map((_, i) => (_jsx(Skeleton, { className: "h-14 w-full" }, i))) })) : !activeModule ? (_jsx(EmptyHint, { text: "Selecciona un m\u00F3dulo de la lista para ver sus acciones." })) : (_jsx(ModuleActionsPanel, { module: activeModule, draft: draft, checksDisabled: checksDisabled, onToggle: toggleCapability })) })] })] }), _jsx(AlertDialog, { open: pendingRoleId !== null, onOpenChange: (open) => !open && setPendingRoleId(null), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "Cambios sin guardar" }), _jsx(AlertDialogDescription, { children: "Tienes cambios sin guardar en este rol. Si cambias de rol se descartar\u00E1n." })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: "Cancelar" }), _jsx(AlertDialogAction, { onClick: () => {
507
534
  setActiveRoleId(pendingRoleId);
508
535
  setPendingRoleId(null);
509
- }, children: "Descartar y cambiar" })] })] }) }), _jsx(Dialog, { open: roleDialog.open, onOpenChange: (open) => setRoleDialog((d) => ({ ...d, open })), children: _jsxs(DialogContent, { className: "sm:max-w-md", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol' }) }), _jsxs("div", { className: "flex flex-col gap-4 py-2", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "pm-role-name", children: "Nombre del rol" }), _jsx(Input, { id: "pm-role-name", value: roleDialog.label, placeholder: "Ej. Cajero", onChange: (e) => setRoleDialog((d) => ({ ...d, label: e.target.value })) })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Color" }), _jsx("div", { className: "flex flex-wrap gap-2", children: ROLE_COLORS.map((c) => (_jsx("button", { type: "button", "aria-label": `Color ${c}`, onClick: () => setRoleDialog((d) => ({ ...d, color: c })), className: cn('h-7 w-7 rounded-full border-2 transition-transform', roleDialog.color === c
510
- ? 'scale-110 border-foreground'
511
- : 'border-transparent hover:scale-105'), style: { background: c } }, c))) })] }), roleDialog.mode === 'create' && (_jsxs("label", { className: "flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-sm hover:bg-muted/40", children: [_jsx("input", { type: "checkbox", className: "mt-0.5 size-4 accent-emerald-600", checked: roleDialog.grantAll, onChange: (e) => setRoleDialog((d) => ({
536
+ }, children: "Descartar y cambiar" })] })] }) }), _jsx(Dialog, { open: roleDialog.open, onOpenChange: (open) => setRoleDialog((d) => ({ ...d, open })), children: _jsxs(DialogContent, { className: "sm:max-w-md", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol' }) }), _jsxs("div", { className: "flex flex-col gap-4 py-2", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "pm-role-name", children: "Nombre del rol" }), _jsx(Input, { id: "pm-role-name", value: roleDialog.label, placeholder: "Ej. Cajero", onChange: (e) => {
537
+ const label = e.target.value;
538
+ setRoleDialog((d) => ({
539
+ ...d,
540
+ label,
541
+ icon: d.icon === suggestRoleIcon(d.label) ||
542
+ d.icon === 'Shield' ||
543
+ !d.icon
544
+ ? suggestRoleIcon(label)
545
+ : d.icon,
546
+ }));
547
+ } })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "\u00CDcono" }), _jsx(IconPickerField, { field: {
548
+ key: 'icon',
549
+ label: 'Ícono',
550
+ type: 'text',
551
+ widget: 'icon',
552
+ }, value: roleDialog.icon, onChange: (v) => setRoleDialog((d) => ({
553
+ ...d,
554
+ icon: typeof v === 'string' && v ? v : 'Shield',
555
+ })) })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Color" }), _jsx(ColorPickerField, { "aria-label": "Color del rol", value: roleDialog.color, onChange: (color) => setRoleDialog((d) => ({ ...d, color })) })] }), roleDialog.mode === 'create' && (_jsxs("label", { className: "flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-sm hover:bg-muted/40", children: [_jsx("input", { type: "checkbox", className: "mt-0.5 size-4 accent-emerald-600", checked: roleDialog.grantAll, onChange: (e) => setRoleDialog((d) => ({
512
556
  ...d,
513
557
  grantAll: e.target.checked,
514
558
  })) }), _jsxs("span", { children: [_jsx("span", { className: "font-medium", children: "Otorgar todos los permisos" }), _jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: "Acceso completo a todos los m\u00F3dulos del cat\u00E1logo (como un admin). Puedes quitar permisos despu\u00E9s." })] })] }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setRoleDialog((d) => ({ ...d, open: false })), disabled: roleSaving, children: "Cancelar" }), _jsx(Button, { onClick: handleRoleSubmit, disabled: roleSaving || !roleDialog.label.trim(), children: roleSaving
@@ -12,9 +12,18 @@ export interface PrintDocumentArgs {
12
12
  * open → open the PDF in a new tab (user prints from the viewer).
13
13
  */
14
14
  mode?: 'print' | 'download' | 'open';
15
- /** Filename for the download mode (defaults to "<key>.pdf"). */
15
+ /**
16
+ * Hint filename for download mode. Prefer leaving this unset: the server
17
+ * expands `{{record.*}}` into Content-Disposition. A raw template string
18
+ * (e.g. "cfdi-{{record.number}}.pdf") must NOT be used as a.download —
19
+ * that is how downloads end up literally named with mustache braces.
20
+ */
16
21
  filename?: string;
17
22
  }
23
+ /** Parse filename from Content-Disposition (RFC 5987 / quoted). */
24
+ export declare function filenameFromContentDisposition(header: string | undefined | null): string | undefined;
25
+ /** True when a caller passed an unexpanded mustache template as filename. */
26
+ export declare function looksLikeFilenameTemplate(name: string | undefined): boolean;
18
27
  /**
19
28
  * Returns a `printDocument(args)` callback. Resolves once the PDF has been
20
29
  * fetched and the browser action (print/download/open) has been kicked off;
@@ -1 +1 @@
1
- {"version":3,"file":"use-print-document.d.ts","sourceRoot":"","sources":["../src/use-print-document.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,iBAAiB;IAC9B,0EAA0E;IAC1E,KAAK,EAAE,MAAM,CAAA;IACb,qBAAqB;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAA;IACX;;;;;OAKG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,UAAU,GAAG,MAAM,CAAA;IACpC,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,0CASrB,iBAAiB,KAAG,OAAO,CAAC,MAAM,CAAC,CAyD7C"}
1
+ {"version":3,"file":"use-print-document.d.ts","sourceRoot":"","sources":["../src/use-print-document.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,iBAAiB;IAC9B,0EAA0E;IAC1E,KAAK,EAAE,MAAM,CAAA;IACb,qBAAqB;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAA;IACX;;;;;OAKG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,UAAU,GAAG,MAAM,CAAA;IACpC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,mEAAmE;AACnE,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAapG;AAED,6EAA6E;AAC7E,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAE3E;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,0CASrB,iBAAiB,KAAG,OAAO,CAAC,MAAM,CAAC,CAmE7C"}
@@ -18,6 +18,27 @@
18
18
  // this hook constructs no client of its own.
19
19
  import { useCallback } from 'react';
20
20
  import { useApi } from './api-context';
21
+ /** Parse filename from Content-Disposition (RFC 5987 / quoted). */
22
+ export function filenameFromContentDisposition(header) {
23
+ if (!header)
24
+ return undefined;
25
+ const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header);
26
+ if (star?.[1]) {
27
+ try {
28
+ return decodeURIComponent(star[1].trim().replace(/^"|"$/g, ''));
29
+ }
30
+ catch {
31
+ return star[1].trim().replace(/^"|"$/g, '');
32
+ }
33
+ }
34
+ const plain = /filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i.exec(header);
35
+ const raw = (plain?.[1] ?? plain?.[2] ?? '').trim();
36
+ return raw || undefined;
37
+ }
38
+ /** True when a caller passed an unexpanded mustache template as filename. */
39
+ export function looksLikeFilenameTemplate(name) {
40
+ return !!name && /\{\{/.test(name);
41
+ }
21
42
  /**
22
43
  * Returns a `printDocument(args)` callback. Resolves once the PDF has been
23
44
  * fetched and the browser action (print/download/open) has been kicked off;
@@ -35,9 +56,15 @@ export function usePrintDocument() {
35
56
  const blobUrl = URL.createObjectURL(blob);
36
57
  const cleanup = () => setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
37
58
  if (mode === 'download') {
59
+ const headers = res.headers || {};
60
+ const fromHeader = filenameFromContentDisposition(headers['content-disposition'] || headers['Content-Disposition']) || undefined;
61
+ // Prefer server-expanded name; never use a raw {{record.*}} template.
62
+ const downloadName = fromHeader ||
63
+ (!looksLikeFilenameTemplate(filename) ? filename : undefined) ||
64
+ `${key}.pdf`;
38
65
  const a = document.createElement('a');
39
66
  a.href = blobUrl;
40
- a.download = filename || `${key}.pdf`;
67
+ a.download = downloadName;
41
68
  document.body.appendChild(a);
42
69
  a.click();
43
70
  a.remove();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "34.1.0",
3
+ "version": "35.1.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { normalizeHex, DEFAULT_ROLE_COLOR } from '../color-picker-field'
3
+
4
+ describe('normalizeHex', () => {
5
+ it('accepts #rgb and expands', () => {
6
+ expect(normalizeHex('#0af')).toBe('#00aaff')
7
+ })
8
+ it('lowercases #rrggbb', () => {
9
+ expect(normalizeHex('#3B82F6')).toBe('#3b82f6')
10
+ })
11
+ it('rejects garbage', () => {
12
+ expect(normalizeHex('blue')).toBe('')
13
+ expect(normalizeHex('#gg0000')).toBe('')
14
+ })
15
+ it('default constant is valid', () => {
16
+ expect(normalizeHex(DEFAULT_ROLE_COLOR)).toBe('#3b82f6')
17
+ })
18
+ })
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ filenameFromContentDisposition,
4
+ looksLikeFilenameTemplate,
5
+ } from '../use-print-document'
6
+
7
+ describe('filenameFromContentDisposition', () => {
8
+ it('parses quoted filename', () => {
9
+ expect(filenameFromContentDisposition('inline; filename="cfdi-F-950.pdf"')).toBe(
10
+ 'cfdi-F-950.pdf',
11
+ )
12
+ })
13
+
14
+ it('parses RFC 5987 filename*', () => {
15
+ expect(
16
+ filenameFromContentDisposition("attachment; filename*=UTF-8''cfdi-F%20950.pdf"),
17
+ ).toBe('cfdi-F 950.pdf')
18
+ })
19
+
20
+ it('returns undefined for empty', () => {
21
+ expect(filenameFromContentDisposition(undefined)).toBeUndefined()
22
+ })
23
+ })
24
+
25
+ describe('looksLikeFilenameTemplate', () => {
26
+ it('detects mustache', () => {
27
+ expect(looksLikeFilenameTemplate('cfdi-{{record.number}}.pdf')).toBe(true)
28
+ expect(looksLikeFilenameTemplate('cfdi-F-950.pdf')).toBe(false)
29
+ })
30
+ })
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readRecordPath, scalarDefaultFromRecord, unwrapRecordScalar } from '../action-modal-dispatcher'
3
+ import type { ActionFieldDef } from '../types'
4
+
5
+ describe('scalarDefaultFromRecord', () => {
6
+ it('unwraps FK cells with value/label', () => {
7
+ expect(unwrapRecordScalar({ value: 'abc', label: 'Cliente SA' })).toBe('abc')
8
+ })
9
+
10
+ it('reads dotted paths', () => {
11
+ const record = { fiscal_data: { forma_pago: '03' } }
12
+ expect(readRecordPath(record, 'fiscal_data.forma_pago')).toBe('03')
13
+ })
14
+
15
+ it('uses defaultFromRecord string', () => {
16
+ const field = { key: 'forma_pago', defaultFromRecord: 'fiscal_data.forma_pago' } as ActionFieldDef & {
17
+ defaultFromRecord: string
18
+ }
19
+ const record = { fiscal_data: { forma_pago: '01' }, forma_pago: '99' }
20
+ expect(scalarDefaultFromRecord(field, record)).toBe('01')
21
+ })
22
+
23
+ it('tries defaultFromRecord array in order', () => {
24
+ const field = {
25
+ key: 'uso_cfdi',
26
+ defaultFromRecord: ['fiscal_data.uso_cfdi', 'uso_cfdi'],
27
+ } as ActionFieldDef & { defaultFromRecord: string[] }
28
+ const record = { uso_cfdi: 'G03' }
29
+ expect(scalarDefaultFromRecord(field, record)).toBe('G03')
30
+ })
31
+
32
+ it('falls back to record[field.key]', () => {
33
+ const field = { key: 'customer_id' } as ActionFieldDef
34
+ const record = { customer_id: '11111111-1111-4111-8111-111111111111' }
35
+ expect(scalarDefaultFromRecord(field, record)).toBe('11111111-1111-4111-8111-111111111111')
36
+ })
37
+ })
@@ -110,4 +110,34 @@ describe('ViewValue — detail dialog display mapping', () => {
110
110
  )
111
111
  expect(container.textContent).toContain('—')
112
112
  })
113
+
114
+ it('renders plain text external_id without a relation initials avatar', () => {
115
+ const { container } = render(
116
+ <ViewValue
117
+ field={{ key: 'external_id', label: 'ID externo', type: 'text' }}
118
+ value="6a8c931523ea7"
119
+ record={{}}
120
+ />
121
+ )
122
+ expect(screen.getByText('6a8c931523ea7')).toBeTruthy()
123
+ // Must NOT render the InitialsAvatar lead ("6" chip) used for FKs.
124
+ expect(container.querySelector('[data-slot="avatar"]')).toBeNull()
125
+ expect(container.textContent).not.toMatch(/^6\s*6a8c/)
126
+ })
127
+
128
+ it('renders nested provider_data objects as pretty JSON, not key: {…}', () => {
129
+ render(
130
+ <ViewValue
131
+ field={{ key: 'provider_data', label: 'Datos del proveedor', type: 'json' }}
132
+ value={{
133
+ forma_pago: '03',
134
+ INV: { Folio: 950, Serie: 'F' },
135
+ }}
136
+ record={{}}
137
+ />
138
+ )
139
+ expect(screen.getByText('03')).toBeTruthy()
140
+ expect(screen.getByText(/"Folio": 950/)).toBeTruthy()
141
+ expect(screen.queryByText(/Inv:\s*\{\.\.\.\}/i)).toBeNull()
142
+ })
113
143
  })
@@ -156,6 +156,65 @@ export function buildPrefillRows(spec: PrefillSpec, record: any): Array<Record<s
156
156
  return rows
157
157
  }
158
158
 
159
+ // ---- scalar prefill from the acted-on record --------------------------------
160
+ //
161
+ // Row actions (stamp / refactura / cancel-with-reason) should open with the
162
+ // current record's values, not empty selects. Manifest declares explicit paths
163
+ // via `defaultFromRecord` (string or string[] fallback chain). When omitted,
164
+ // the field seeds from record[field.key] if present.
165
+
166
+ export function unwrapRecordScalar(value: unknown): unknown {
167
+ if (value === null || value === undefined) return value
168
+ if (typeof value !== 'object' || value instanceof Date) return value
169
+ if (Array.isArray(value)) return value
170
+ const o = value as Record<string, unknown>
171
+ if ('value' in o && (typeof o.value === 'string' || typeof o.value === 'number')) {
172
+ return o.value
173
+ }
174
+ if ('id' in o && (typeof o.id === 'string' || typeof o.id === 'number')) {
175
+ return o.id
176
+ }
177
+ return value
178
+ }
179
+
180
+ export function readRecordPath(record: any, path: string): unknown {
181
+ if (!record || !path) return undefined
182
+ const parts = path.split('.')
183
+ let cur: any = record
184
+ for (const p of parts) {
185
+ if (cur == null || typeof cur !== 'object') return undefined
186
+ cur = cur[p]
187
+ }
188
+ return unwrapRecordScalar(cur)
189
+ }
190
+
191
+ function defaultFromRecordSpec(field: ActionFieldDef): string | string[] | undefined {
192
+ const f = field as ActionFieldDef & {
193
+ defaultFromRecord?: string | string[]
194
+ default_from_record?: string | string[]
195
+ }
196
+ return f.defaultFromRecord ?? f.default_from_record
197
+ }
198
+
199
+ /** Scalar seed for one action field from the row being acted on. */
200
+ export function scalarDefaultFromRecord(field: ActionFieldDef, record: any): unknown {
201
+ if (!record) return undefined
202
+ const spec = defaultFromRecordSpec(field)
203
+ if (typeof spec === 'string') {
204
+ const v = readRecordPath(record, spec)
205
+ if (v !== undefined && v !== null && v !== '') return v
206
+ } else if (Array.isArray(spec)) {
207
+ for (const path of spec) {
208
+ const v = readRecordPath(record, path)
209
+ if (v !== undefined && v !== null && v !== '') return v
210
+ }
211
+ } else if (field.key) {
212
+ const v = readRecordPath(record, field.key)
213
+ if (v !== undefined && v !== null && v !== '') return v
214
+ }
215
+ return undefined
216
+ }
217
+
159
218
  export function ActionModalDispatcher({
160
219
  open,
161
220
  onOpenChange,
@@ -322,14 +381,17 @@ function selectPreviewColumns(columns: ColumnDefinition[] | undefined, record: a
322
381
  continue
323
382
  }
324
383
 
325
- // Relación (ref / search / dynamic_select / *_id): solo si el sibling
326
- // resolvió a un label legible; si es un *_id crudo sin resolver, se omite.
384
+ // Relación (ref / search / dynamic_select / uuid *_id): solo si el sibling
385
+ // resolvió a un label legible; text `*_id` (external_id) no es FK.
386
+ const t = String(col.type || '').toLowerCase()
327
387
  const isRelation =
328
388
  !!getFieldRef(col as ActionFieldDef) ||
329
389
  col.type === 'search' ||
330
390
  col.type === 'relation' ||
331
391
  (col as { widget?: string }).widget === 'dynamic_select' ||
332
- (typeof col.key === 'string' && col.key.endsWith('_id'))
392
+ (typeof col.key === 'string' &&
393
+ col.key.endsWith('_id') &&
394
+ (t === 'uuid' || t === 'search' || t === 'relation' || t === 'dynamic_select' || t === 'belongs_to'))
333
395
  if (isRelation) {
334
396
  const sib = relationSiblingValue(col as any, record)
335
397
  const label = typeof sib === 'string' ? sib : objectLabel(sib)
@@ -552,20 +614,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
552
614
 
553
615
  useEffect(() => {
554
616
  if (open && action.fields) {
555
- const defaults: Record<string, any> = {}
556
- for (const field of action.fields) {
557
- if (isLineItemsField(field)) {
558
- const dv = lineItemsDefault(field)
559
- defaults[field.key] = isPrefillSpec(dv)
560
- ? buildPrefillRows(dv, record)
561
- : Array.isArray(dv)
562
- ? dv
563
- : []
564
- continue
565
- }
566
- defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
567
- }
568
- setFormData(defaults)
617
+ setFormData(buildFieldDefaults(action.fields, record))
569
618
  setFieldErrors({})
570
619
  }
571
620
  }, [open, action.fields, record])
@@ -632,6 +681,8 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
632
681
  () => (action.fields ?? []).some(isLineItemsField),
633
682
  [action.fields],
634
683
  )
684
+ const embedRelations =
685
+ hasLineItems || !!(action as ActionMetadata & { embedRelations?: boolean }).embedRelations
635
686
  const explicitWidth = (action as unknown as { modalWidth?: number | string }).modalWidth
636
687
  const widthPx =
637
688
  explicitWidth != null
@@ -657,7 +708,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
657
708
  <DynamicIcon name={action.icon} className="h-5 w-5" />
658
709
  {tl(action.label)}
659
710
  </DialogTitle>
660
- {action.confirmMessage && <DialogDescription>{action.confirmMessage}</DialogDescription>}
711
+ {action.confirmMessage && <DialogDescription>{tl(action.confirmMessage)}</DialogDescription>}
661
712
  </DialogHeader>
662
713
  {/* Scrollable body. The shared FieldGrid lays scalar fields out
663
714
  in two responsive columns (single column on phones); line-items
@@ -676,14 +727,14 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
676
727
  <FieldLabel htmlFor={field.key} required={field.required}>
677
728
  {tl(field.label)}
678
729
  </FieldLabel>
679
- {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
730
+ {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData, record)}
680
731
  {fieldErrors[field.key] && (
681
732
  <p className="text-destructive text-xs mt-1">{fieldErrors[field.key]}</p>
682
733
  )}
683
734
  </FieldCell>
684
735
  )
685
736
  })}
686
- {relations.length > 0 && (
737
+ {embedRelations && relations.length > 0 && (
687
738
  <FieldCell fullWidth>
688
739
  {/* Igual que el modal de registro: solo las
689
740
  relaciones de composición se embeben. */}
@@ -725,6 +776,11 @@ function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<strin
725
776
  : []
726
777
  continue
727
778
  }
779
+ const fromRecord = scalarDefaultFromRecord(field, record)
780
+ if (fromRecord !== undefined && fromRecord !== null && fromRecord !== '') {
781
+ defaults[field.key] = fromRecord
782
+ continue
783
+ }
728
784
  defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
729
785
  }
730
786
  return defaults
@@ -881,7 +937,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
881
937
  <FieldLabel htmlFor={field.key} required={field.required}>
882
938
  {tl(field.label)}
883
939
  </FieldLabel>
884
- {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
940
+ {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData, record)}
885
941
  </FieldCell>
886
942
  )
887
943
  })}
@@ -922,6 +978,29 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
922
978
  )
923
979
  }
924
980
 
981
+ function seedOptionFromRecord(
982
+ field: ActionFieldDef,
983
+ value: any,
984
+ record?: Record<string, any>,
985
+ ): import('./use-options-resolver').ResolvedOption | undefined {
986
+ if (!record || !field.key.endsWith('_id')) return undefined
987
+ const siblingKey = field.key.replace(/_id$/, '')
988
+ const sib = record[siblingKey]
989
+ if (!sib || typeof sib !== 'object') return undefined
990
+ const label = (sib as any).label ?? (sib as any).name ?? ''
991
+ if (!label && !(sib as any).image) return undefined
992
+ const id = String((sib as any).value ?? (sib as any).id ?? value ?? '')
993
+ return {
994
+ id,
995
+ value: id,
996
+ label: String(label),
997
+ name: String(label),
998
+ image: (sib as any).image,
999
+ color: (sib as any).color,
1000
+ icon: (sib as any).icon,
1001
+ }
1002
+ }
1003
+
925
1004
  function renderField(
926
1005
  field: ActionFieldDef,
927
1006
  value: any,
@@ -931,6 +1010,7 @@ function renderField(
931
1010
  // fields. Omitted by callers that have no surrounding form (the field is
932
1011
  // then treated as having no resolvable dependency).
933
1012
  formValues?: Record<string, any>,
1013
+ record?: Record<string, any>,
934
1014
  ) {
935
1015
  // Repeatable line-items group → row grid (value is an array of row objects).
936
1016
  // The header form values flow in so a cell can depend on a header field.
@@ -948,7 +1028,15 @@ function renderField(
948
1028
  const dependsValue = getDependsOn(field)
949
1029
  ? resolveDependsValue(field, formValues)
950
1030
  : undefined
951
- return <DynamicSelectField field={field} value={value} onChange={onChange} dependsValue={dependsValue} />
1031
+ return (
1032
+ <DynamicSelectField
1033
+ field={field}
1034
+ value={value}
1035
+ onChange={onChange}
1036
+ dependsValue={dependsValue}
1037
+ seedOption={seedOptionFromRecord(field, value, record)}
1038
+ />
1039
+ )
952
1040
  }
953
1041
  // File upload → themed picker that POSTs the file to the host upload
954
1042
  // endpoint and stores the returned url/path. Kept in sync with DynamicForm.
@@ -199,7 +199,13 @@ export function AddonLoader({
199
199
  }, [scope, url, module, addonKey, unbindKey, hostRegistry])
200
200
 
201
201
  if (status === 'loading') return <>{fallback}</>
202
- if (status === 'error')
202
+ if (status === 'error') {
203
+ // Hosts that pass `onError` (toast + telemetry) should not also paint
204
+ // a second, persistent inline banner — DynamicAddonLoaders mounts one
205
+ // fiber per installed addon and a visible error stack reads as a broken
206
+ // shell even when only one remote failed transiently.
207
+ if (onError) return null
203
208
  return <div className="text-sm text-red-500">Addon load error: {error?.message}</div>
209
+ }
204
210
  return <>{children}</>
205
211
  }