shutro-erp 0.3.2 → 0.3.4

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 (156) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +156 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +156 -1
  5. package/dist/assistant/index.js +1 -1
  6. package/dist/contacts/.kosh/bindings.js +1 -1
  7. package/dist/contacts/.kosh/schema.d.ts +156 -1
  8. package/dist/integrations/index.js +12 -0
  9. package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
  10. package/dist/integrations/web/OAuthAppsPage.js +79 -0
  11. package/dist/integrations/web/index.d.ts +1 -0
  12. package/dist/integrations/web/index.js +3 -0
  13. package/dist/integrations/web/messages.js +58 -0
  14. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  15. package/dist/l10n_bd/.kosh/schema.d.ts +156 -1
  16. package/dist/l10n_in/.kosh/bindings.js +1 -1
  17. package/dist/l10n_in/.kosh/schema.d.ts +156 -4
  18. package/dist/l10n_in/module.d.ts +0 -62
  19. package/dist/l10n_in/module.js +2 -4
  20. package/dist/messaging/web/index.js +1 -1
  21. package/dist/notify/web/index.js +1 -1
  22. package/dist/payment/.kosh/bindings.js +1 -1
  23. package/dist/payment/.kosh/schema.d.ts +157 -2
  24. package/dist/payment/bkash.d.ts +28 -0
  25. package/dist/payment/bkash.js +169 -0
  26. package/dist/payment/index.d.ts +4 -0
  27. package/dist/payment/index.js +80 -24
  28. package/dist/payment/module.d.ts +7 -4
  29. package/dist/payment/module.js +3 -0
  30. package/dist/payment/money.d.ts +7 -0
  31. package/dist/payment/money.js +37 -0
  32. package/dist/payment/providers.d.ts +13 -2
  33. package/dist/payment/razorpay.d.ts +12 -0
  34. package/dist/payment/razorpay.js +149 -0
  35. package/dist/payment/sslcommerz.js +1 -1
  36. package/dist/payment/stripe.d.ts +15 -0
  37. package/dist/payment/stripe.js +182 -0
  38. package/dist/permissions/admin.d.ts +3 -0
  39. package/dist/permissions/admin.js +108 -10
  40. package/dist/permissions/index.d.ts +1 -0
  41. package/dist/permissions/index.js +3 -0
  42. package/dist/permissions/roles.d.ts +2 -0
  43. package/dist/permissions/roles.js +236 -0
  44. package/dist/portal/.kosh/bindings.js +1 -1
  45. package/dist/portal/.kosh/schema.d.ts +156 -1
  46. package/dist/product/.kosh/bindings.js +1 -1
  47. package/dist/product/.kosh/schema.d.ts +156 -1
  48. package/dist/project/.kosh/bindings.js +1 -1
  49. package/dist/project/.kosh/schema.d.ts +156 -1
  50. package/dist/project_portal/.kosh/bindings.js +1 -1
  51. package/dist/project_portal/.kosh/schema.d.ts +156 -1
  52. package/dist/purchase/.kosh/bindings.js +1 -1
  53. package/dist/purchase/.kosh/schema.d.ts +156 -1
  54. package/dist/purchase_account/.kosh/bindings.js +1 -1
  55. package/dist/purchase_account/.kosh/schema.d.ts +156 -1
  56. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  57. package/dist/purchase_portal/.kosh/schema.d.ts +156 -1
  58. package/dist/sale_account/.kosh/bindings.js +1 -1
  59. package/dist/sale_account/.kosh/schema.d.ts +156 -1
  60. package/dist/sale_portal/.kosh/bindings.js +1 -1
  61. package/dist/sale_portal/.kosh/schema.d.ts +156 -1
  62. package/dist/sale_project/.kosh/bindings.js +1 -1
  63. package/dist/sale_project/.kosh/schema.d.ts +156 -1
  64. package/dist/sales/.kosh/bindings.js +1 -1
  65. package/dist/sales/.kosh/schema.d.ts +156 -1
  66. package/dist/server/auth/base.js +98 -0
  67. package/dist/server/auth/oauth.d.ts +21 -0
  68. package/dist/server/auth/oauth.js +579 -0
  69. package/dist/server/auth/routes.d.ts +3 -0
  70. package/dist/server/auth/routes.js +29 -0
  71. package/dist/server/auth/service.d.ts +56 -0
  72. package/dist/server/auth/service.js +185 -3
  73. package/dist/server/identity.d.ts +4 -0
  74. package/dist/server/images.d.ts +12 -0
  75. package/dist/server/images.js +139 -0
  76. package/dist/server/index.d.ts +2 -1
  77. package/dist/server/index.js +1 -0
  78. package/dist/server/server.d.ts +4 -0
  79. package/dist/server/server.js +20 -4
  80. package/dist/settings/company.d.ts +34 -0
  81. package/dist/settings/company.js +189 -0
  82. package/dist/settings/index.d.ts +10 -10
  83. package/dist/settings/index.js +159 -30
  84. package/dist/settings/people.d.ts +16 -0
  85. package/dist/settings/people.js +507 -0
  86. package/dist/settings/security.d.ts +13 -0
  87. package/dist/settings/security.js +158 -0
  88. package/dist/settings/web/AuditPage.d.ts +8 -0
  89. package/dist/settings/web/AuditPage.js +74 -0
  90. package/dist/settings/web/CompaniesPage.d.ts +8 -0
  91. package/dist/settings/web/CompaniesPage.js +52 -0
  92. package/dist/settings/web/CompanyPage.d.ts +9 -0
  93. package/dist/settings/web/CompanyPage.js +105 -0
  94. package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
  95. package/dist/settings/web/ConnectedAppsPage.js +33 -0
  96. package/dist/settings/web/OverviewPage.d.ts +9 -0
  97. package/dist/settings/web/OverviewPage.js +77 -0
  98. package/dist/settings/web/PeoplePage.d.ts +9 -0
  99. package/dist/settings/web/PeoplePage.js +154 -0
  100. package/dist/settings/web/ProfilePage.d.ts +14 -0
  101. package/dist/settings/web/ProfilePage.js +196 -0
  102. package/dist/settings/web/RolesEditor.d.ts +35 -0
  103. package/dist/settings/web/RolesEditor.js +49 -0
  104. package/dist/settings/web/RolesPage.d.ts +9 -0
  105. package/dist/settings/web/RolesPage.js +296 -0
  106. package/dist/settings/web/SecurityPage.d.ts +8 -0
  107. package/dist/settings/web/SecurityPage.js +81 -0
  108. package/dist/settings/web/index.d.ts +10 -0
  109. package/dist/settings/web/index.js +31 -1
  110. package/dist/settings/web/messages.js +594 -0
  111. package/dist/ui/api.d.ts +8 -0
  112. package/dist/ui/app.d.ts +5 -0
  113. package/dist/ui/app.js +143 -33
  114. package/dist/ui/components/AppIcon.js +11 -1
  115. package/dist/ui/components/Avatar.d.ts +21 -0
  116. package/dist/ui/components/Avatar.js +71 -0
  117. package/dist/ui/components/primitives.d.ts +9 -1
  118. package/dist/ui/components/primitives.js +12 -4
  119. package/dist/ui/context.d.ts +10 -1
  120. package/dist/ui/context.js +60 -2
  121. package/dist/ui/i18n/messages.d.ts +76 -0
  122. package/dist/ui/i18n/messages.js +152 -0
  123. package/dist/ui/index.d.ts +3 -1
  124. package/dist/ui/index.js +3 -1
  125. package/dist/ui/lib/accent.d.ts +13 -0
  126. package/dist/ui/lib/accent.js +36 -0
  127. package/dist/ui/registry.d.ts +7 -1
  128. package/dist/ui/registry.js +10 -1
  129. package/dist/ui/search/ViewTabs.d.ts +14 -0
  130. package/dist/ui/search/ViewTabs.js +42 -0
  131. package/dist/ui/shell/Activate.d.ts +6 -0
  132. package/dist/ui/shell/Activate.js +39 -0
  133. package/dist/ui/shell/CommandPalette.d.ts +7 -1
  134. package/dist/ui/shell/CommandPalette.js +16 -2
  135. package/dist/ui/shell/Consent.d.ts +5 -0
  136. package/dist/ui/shell/Consent.js +35 -0
  137. package/dist/ui/shell/Home.js +85 -16
  138. package/dist/ui/shell/Sidebar.d.ts +27 -0
  139. package/dist/ui/shell/Sidebar.js +77 -0
  140. package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
  141. package/dist/ui/shell/TwoFactorSetup.js +32 -0
  142. package/dist/ui/shell/shortcuts.d.ts +3 -0
  143. package/dist/ui/shell/shortcuts.js +13 -4
  144. package/dist/ui/styles.css +70 -28
  145. package/dist/ui/views/DateField.d.ts +10 -0
  146. package/dist/ui/views/DateField.js +129 -0
  147. package/dist/ui/views/FormView.js +161 -21
  148. package/dist/ui/views/ListView.d.ts +2 -0
  149. package/dist/ui/views/ListView.js +57 -25
  150. package/dist/ui/views/StatusChip.d.ts +10 -0
  151. package/dist/ui/views/StatusChip.js +22 -0
  152. package/dist/ui/views/widgets.d.ts +3 -0
  153. package/dist/ui/views/widgets.js +26 -8
  154. package/dist/views/types.d.ts +7 -0
  155. package/dist/views/validate.js +4 -0
  156. package/package.json +2 -2
@@ -27,6 +27,11 @@ import { PrefetchedNames, useRowNames } from './widgets.js';
27
27
  const features = tableFeatures({});
28
28
  const helper = createColumnHelper();
29
29
  const HEIGHTS = { comfortable: 40, compact: 32 };
30
+ /** The record's page in a new tab: the list's address with the record's id (`/<app>/a/<action>/<id>`). */
31
+ function openInNewTab(id) {
32
+ const path = window.location.pathname.replace(/\/$/, '');
33
+ window.open(`${path}/${id}`, '_blank', 'noopener');
34
+ }
30
35
  /** Requests a bulk action sends at once. */
31
36
  const PARALLEL = 6;
32
37
  /** How long a bulk delete waits for Undo before it runs. */
@@ -188,7 +193,6 @@ function Rows({ props, where, columns, fields, offset, limit, height, selection,
188
193
  offset,
189
194
  count: true,
190
195
  });
191
- onCount?.(result.count ?? result.items.length);
192
196
  rememberList(model, result.items.map((r) => r.id), offset, result.count ?? result.items.length);
193
197
  return result;
194
198
  },
@@ -199,6 +203,11 @@ function Rows({ props, where, columns, fields, offset, limit, height, selection,
199
203
  if (query.data !== undefined)
200
204
  onRows?.(query.data.items);
201
205
  }, [query.data, onRows]);
206
+ // From the data, not the fetch: a search answered from the cache (back to All) has its count too.
207
+ useEffect(() => {
208
+ if (query.data !== undefined)
209
+ onCount?.(query.data.count ?? query.data.items.length);
210
+ }, [query.data, onCount]);
202
211
  const names = useRowNames(items, Object.values(fields));
203
212
  const tableColumns = useMemo(() => helper.columns(columns.map((node) => helper.accessor((row) => row[node.field], {
204
213
  id: node.id,
@@ -488,7 +497,11 @@ function Rows({ props, where, columns, fields, offset, limit, height, selection,
488
497
  if (query.isError)
489
498
  return (_jsx(Alert, { action: _jsx(Button, { size: "sm", onClick: () => void query.refetch(), children: t('app.retry') }), children: query.error instanceof ApiError ? query.error.message : t('app.error') }));
490
499
  if (items.length === 0)
491
- return (_jsx(EmptyState, { icon: _jsx(Inbox, { "aria-hidden": true }), title: filtered === true ? t('views.emptyFiltered') : t('views.emptyTitle'), ...(filtered === true ? {} : { description: t('views.emptyHint') }), ...(props.served.operations.create && offset === 0
500
+ return (_jsx(EmptyState, { icon: _jsx(Inbox, { "aria-hidden": true }), title: filtered === true
501
+ ? t('views.emptyFiltered')
502
+ : props.served.view.label === undefined
503
+ ? t('views.emptyTitle')
504
+ : t('views.emptyNamed', { name: props.served.view.label.toLocaleLowerCase() }), ...(filtered === true ? {} : { description: t('views.emptyHint') }), ...(props.served.operations.create && offset === 0
492
505
  ? {
493
506
  action: (_jsxs(Button, { variant: "primary", size: "sm", onClick: () => props.onOpen('new'), children: [_jsx(Plus, { "aria-hidden": true }), t('views.createFirst')] })),
494
507
  }
@@ -525,8 +538,20 @@ function Rows({ props, where, columns, fields, offset, limit, height, selection,
525
538
  return null;
526
539
  const id = row.original.id;
527
540
  const isSelected = selection?.selected.has(id) === true;
528
- return (_jsxs("tr", { tabIndex: 0, "aria-selected": selection === undefined ? undefined : isSelected, style: { height }, className: cn('cursor-pointer border-b hover:bg-muted/60 focus-visible:bg-muted', isSelected && 'bg-accent/60 hover:bg-accent/70'), onClick: () => props.onOpen(id), onKeyDown: (e) => {
529
- if (e.key === 'Enter')
541
+ return (_jsxs("tr", { tabIndex: 0, "aria-selected": selection === undefined ? undefined : isSelected, style: { height }, className: cn('cursor-pointer border-b hover:bg-muted/60 focus-visible:bg-muted', isSelected && 'bg-accent/60 hover:bg-accent/70'),
542
+ // Ctrl / Cmd + click or the middle button: the record in a new tab, the list stays.
543
+ onClick: (e) => {
544
+ if (e.ctrlKey || e.metaKey)
545
+ openInNewTab(id);
546
+ else
547
+ props.onOpen(id);
548
+ }, onAuxClick: (e) => {
549
+ if (e.button === 1)
550
+ openInNewTab(id);
551
+ }, onKeyDown: (e) => {
552
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey))
553
+ openInNewTab(id);
554
+ else if (e.key === 'Enter')
530
555
  props.onOpen(id);
531
556
  // Space on the row itself selects it (the checkbox handles its own keys).
532
557
  if (e.key === ' ' && selection !== undefined && e.target === e.currentTarget) {
@@ -597,7 +622,7 @@ function Totals({ props, columns, fields, }) {
597
622
  : v === null || v === undefined
598
623
  ? '0'
599
624
  : String(v);
600
- return (_jsxs("div", { className: "flex gap-2", children: [_jsxs("dt", { className: "text-muted-foreground", children: [t('views.total'), " ", c.label ?? fields[c.field]?.label ?? c.field] }), _jsx("dd", { className: "font-medium tabular-nums", children: format.decimal(amount) })] }, c.id));
625
+ return (_jsxs("div", { className: "flex gap-2", children: [_jsx("dt", { className: "text-muted-foreground", children: totalLabel(t('views.total'), c.label ?? fields[c.field]?.label ?? c.field) }), _jsx("dd", { className: "font-medium tabular-nums", children: format.decimal(amount) })] }, c.id));
601
626
  }) }));
602
627
  }
603
628
  function Groups({ props, columns, fields, height, }) {
@@ -653,6 +678,12 @@ function saveFile(blob, filename) {
653
678
  setTimeout(() => URL.revokeObjectURL(url), 1000);
654
679
  }
655
680
  /** The default list renderer. */
681
+ /** A sum's label: "Total Credit limit", but "Total" alone for a column already called "Total" (no "Total Total"). */
682
+ export function totalLabel(word, label) {
683
+ return label.toLocaleLowerCase().startsWith(word.toLocaleLowerCase())
684
+ ? label
685
+ : `${word} ${label}`;
686
+ }
656
687
  export function ListView(props) {
657
688
  const { api, uid } = useKosh();
658
689
  const { t } = useTranslation();
@@ -669,7 +700,7 @@ export function ListView(props) {
669
700
  const allColumns = view.columns.filter((c) => fields[c.field] !== undefined);
670
701
  const columnsKey = `kosh.columns.${uid}.${view.id}`;
671
702
  const [hidden, setHidden] = useState(() => new Set(stored.get(columnsKey, [])));
672
- const [density, setDensity] = useState(() => stored.get('kosh.density', 'comfortable'));
703
+ const { density, setDensity } = useKosh();
673
704
  const height = HEIGHTS[density];
674
705
  const columns = allColumns.filter((c) => !hidden.has(c.id));
675
706
  const design = useDesign();
@@ -808,25 +839,26 @@ export function ListView(props) {
808
839
  tone: 'danger',
809
840
  }),
810
841
  });
811
- const toolbar = ids.length > 0 ? (_jsxs("div", { role: "toolbar", "aria-label": t('views.selected', { count: ids.length }), className: "flex flex-wrap items-center gap-2 border-b bg-accent/40 px-3 py-2 text-sm", children: [_jsx("span", { className: "font-medium", children: t('views.selected', { count: ids.length }) }), _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.clearSelection'), onClick: () => setSelected(new Set()), children: _jsx(X, { "aria-hidden": true }) }), _jsx("span", { className: "flex-1" }), transitions.map((tr) => (_jsx(Button, { size: "sm", busy: transition.isPending && transition.variables?.name === tr.name, onClick: () => transition.mutate({ name: tr.name, what: ids }), children: tr.name.charAt(0).toUpperCase() + tr.name.slice(1).replace(/_/g, ' ') }, tr.name))), archivable ? (_jsxs(Button, { size: "sm", busy: archive.isPending, onClick: () => archive.mutate(ids), children: [_jsx(Archive, { "aria-hidden": true }), t('views.archive')] })) : null, served.operations.unlink ? (_jsxs(Button, { size: "sm", variant: "danger", onClick: () => setConfirmingDelete(true), children: [_jsx(Trash2, { "aria-hidden": true }), t('views.delete')] })) : null, canExport ? (_jsxs(Button, { size: "sm", busy: exportAs.isPending, onClick: () => exportAs.mutate('csv'), children: [_jsx(Download, { "aria-hidden": true }), t('views.export')] })) : null] })) : (_jsxs("div", { className: "flex flex-wrap items-center gap-2 border-b px-3 py-1.5 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: count === null ? (_jsx(Skeleton, { className: "inline-block h-4 w-20" })) : (t('views.records', { count })) }), _jsx("span", { className: "flex-1" }), design.active ? (_jsxs(Button, { size: "sm", variant: "ghost", className: "border border-dashed border-primary/50 text-primary", onClick: () => design.choose({
812
- kind: 'list',
813
- view: view.id,
814
- model,
815
- after: allColumns.at(-1)?.id ?? '',
816
- shown: allColumns.map((c) => c.field),
817
- }), children: [_jsx(Plus, { "aria-hidden": true }), t('views.design.addColumn')] })) : null, _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.density'), "aria-pressed": density === 'compact', title: density === 'compact' ? t('views.comfortable') : t('views.compact'), onClick: () => {
818
- const next = density === 'compact' ? 'comfortable' : 'compact';
819
- setDensity(next);
820
- stored.set('kosh.density', next);
821
- }, children: _jsx(Rows3, { "aria-hidden": true }) }), _jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.columns'), children: _jsx(Columns3, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "end", children: [_jsx(MenuLabel, { children: t('views.columns') }), allColumns.map((c) => (_jsx(MenuCheckboxItem, { checked: !hidden.has(c.id), onCheckedChange: (checked) => {
822
- const next = new Set(hidden);
823
- if (checked)
824
- next.delete(c.id);
825
- else if (next.size < allColumns.length - 1)
826
- next.add(c.id);
827
- setHidden(next);
828
- stored.set(columnsKey, [...next]);
829
- }, children: c.label ?? fields[c.field]?.label ?? c.field }, c.id)))] })] }), canExport ? (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.export'), busy: exportAs.isPending, children: _jsx(Download, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "end", children: [_jsx(MenuLabel, { children: t('views.export') }), _jsx(MenuItem, { onSelect: () => exportAs.mutate('csv'), children: t('views.exportCsv') }), _jsx(MenuItem, { onSelect: () => exportAs.mutate('xlsx'), children: t('views.exportXlsx') })] })] })) : null] }));
842
+ // Selected rows: a floating bar at the bottom of the screen (RFC 0018 §4.3); the list's own toolbar stays put.
843
+ const floating = ids.length > 0 ? (_jsxs("div", { role: "toolbar", "aria-label": t('views.selected', { count: ids.length }), "data-kosh-selection-bar": true, className: "fixed inset-x-0 bottom-4 z-30 mx-auto flex w-fit max-w-[calc(100vw-2rem)] flex-wrap items-center gap-2 rounded-xl border bg-surface px-3 py-2 text-sm shadow-lg", children: [_jsx("span", { className: "font-medium", children: t('views.selected', { count: ids.length }) }), _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.clearSelection'), onClick: () => setSelected(new Set()), children: _jsx(X, { "aria-hidden": true }) }), _jsx("span", { className: "flex-1" }), transitions.map((tr) => (_jsx(Button, { size: "sm", busy: transition.isPending && transition.variables?.name === tr.name, onClick: () => transition.mutate({ name: tr.name, what: ids }), children: tr.name.charAt(0).toUpperCase() + tr.name.slice(1).replace(/_/g, ' ') }, tr.name))), archivable ? (_jsxs(Button, { size: "sm", busy: archive.isPending, onClick: () => archive.mutate(ids), children: [_jsx(Archive, { "aria-hidden": true }), t('views.archive')] })) : null, served.operations.unlink ? (_jsxs(Button, { size: "sm", variant: "danger", onClick: () => setConfirmingDelete(true), children: [_jsx(Trash2, { "aria-hidden": true }), t('views.delete')] })) : null, canExport ? (_jsxs(Button, { size: "sm", busy: exportAs.isPending, onClick: () => exportAs.mutate('csv'), children: [_jsx(Download, { "aria-hidden": true }), t('views.export')] })) : null] })) : null;
844
+ const toolbar = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 border-b px-3 py-1.5 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: count === null ? (_jsx(Skeleton, { className: "inline-block h-4 w-20" })) : (t('views.records', { count })) }), _jsx("span", { className: "flex-1" }), design.active ? (_jsxs(Button, { size: "sm", variant: "ghost", className: "border border-dashed border-primary/50 text-primary", onClick: () => design.choose({
845
+ kind: 'list',
846
+ view: view.id,
847
+ model,
848
+ after: allColumns.at(-1)?.id ?? '',
849
+ shown: allColumns.map((c) => c.field),
850
+ }), children: [_jsx(Plus, { "aria-hidden": true }), t('views.design.addColumn')] })) : null, _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.density'), "aria-pressed": density === 'compact', title: density === 'compact' ? t('views.comfortable') : t('views.compact'), onClick: () => {
851
+ const next = density === 'compact' ? 'comfortable' : 'compact';
852
+ setDensity(next);
853
+ }, children: _jsx(Rows3, { "aria-hidden": true }) }), _jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.columns'), children: _jsx(Columns3, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "end", children: [_jsx(MenuLabel, { children: t('views.columns') }), allColumns.map((c) => (_jsx(MenuCheckboxItem, { checked: !hidden.has(c.id), onCheckedChange: (checked) => {
854
+ const next = new Set(hidden);
855
+ if (checked)
856
+ next.delete(c.id);
857
+ else if (next.size < allColumns.length - 1)
858
+ next.add(c.id);
859
+ setHidden(next);
860
+ stored.set(columnsKey, [...next]);
861
+ }, children: c.label ?? fields[c.field]?.label ?? c.field }, c.id)))] })] }), canExport ? (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.export'), busy: exportAs.isPending, children: _jsx(Download, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "end", children: [_jsx(MenuLabel, { children: t('views.export') }), _jsx(MenuItem, { onSelect: () => exportAs.mutate('csv'), children: t('views.exportCsv') }), _jsx(MenuItem, { onSelect: () => exportAs.mutate('xlsx'), children: t('views.exportXlsx') })] })] })) : null] }), floating] }));
830
862
  return (_jsxs("div", { className: "rounded-lg border bg-surface", children: [props.groupBy.length > 0 ? (_jsx(Groups, { props: props, columns: columns, fields: fields, height: height })) : (_jsxs(_Fragment, { children: [toolbar, _jsx(Rows, { props: props, where: props.where, columns: columns, fields: fields, offset: offset, limit: pageSize, height: height, selection: { selected, onToggle: toggle, onToggleAll: toggleAll }, hiddenIds: pendingDelete, onCount: setCount, onRows: setPageRows, filtered: filtered }), count !== null && count > pageSize ? (_jsxs("nav", { "aria-label": "Pagination", className: "flex items-center justify-end gap-2 border-t px-3 py-2 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: t('views.range', {
831
863
  from: offset + 1,
832
864
  to: Math.min(offset + pageSize, count),
@@ -0,0 +1,10 @@
1
+ import type { ReactElement } from 'react';
2
+ import type { ViewField } from '../api.js';
3
+ export declare function StatusChip({ field, value, transitions, }: {
4
+ readonly field: ViewField;
5
+ readonly value: unknown;
6
+ readonly transitions: readonly {
7
+ readonly from: readonly string[];
8
+ readonly to: string;
9
+ }[];
10
+ }): ReactElement;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A record's state as one chip in its meaning's tone (RFC 0018 §4.2), instead of a row of every state: it opens the
4
+ * whole workflow — the states passed, the current one, the ones a transition leads to next. The server serves the
5
+ * transitions; the chip only shows them.
6
+ */
7
+ import { Check, ChevronDown, Circle } from 'lucide-react';
8
+ import { useTranslation } from 'react-i18next';
9
+ import { badgeVariants, Popover, PopoverContent, PopoverTrigger, } from '../components/primitives.js';
10
+ import { cn } from '../lib/cn.js';
11
+ import { toneOf } from './widgets.js';
12
+ export function StatusChip({ field, value, transitions, }) {
13
+ const { t } = useTranslation();
14
+ const options = field.options ?? [];
15
+ const index = options.findIndex((o) => o.value === value);
16
+ const current = options[index];
17
+ const next = new Set(transitions.filter((tr) => tr.from.includes(String(value))).map((tr) => tr.to));
18
+ return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": t('views.statusOf', { field: field.label, state: current?.label ?? '—' }), className: cn(badgeVariants({ tone: toneOf(String(value ?? '')) }), 'gap-1.5 px-2.5 py-1 hover:brightness-95 focus-visible:outline-2'), children: [_jsx("span", { className: "size-1.5 rounded-full bg-current", "aria-hidden": true }), current?.label ?? '—', _jsx(ChevronDown, { className: "size-3.5 opacity-70", "aria-hidden": true })] }) }), _jsxs(PopoverContent, { className: "w-64", children: [_jsx("p", { className: "mb-2 px-2 text-xs font-medium text-muted-foreground", children: field.label }), _jsx("ol", { className: "grid gap-0.5", children: options.map((o, i) => {
19
+ const isCurrent = i === index;
20
+ return (_jsxs("li", { "aria-current": isCurrent ? 'step' : undefined, className: cn('flex items-center gap-2 rounded-md px-2 py-1.5 text-sm', isCurrent ? 'bg-accent font-medium text-accent-foreground' : 'text-foreground', i > index && !next.has(o.value) && 'text-muted-foreground'), children: [i < index ? (_jsx(Check, { className: "size-3.5 text-success", "aria-hidden": true })) : (_jsx(Circle, { className: cn('size-3.5', isCurrent && 'fill-current'), "aria-hidden": true })), _jsx("span", { className: "flex-1", children: o.label }), next.has(o.value) ? (_jsx("span", { className: "text-xs text-muted-foreground", children: t('views.nextState') })) : null] }, o.value));
21
+ }) })] })] }));
22
+ }
@@ -1,5 +1,7 @@
1
1
  import { type Context } from 'react';
2
2
  import type { Registry } from '../registry.js';
3
+ type Tone = 'info' | 'success' | 'danger' | 'warning' | 'neutral';
4
+ export declare function toneOf(value: string): Tone;
3
5
  /** Display names a list or kanban loaded in one batch per model, so cells do not query one by one. */
4
6
  export declare const PrefetchedNames: Context<ReadonlyMap<string, ReadonlyMap<number, string>>>;
5
7
  /** Display names of ids: from {@link PrefetchedNames}, else batched through the name-search endpoint (cached). */
@@ -12,3 +14,4 @@ export declare function useRowNames(rows: readonly Readonly<Record<string, unkno
12
14
  }[]): ReadonlyMap<string, ReadonlyMap<number, string>>;
13
15
  /** Registers the default widgets. */
14
16
  export declare function registerDefaultWidgets(registry: Registry): Registry;
17
+ export {};
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
11
11
  import { Badge, Checkbox, Input, Select, Textarea } from '../components/primitives.js';
12
12
  import { useKosh } from '../context.js';
13
13
  import { cn } from '../lib/cn.js';
14
+ import { DateField } from './DateField.js';
14
15
  const text = (v) => (v === null || v === undefined ? '' : String(v));
15
16
  function CharWidget({ id, value, readonly, onChange, field, invalid }) {
16
17
  if (onChange === undefined || readonly)
@@ -56,11 +57,11 @@ function BooleanWidget({ id, value, readonly, onChange, field }) {
56
57
  return _jsx("span", { children: value === true ? t('views.yes') : t('views.no') });
57
58
  return (_jsx(Checkbox, { id: id, checked: value === true, disabled: readonly, "aria-label": field.label, onCheckedChange: (c) => onChange(c === true) }));
58
59
  }
59
- function DateWidget({ id, value, readonly, onChange }) {
60
+ function DateWidget({ id, value, readonly, onChange, field, invalid }) {
60
61
  const { format } = useKosh();
61
62
  if (onChange === undefined || readonly)
62
63
  return _jsx("span", { children: format.date(value === null || value === undefined ? null : String(value)) });
63
- return (_jsx(Input, { id: id, type: "date", value: text(value).slice(0, 10), onChange: (e) => onChange(e.target.value === '' ? null : e.target.value) }));
64
+ return (_jsx(DateField, { id: id, value: value === null || value === undefined || value === '' ? null : text(value).slice(0, 10), onChange: onChange, required: field.required, ...(invalid === undefined ? {} : { invalid }) }));
64
65
  }
65
66
  function DatetimeWidget({ id, value, readonly, onChange }) {
66
67
  const { format } = useKosh();
@@ -80,14 +81,31 @@ function SelectionWidget({ id, value, readonly, onChange, field }) {
80
81
  return _jsx("span", { children: label });
81
82
  return (_jsx(Select, { id: id, label: field.label, value: value === null || value === undefined ? null : String(value), options: options, onChange: onChange }));
82
83
  }
83
- const TONES = ['info', 'success', 'danger', 'warning', 'neutral'];
84
+ /** What a state's value means, by the words ERP states use (`posted`, `to_approve`, `cancel`…). */
85
+ const MEANINGS = [
86
+ [
87
+ /(^|_)(cancel|cancelled|canceled|failed|refused|rejected|lost|blocked|overdue|error|bounced)($|_)/,
88
+ 'danger',
89
+ ],
90
+ [
91
+ /(^|_)(to|not|partial|partially|pending|waiting|wait|hold|review|approve|quarantine)($|_)/,
92
+ 'warning',
93
+ ],
94
+ [
95
+ /(^|_)(done|paid|posted|sale|confirmed|approved|won|delivered|validated|completed|complete|reconciled|invoiced|handled|active|accepted|signed|locked)($|_)/,
96
+ 'success',
97
+ ],
98
+ [/(^|_)(sent|open|progress|received|in|scheduled|running|purchase|ordered)($|_)/, 'info'],
99
+ ];
100
+ export function toneOf(value) {
101
+ const v = value.toLowerCase();
102
+ return MEANINGS.find(([pattern]) => pattern.test(v))?.[1] ?? 'neutral';
103
+ }
84
104
  function BadgeWidget({ value, field }) {
85
105
  if (value === null || value === undefined || value === '')
86
106
  return null;
87
- const options = field.options ?? [];
88
- const index = options.findIndex((o) => o.value === value);
89
- const label = options[index]?.label ?? text(value);
90
- return _jsx(Badge, { tone: TONES[Math.max(index, 0) % TONES.length] ?? 'neutral', children: label });
107
+ const label = (field.options ?? []).find((o) => o.value === value)?.label ?? text(value);
108
+ return _jsx(Badge, { tone: toneOf(text(value)), children: label });
91
109
  }
92
110
  /** Display names a list or kanban loaded in one batch per model, so cells do not query one by one. */
93
111
  export const PrefetchedNames = createContext(new Map());
@@ -166,7 +184,7 @@ function Many2oneWidget({ id, value, readonly, onChange, field, invalid, }) {
166
184
  const label = current === null ? '' : (names.get(current) ?? `#${current}`);
167
185
  if (onChange === undefined || readonly)
168
186
  return _jsx("span", { children: label });
169
- return (_jsxs(Popover.Root, { open: open, onOpenChange: setOpen, children: [_jsx(Popover.Trigger, { asChild: true, children: _jsx("button", { id: id, type: "button", role: "combobox", "aria-expanded": open, "aria-controls": listId, "aria-invalid": invalid === true ? true : undefined, className: "flex h-9 w-full items-center justify-between rounded-md border border-input bg-surface px-3 text-start text-sm aria-invalid:border-danger", children: _jsx("span", { className: cn(current === null && 'text-muted-foreground'), children: current === null ? t('views.choose') : label }) }) }), _jsx(Popover.Portal, { children: _jsxs(Popover.Content, { align: "start", sideOffset: 4, className: "z-50 w-[var(--radix-popover-trigger-width)] rounded-md border bg-surface p-1 shadow-lg", children: [_jsx(Input, { autoFocus: true, "aria-label": field.label, placeholder: t('search.placeholder'), value: q, onChange: (e) => setQ(e.target.value) }), _jsxs("div", { id: listId, role: "listbox", "aria-label": field.label, className: "mt-1 max-h-60 overflow-auto", children: [field.required ? null : (_jsx("button", { type: "button", role: "option", "aria-selected": current === null, className: "w-full rounded-sm px-2 py-1.5 text-start text-sm text-muted-foreground hover:bg-muted", onClick: () => {
187
+ return (_jsxs(Popover.Root, { open: open, onOpenChange: setOpen, children: [_jsx(Popover.Trigger, { asChild: true, children: _jsx("button", { id: id, type: "button", role: "combobox", "aria-expanded": open, "aria-controls": listId, "aria-invalid": invalid === true ? true : undefined, className: "flex h-[var(--control-h)] w-full items-center justify-between rounded-md border border-input bg-surface px-3 text-start text-sm aria-invalid:border-danger", children: _jsx("span", { className: cn(current === null && 'text-muted-foreground'), children: current === null ? t('views.choose') : label }) }) }), _jsx(Popover.Portal, { children: _jsxs(Popover.Content, { align: "start", sideOffset: 4, className: "z-50 w-[var(--radix-popover-trigger-width)] rounded-md border bg-surface p-1 shadow-lg", children: [_jsx(Input, { autoFocus: true, "aria-label": field.label, placeholder: t('search.placeholder'), value: q, onChange: (e) => setQ(e.target.value) }), _jsxs("div", { id: listId, role: "listbox", "aria-label": field.label, className: "mt-1 max-h-60 overflow-auto", children: [field.required ? null : (_jsx("button", { type: "button", role: "option", "aria-selected": current === null, className: "w-full rounded-sm px-2 py-1.5 text-start text-sm text-muted-foreground hover:bg-muted", onClick: () => {
170
188
  onChange(null);
171
189
  setOpen(false);
172
190
  }, children: t('views.none') })), (results.data ?? []).map((r) => (_jsx("button", { type: "button", role: "option", "aria-selected": r.id === current, className: "w-full rounded-sm px-2 py-1.5 text-start text-sm hover:bg-muted", onClick: () => {
@@ -96,6 +96,13 @@ export interface FormView extends ViewBase {
96
96
  readonly readonlyWhen?: Condition;
97
97
  /** Messages and activities (RFC 0006). */
98
98
  readonly chatter?: boolean;
99
+ /**
100
+ * The key facts under the title (RFC 0018 §4.2): the customer, the date, the total. Default: the first many2one,
101
+ * date and monetary fields of the form.
102
+ */
103
+ readonly subtitle?: readonly string[];
104
+ /** The fields of the right rail's Summary (RFC 0018 §4.2). Default: the group named `totals`. */
105
+ readonly summary?: readonly string[];
99
106
  }
100
107
  export interface KanbanCard {
101
108
  readonly title: string;
@@ -58,6 +58,10 @@ export function validateView(view, models) {
58
58
  if (node.kind === 'button' && !info.transitions.includes(node.transition))
59
59
  problems.push(`unknown transition '${node.transition}' (has ${info.transitions.join(', ') || 'none'})`);
60
60
  }
61
+ if (view.type === 'form')
62
+ for (const key of [...(view.subtitle ?? []), ...(view.summary ?? [])])
63
+ if (!info.fields.has(key))
64
+ problems.push(`unknown field '${key}' in the subtitle or summary`);
61
65
  if (view.type === 'form' && view.header.statusbar !== undefined) {
62
66
  const kind = info.fields.get(view.header.statusbar)?.kind;
63
67
  if (kind !== 'state' && kind !== 'selection')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shutro-erp",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Shutro ERP (Kosh): a modern ERP framework on TypeScript, PostgreSQL and the Shutro ORM — typed end to end, permissions enforced on the server, metadata-driven views, reporting, documents and a chatter on every record.",
5
5
  "keywords": [
6
6
  "erp",
@@ -289,7 +289,7 @@
289
289
  "postal-mime": "^4.1.4",
290
290
  "radix-ui": "^1.8.1",
291
291
  "sanitize-html": "^2.18.1",
292
- "shutro": "^0.3.2",
292
+ "shutro": "^0.3.3",
293
293
  "tailwind-merge": "^3.7.0",
294
294
  "web-push": "^3.6.7"
295
295
  },