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
package/dist/ui/app.js CHANGED
@@ -12,28 +12,35 @@ import { compileSearch, decodeSearch, encodeSearch } from '../search/index.js';
12
12
  import { onlineManager, QueryClient, QueryClientProvider, useQuery, useQueryClient, } from '@tanstack/react-query';
13
13
  import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
14
14
  import { createRootRoute, createRoute, createRouter, Link, Navigate, Outlet, RouterProvider, useNavigate, useParams, useSearch, } from '@tanstack/react-router';
15
- import { CalendarDays, ChartColumn, ChartGantt, Columns3, Grid2x2, Grid3x3, Languages, LayoutDashboard, List, LogOut, Map as MapIcon, Monitor, Moon, Network, Plus, Search, Sheet, Sun, Table2, UserRound, } from 'lucide-react';
15
+ import { CalendarDays, ChartColumn, ChartGantt, ChevronDown, ChevronRight, ChevronsUpDown, Columns3, Grid2x2, House, Languages, LayoutDashboard, List, LogOut, Map as MapIcon, Monitor, Moon, Network, PanelLeft, Plus, Search, Sheet, Sun, Table2, } from 'lucide-react';
16
16
  import { Direction } from 'radix-ui';
17
17
  import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
18
18
  import { I18nextProvider, useTranslation } from 'react-i18next';
19
19
  import { ApiError } from './api.js';
20
- import { Alert, Button, Menu, MenuCheckboxItem, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTrigger, Select, Spinner, } from './components/primitives.js';
20
+ import { Avatar, imageUrl } from './components/Avatar.js';
21
+ import { Alert, Button, Menu, MenuCheckboxItem, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTrigger, Spinner, } from './components/primitives.js';
21
22
  import { KoshProvider, useKosh } from './context.js';
22
23
  import { DesignProvider } from './design.js';
23
24
  import { addMessages, createI18n, directionOf } from './i18n/index.js';
24
25
  import { LANGUAGES } from './i18n/messages.js';
26
+ import { cn } from './lib/cn.js';
25
27
  import { claimOfflineCache, createOfflinePersister, dropOfflineCache, OFFLINE_MAX_AGE_MS, offlinePersistOptions, } from './offline/persist.js';
26
28
  import { OfflineQueue } from './offline/queue.js';
27
29
  import { setPrefill, takePrefill } from './prefill.js';
28
30
  import { onAppUpdate, registerServiceWorker } from './pwa/register.js';
29
31
  import { createRealtimeClient } from './realtime.js';
30
32
  import { SearchBar } from './search/SearchBar.js';
33
+ import { ViewTabs } from './search/ViewTabs.js';
34
+ import { Activate } from './shell/Activate.js';
31
35
  import { CommandPalette, PaletteContext } from './shell/CommandPalette.js';
36
+ import { Consent } from './shell/Consent.js';
32
37
  import { Home } from './shell/Home.js';
33
38
  import { Login } from './shell/Login.js';
34
39
  import { OfflineMenu } from './shell/OfflineMenu.js';
35
40
  import { rememberRecord } from './shell/recent.js';
41
+ import { Sidebar, SidebarDrawer, useSidebarCollapsed } from './shell/Sidebar.js';
36
42
  import { ShortcutsHelp, useShortcuts } from './shell/shortcuts.js';
43
+ import { TwoFactorSetup } from './shell/TwoFactorSetup.js';
37
44
  import { ToastProvider, useToast } from './shell/toast.js';
38
45
  import { neighbours } from './views/listMemory.js';
39
46
  /**
@@ -100,6 +107,11 @@ function useServices() {
100
107
  throw new Error('Shutro ERP routes render inside <KoshApp>.');
101
108
  return value;
102
109
  }
110
+ /** The app's language and its switch (as the language menu does: direction, the server's labels). */
111
+ export function useLanguage() {
112
+ const { lang, setLang } = useServices();
113
+ return { lang, setLang };
114
+ }
103
115
  // ------------------------------------------------------------------------------------------------- shell
104
116
  function firstAction(menus) {
105
117
  for (const m of menus) {
@@ -111,61 +123,89 @@ function firstAction(menus) {
111
123
  }
112
124
  return undefined;
113
125
  }
114
- function MenuLinks({ app, menus, }) {
115
- const navigate = useNavigate();
116
- const entries = menus.flatMap((root) => (root.action === undefined ? root.children : [root]));
117
- return (_jsx("ul", { className: "flex flex-wrap items-center gap-1", children: entries.map((m) => m.children.length > 0 ? (_jsx("li", { children: _jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "sm", children: m.label }) }), _jsx(MenuContent, { align: "start", children: m.children.map((c) => c.action === undefined ? null : (_jsx(MenuItem, { onSelect: () => void navigate({
118
- to: '/$app/a/$action',
119
- params: { app, action: c.action },
120
- }), children: c.label }, c.id))) })] }) }, m.id)) : m.action === undefined ? null : (_jsx("li", { children: _jsx(Link, { to: "/$app/a/$action", params: { app, action: m.action }, className: "rounded-md px-3 py-1.5 text-sm hover:bg-muted", activeProps: { 'aria-current': 'page', className: 'bg-muted font-medium' }, children: m.label }) }, m.id))) }));
121
- }
122
126
  function ThemeMenu() {
123
127
  const { t } = useTranslation();
124
- const { theme, setTheme } = useKosh();
128
+ const { theme, setTheme, density, setDensity } = useKosh();
125
129
  const icons = { light: Sun, dark: Moon, system: Monitor };
126
130
  const Icon = icons[theme];
127
- return (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": t('shell.theme'), children: _jsx(Icon, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { children: [_jsx(MenuLabel, { children: t('shell.theme') }), ['light', 'dark', 'system'].map((choice) => (_jsx(MenuCheckboxItem, { checked: theme === choice, onCheckedChange: () => setTheme(choice), children: t(`shell.${choice}`) }, choice)))] })] }));
131
+ return (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": t('shell.theme'), children: _jsx(Icon, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { children: [_jsx(MenuLabel, { children: t('shell.theme') }), ['light', 'dark', 'system'].map((choice) => (_jsx(MenuCheckboxItem, { checked: theme === choice, onCheckedChange: () => setTheme(choice), children: t(`shell.${choice}`) }, choice))), _jsx(MenuSeparator, {}), _jsx(MenuLabel, { children: t('shell.density') }), ['comfortable', 'compact'].map((choice) => (_jsx(MenuCheckboxItem, { checked: density === choice, onCheckedChange: () => setDensity(choice), children: t(`views.${choice}`) }, choice)))] })] }));
128
132
  }
129
133
  function LanguageMenu() {
130
134
  const { t } = useTranslation();
131
135
  const { lang, setLang } = useServices();
132
136
  return (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": t('shell.language'), children: _jsx(Languages, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { children: [_jsx(MenuLabel, { children: t('shell.language') }), LANGUAGES.map((l) => (_jsx(MenuCheckboxItem, { checked: lang === l.code, onCheckedChange: () => setLang(l.code), children: _jsx("span", { lang: l.code.replace('_', '-'), children: l.name }) }, l.code)))] })] }));
133
137
  }
134
- function CompanySwitcher({ me, company, onChange, }) {
138
+ /** The company at the top of the sidebar: its initial and name, or the switcher when the user has several. */
139
+ function CompanyBlock({ me, company, collapsed, onChange, }) {
135
140
  const { t } = useTranslation();
136
141
  const { api } = useServices();
137
142
  const names = useQuery({
138
143
  queryKey: ['names', 'res.company', me.companies],
139
144
  queryFn: () => api.names('res.company', { ids: me.companies }),
140
- enabled: me.companies.length > 1,
145
+ enabled: me.companies.length > 0,
141
146
  });
147
+ const name = names.data?.find((c) => c.id === company)?.displayName ?? t('app.name');
148
+ // The company's logo, else its initial.
149
+ const badge = (_jsx(Avatar, { name: name.slice(0, 1), src: imageUrl('company', company, me.logos?.[String(company)]), shape: "square", className: "font-semibold" }));
150
+ if (collapsed)
151
+ return _jsx("span", { title: name, children: badge });
142
152
  if (me.companies.length < 2)
143
- return null;
144
- return (_jsx(Select, { label: t('shell.company'), className: "h-8 w-48", value: String(company), options: (names.data ?? []).map((c) => ({ value: String(c.id), label: c.displayName })), onChange: (v) => onChange(Number(v)) }));
153
+ return (_jsxs("div", { className: "flex items-center gap-2.5 px-1 py-1", children: [badge, _jsx("span", { className: "truncate text-sm font-semibold", children: name })] }));
154
+ // Several companies: a switcher (also in the palette: "Switch to …").
155
+ return (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": t('shell.switchCompany', { name }), className: "flex w-full items-center gap-2.5 rounded-md px-1 py-1 text-start hover:bg-muted", children: [badge, _jsx("span", { className: "min-w-0 flex-1 truncate text-sm font-semibold", children: name }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true })] }) }), _jsxs(MenuContent, { align: "start", className: "w-64", children: [_jsx(MenuLabel, { children: t('shell.company') }), (names.data ?? []).map((c) => (_jsx(MenuCheckboxItem, { checked: c.id === company, onCheckedChange: () => {
156
+ if (c.id !== company)
157
+ onChange(c.id);
158
+ }, children: _jsxs("span", { className: "flex items-center gap-2", children: [_jsx("span", { "aria-hidden": true, className: "grid size-5 place-items-center rounded bg-muted text-[10px] font-semibold", children: c.displayName.slice(0, 1).toUpperCase() }), c.displayName] }) }, c.id)))] })] }));
145
159
  }
146
- function Shell() {
147
- const { api, registry, lang, realtime, offline, persister } = useServices();
160
+ function UserMenu({ user, collapsed, corner, }) {
148
161
  const { t } = useTranslation();
162
+ const { api, registry, persister, lang, setLang } = useServices();
163
+ const { theme, setTheme, density, setDensity } = useKosh();
149
164
  const queryClient = useQueryClient();
150
165
  const navigate = useNavigate();
166
+ return (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": user.name, "data-kosh-account": corner === undefined ? 'sidebar' : 'corner', className: cn('flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1.5 text-sm hover:bg-muted', corner === undefined ? 'w-full' : 'rounded-full border bg-surface pe-3', collapsed && 'justify-center'), children: [_jsx(Avatar, { name: user.name, src: imageUrl('user', user.id, user.avatar) }), collapsed ? null : _jsx("span", { className: "truncate", children: user.name }), corner === undefined ? null : (_jsx(ChevronDown, { className: "size-3.5 text-muted-foreground", "aria-hidden": true }))] }) }), _jsxs(MenuContent, { align: corner === undefined ? 'start' : 'end', side: corner === undefined ? 'top' : 'bottom', className: corner === undefined ? undefined : 'max-h-[80vh] w-64 overflow-y-auto', children: [_jsxs(MenuLabel, { children: [user.name, _jsx("span", { className: "block text-xs font-normal text-muted-foreground", children: user.login })] }), registry.userPages().map((p) => (_jsx(MenuItem, { onSelect: () => void navigate({ to: '/me/$page', params: { page: p.name } }), children: t(p.label) }, p.name))), corner === undefined ? null : (_jsxs(_Fragment, { children: [corner.companies.length < 2 ? null : (_jsxs(_Fragment, { children: [_jsx(MenuSeparator, {}), _jsx(MenuLabel, { children: t('shell.company') }), corner.companies.map((c) => (_jsx(MenuCheckboxItem, { checked: c.id === corner.current, onCheckedChange: () => {
167
+ if (c.id !== corner.current)
168
+ corner.onSwitch(c.id);
169
+ }, children: c.name }, c.id)))] })), _jsx(MenuSeparator, {}), _jsx(MenuLabel, { children: t('shell.language') }), LANGUAGES.map((l) => (_jsx(MenuCheckboxItem, { checked: lang === l.code, onCheckedChange: () => setLang(l.code), children: _jsx("span", { lang: l.code.replace('_', '-'), children: l.name }) }, l.code))), _jsx(MenuSeparator, {}), _jsx(MenuLabel, { children: t('shell.theme') }), ['light', 'dark', 'system'].map((choice) => (_jsx(MenuCheckboxItem, { checked: theme === choice, onCheckedChange: () => setTheme(choice), children: t(`shell.${choice}`) }, choice))), _jsx(MenuLabel, { children: t('shell.density') }), ['comfortable', 'compact'].map((choice) => (_jsx(MenuCheckboxItem, { checked: density === choice, onCheckedChange: () => setDensity(choice), children: t(`views.${choice}`) }, choice)))] })), _jsx(MenuSeparator, {}), _jsxs(MenuItem, { onSelect: async () => {
170
+ await api.logout();
171
+ queryClient.clear();
172
+ await dropOfflineCache(persister);
173
+ await navigate({ to: '/login' });
174
+ }, children: [_jsx(LogOut, { className: "size-4", "aria-hidden": true }), t('auth.signOut')] })] })] }));
175
+ }
176
+ function Shell() {
177
+ const { api, registry, lang, realtime, offline } = useServices();
178
+ const { t } = useTranslation();
179
+ const queryClient = useQueryClient();
151
180
  const params = useParams({ strict: false });
152
181
  const me = useQuery({ queryKey: ['me'], queryFn: () => api.me(), retry: false });
153
182
  const [company, setCompany] = useState(undefined);
154
183
  const [palette, setPalette] = useState(false);
155
184
  const [help, setHelp] = useState(false);
156
- const shortcutHandlers = useMemo(() => ({ palette: () => setPalette(true), help: () => setHelp(true) }), []);
185
+ const [drawer, setDrawer] = useState(false);
186
+ const [collapsed, setCollapsed] = useSidebarCollapsed();
187
+ const shortcutHandlers = useMemo(() => ({
188
+ palette: () => setPalette(true),
189
+ help: () => setHelp(true),
190
+ sidebar: () => setCollapsed(!collapsed),
191
+ }), [collapsed, setCollapsed]);
157
192
  useShortcuts(shortcutHandlers);
158
193
  const openHelp = useCallback(() => setHelp(true), []);
159
194
  const openPalette = useCallback(() => setPalette(true), []);
160
195
  const apps = useQuery({
161
196
  queryKey: ['apps', company],
162
197
  queryFn: () => api.apps(),
163
- enabled: me.isSuccess,
198
+ enabled: me.isSuccess && me.data.twoFactorSetupRequired !== true,
199
+ });
200
+ const companyNames = useQuery({
201
+ queryKey: ['names', 'res.company', me.data?.companies],
202
+ queryFn: () => api.names('res.company', { ids: me.data?.companies ?? [] }),
203
+ enabled: (me.data?.companies.length ?? 0) > 1,
164
204
  });
165
205
  const menus = useQuery({
166
206
  queryKey: ['menus', params.app, company],
167
207
  queryFn: () => api.menus(params.app),
168
- enabled: me.isSuccess && params.app !== undefined,
208
+ enabled: me.isSuccess && me.data.twoFactorSetupRequired !== true && params.app !== undefined,
169
209
  });
170
210
  if (me.error instanceof ApiError && me.error.status === 401)
171
211
  return _jsx(Navigate, { to: "/login" });
@@ -174,19 +214,57 @@ function Shell() {
174
214
  return _jsx(Alert, { children: me.error.message });
175
215
  if (me.data === undefined)
176
216
  return _jsx(Spinner, { label: t('app.loading') });
217
+ // The policy asks everyone for a second factor (RFC 0019 §3.5): nothing else until it is set up.
218
+ if (me.data.twoFactorSetupRequired === true)
219
+ return _jsx(TwoFactorSetup, { api: api });
177
220
  const user = me.data;
178
221
  const current = company ?? user.company ?? user.companies[0] ?? 0;
179
222
  const app = apps.data?.find((a) => a.code === params.app);
180
- return (_jsx(KoshProvider, { api: api, registry: registry, uid: user.id, companyId: current, lang: lang, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, grouping: user.groupings?.[String(current)] ?? 'international', ...(realtime === undefined ? {} : { realtime }), ...(offline === undefined ? {} : { offline }), children: _jsx(ToastProvider, { children: _jsxs(DesignProvider, { children: [_jsx(OfflineAgent, { uid: user.id }), _jsxs(PaletteContext.Provider, { value: openPalette, children: [_jsx("a", { href: "#content", className: "sr-only focus:not-sr-only focus:absolute focus:start-2 focus:top-2 focus:z-50 focus:rounded focus:bg-surface focus:px-3 focus:py-2", children: t('shell.skip') }), _jsx("header", { className: "sticky top-0 z-30 border-b bg-surface", children: _jsxs("div", { className: "flex h-12 items-center gap-2 px-3", children: [_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": t('shell.apps'), children: _jsx(Grid3x3, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "start", children: [_jsx(MenuItem, { onSelect: () => void navigate({ to: '/' }), children: t('shell.apps') }), _jsx(MenuSeparator, {}), (apps.data ?? []).map((a) => (_jsx(MenuItem, { onSelect: () => void navigate({ to: '/$app', params: { app: a.code } }), children: a.label }, a.code)))] })] }), _jsx(Link, { to: "/", className: "font-semibold", children: app?.label ?? t('app.name') }), _jsx("nav", { "aria-label": t('shell.menu'), className: "ms-2 flex-1", children: params.app === undefined ? null : (_jsx(MenuLinks, { app: params.app, menus: menus.data ?? [] })) }), _jsxs(Button, { variant: "secondary", size: "sm", className: "hidden h-8 w-56 justify-between text-muted-foreground sm:flex", onClick: () => setPalette(true), "aria-keyshortcuts": "Control+K Meta+K", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Search, { className: "size-4", "aria-hidden": true }), t('palette.button')] }), _jsx("kbd", { className: "rounded border bg-surface px-1.5 text-xs", children: "Ctrl K" })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "sm:hidden", "aria-label": t('palette.button'), onClick: () => setPalette(true), children: _jsx(Search, { "aria-hidden": true }) }), _jsx(OfflineMenu, {}), registry.headerItems().map(({ name, component: Item }) => (_jsx(Item, { api: api }, name))), _jsx(CompanySwitcher, { me: user, company: current, onChange: (id) => {
181
- api.setCompany(id);
182
- setCompany(id);
183
- void queryClient.invalidateQueries();
184
- } }), _jsx(LanguageMenu, {}), _jsx(ThemeMenu, {}), _jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": user.name, children: _jsx(UserRound, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { children: [_jsx(MenuLabel, { children: user.name }), registry.userPages().map((p) => (_jsx(MenuItem, { onSelect: () => void navigate({ to: '/me/$page', params: { page: p.name } }), children: t(p.label) }, p.name))), _jsxs(MenuItem, { onSelect: async () => {
185
- await api.logout();
186
- queryClient.clear();
187
- await dropOfflineCache(persister);
188
- await navigate({ to: '/login' });
189
- }, children: [_jsx(LogOut, { className: "size-4", "aria-hidden": true }), t('auth.signOut')] })] })] })] }) }), user.administratorNeedsSecondFactor ? (_jsx("div", { className: "px-4 pt-3", children: _jsx(Alert, { tone: "info", children: t('auth.adminNeeds2fa') }) })) : null, _jsx("main", { id: "content", className: "mx-auto w-full max-w-[100rem] p-4", children: _jsx(Outlet, {}) }), _jsx(CommandPalette, { open: palette, onOpenChange: setPalette, api: api, onShortcuts: openHelp }), _jsx(ShortcutsHelp, { open: help, onOpenChange: setHelp })] })] }) }) }));
223
+ // A page of the user menu (`/me/<page>`): the page bar names it.
224
+ const userPage = params.page === undefined
225
+ ? undefined
226
+ : registry.userPages().find((p) => p.name === params.page);
227
+ /** The open section's label (the menu entry of the current action), for the page bar. */
228
+ const section = (() => {
229
+ const walk = (nodes) => {
230
+ for (const n of nodes) {
231
+ if (n.action !== undefined && n.action === params.action)
232
+ return n.label;
233
+ const found = walk(n.children);
234
+ if (found !== undefined)
235
+ return found;
236
+ }
237
+ return undefined;
238
+ };
239
+ return params.action === undefined ? undefined : walk(menus.data ?? []);
240
+ })();
241
+ const switchCompany = (id) => {
242
+ setPalette(false);
243
+ api.setCompany(id);
244
+ setCompany(id);
245
+ void queryClient.invalidateQueries();
246
+ };
247
+ const sidebar = (inDrawer) => (_jsx(Sidebar, { api: api, apps: apps.data ?? [], current: params.app, company: current, collapsed: inDrawer ? false : collapsed, onCollapse: inDrawer ? undefined : setCollapsed, onSearch: () => {
248
+ setDrawer(false);
249
+ setPalette(true);
250
+ }, top: _jsx(CompanyBlock, { me: user, company: current, collapsed: inDrawer ? false : collapsed, onChange: switchCompany }), bottom: _jsx(UserMenu, { user: user, collapsed: inDrawer ? false : collapsed }), onNavigate: inDrawer ? () => setDrawer(false) : undefined }));
251
+ return (_jsx(KoshProvider, { api: api, registry: registry, uid: user.id, companyId: current, lang: lang, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, grouping: user.groupings?.[String(current)] ?? 'international', accent: user.accents?.[String(current)] ?? null, ...(realtime === undefined ? {} : { realtime }), ...(offline === undefined ? {} : { offline }), switchCompany: switchCompany, children: _jsx(ToastProvider, { children: _jsxs(DesignProvider, { children: [_jsx(OfflineAgent, { uid: user.id }), _jsxs(PaletteContext.Provider, { value: openPalette, children: [_jsx("a", { href: "#content", className: "sr-only focus:not-sr-only focus:absolute focus:start-2 focus:top-2 focus:z-50 focus:rounded focus:bg-surface focus:px-3 focus:py-2", children: t('shell.skip') }), params.app === undefined ? (_jsxs("div", { className: "flex min-h-screen flex-col", children: [_jsxs("div", { className: "flex h-14 items-center gap-2 px-4 lg:px-6", children: [userPage === undefined ? null : (_jsxs(Link, { to: "/", className: "flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(House, { className: "size-4", "aria-hidden": true }), t('shell.home')] })), _jsx("span", { className: "flex-1" }), registry
252
+ .headerItems()
253
+ .filter((item) => item.home)
254
+ .map(({ name, component: Item }) => (_jsx(Item, { api: api }, name))), _jsx(UserMenu, { user: user, collapsed: false, corner: {
255
+ companies: (companyNames.data ?? []).map((c) => ({
256
+ id: c.id,
257
+ name: c.displayName,
258
+ })),
259
+ current,
260
+ onSwitch: switchCompany,
261
+ } })] }), user.administratorNeedsSecondFactor ? (_jsx("div", { className: "px-4 pt-3", children: _jsx(Alert, { tone: "info", children: t('auth.adminNeeds2fa') }) })) : null, _jsx("main", { id: "content", className: "mx-auto w-full max-w-[100rem] flex-1 p-4 lg:p-6", children: _jsx(Outlet, {}) })] })) : (_jsxs("div", { className: "flex min-h-screen", children: [_jsx("aside", { "aria-label": t('shell.sidebar'), className: cn('sticky top-0 hidden h-screen shrink-0 border-e bg-surface transition-[width] lg:block', collapsed ? 'w-14' : 'w-60'), children: sidebar(false) }), _jsx(SidebarDrawer, { open: drawer, onOpenChange: setDrawer, children: sidebar(true) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("header", { className: "sticky top-0 z-30 border-b bg-surface", children: _jsxs("div", { className: "flex h-12 items-center gap-2 px-3 lg:px-4", children: [_jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden", "aria-label": t('shell.openMenu'), onClick: () => setDrawer(true), children: _jsx(PanelLeft, { className: "rtl:rotate-180", "aria-hidden": true }) }), _jsx("nav", { "aria-label": t('shell.location'), className: "flex min-w-0 flex-1 items-center gap-1.5 text-sm", children: app === undefined ? (_jsx("span", { className: "font-semibold", children: userPage !== undefined
262
+ ? t(userPage.label)
263
+ : params.app === undefined
264
+ ? t('shell.home')
265
+ : t('app.name') })) : (_jsxs(_Fragment, { children: [_jsx(Link, { to: "/$app", params: { app: app.code }, className: "font-semibold hover:underline", children: app.label }), section === undefined ? null : (_jsxs(_Fragment, { children: [_jsx(ChevronRight, { className: "size-3.5 text-muted-foreground rtl:rotate-180", "aria-hidden": true }), _jsx(Link, { to: "/$app/a/$action", params: { app: app.code, action: params.action ?? '' }, className: "truncate text-muted-foreground hover:text-foreground hover:underline", children: section })] }))] })) }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden", "aria-label": t('palette.button'), onClick: () => setPalette(true), children: _jsx(Search, { "aria-hidden": true }) }), _jsx(OfflineMenu, {}), registry.headerItems().map(({ name, component: Item }) => (_jsx(Item, { api: api }, name))), _jsx(LanguageMenu, {}), _jsx(ThemeMenu, {})] }) }), user.administratorNeedsSecondFactor ? (_jsx("div", { className: "px-4 pt-3", children: _jsx(Alert, { tone: "info", children: t('auth.adminNeeds2fa') }) })) : null, _jsx("main", { id: "content", className: "mx-auto w-full max-w-[100rem] flex-1 p-4 lg:p-6", children: _jsx(Outlet, {}) })] })] })), _jsx(CommandPalette, { open: palette, onOpenChange: setPalette, api: api, onShortcuts: openHelp, companies: (companyNames.data ?? [])
266
+ .filter((c) => c.id !== current)
267
+ .map((c) => ({ id: c.id, name: c.displayName })), onSwitchCompany: switchCompany }), _jsx(ShortcutsHelp, { open: help, onOpenChange: setHelp })] })] }) }) }));
190
268
  }
191
269
  function AppHome() {
192
270
  const { api } = useServices();
@@ -323,7 +401,7 @@ function WindowActionPage() {
323
401
  params: { app, action: actionId },
324
402
  search: { ...search, view: v },
325
403
  }), children: _jsx(Icon, { "aria-hidden": true }) }, v));
326
- })] })) : null] }), _jsx(SearchBar, { model: model, viewType: type, spec: spec.data, state: state, onChange: setState, labels: labels }), Renderer === undefined ? (_jsx(Alert, { children: `No renderer for '${served.data.view.component ?? type}'.` })) : (_jsx(Renderer, { served: served.data, where: compiled.where, groupBy: compiled.groupBy, order: compiled.order, onOrder: (order) => setState({ ...state, order }), onOpen: open, onDrill: drill }))] }));
404
+ })] })) : null] }), _jsx(ViewTabs, { model: model, viewType: type, state: state, onChange: setState }), _jsx(SearchBar, { model: model, viewType: type, spec: spec.data, state: state, onChange: setState, labels: labels }), Renderer === undefined ? (_jsx(Alert, { children: `No renderer for '${served.data.view.component ?? type}'.` })) : (_jsx(Renderer, { served: served.data, where: compiled.where, groupBy: compiled.groupBy, order: compiled.order, onOrder: (order) => setState({ ...state, order }), onOpen: open, onDrill: drill }))] }));
327
405
  }
328
406
  /** What the record page needs from the shell: the API, the user, the apps (for breadcrumbs). */
329
407
  function useRecordContext() {
@@ -410,9 +488,28 @@ function OpenRecordPage() {
410
488
  return _jsx(Alert, { children: t('app.notFound') });
411
489
  return (_jsx(Navigate, { to: "/$app/a/$action/$id", params: { app: route.app, action: route.action, id }, replace: true }));
412
490
  }
491
+ function ActivatePage() {
492
+ const navigate = useNavigate();
493
+ return _jsx(Activate, { api: useServices().api, onSignedIn: () => void navigate({ to: '/' }) });
494
+ }
495
+ /** Where to go after signing in: `?next=` when it is a path of this site (never another site), else home. */
496
+ function nextPath() {
497
+ const next = new URLSearchParams(window.location.search).get('next') ?? '';
498
+ return next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : '/';
499
+ }
413
500
  function LoginPage() {
414
501
  const navigate = useNavigate();
415
- return _jsx(Login, { api: useServices().api, onSignedIn: () => void navigate({ to: '/' }) });
502
+ return (_jsx(Login, { api: useServices().api, onSignedIn: () => {
503
+ const next = nextPath();
504
+ // A page outside the router's own paths with its query (the OAuth consent) loads as an address.
505
+ if (next.startsWith('/oauth/'))
506
+ window.location.assign(next);
507
+ else
508
+ void navigate({ to: next });
509
+ } }));
510
+ }
511
+ function ConsentPage() {
512
+ return _jsx(Consent, { api: useServices().api });
416
513
  }
417
514
  // ------------------------------------------------------------------------------------------------- router
418
515
  const rootRoute = createRootRoute({ component: Outlet });
@@ -421,6 +518,17 @@ const loginRoute = createRoute({
421
518
  path: '/login',
422
519
  component: LoginPage,
423
520
  });
521
+ const activateRoute = createRoute({
522
+ getParentRoute: () => rootRoute,
523
+ path: '/activate',
524
+ component: ActivatePage,
525
+ });
526
+ /** The OAuth consent page (RFC 0017 §5.2): outside the shell, like sign-in. */
527
+ const consentRoute = createRoute({
528
+ getParentRoute: () => rootRoute,
529
+ path: '/oauth/authorize',
530
+ component: ConsentPage,
531
+ });
424
532
  const shellRoute = createRoute({ getParentRoute: () => rootRoute, id: 'shell', component: Shell });
425
533
  const indexRoute = createRoute({
426
534
  getParentRoute: () => shellRoute,
@@ -469,6 +577,8 @@ const userPageRoute = createRoute({
469
577
  });
470
578
  const routeTree = rootRoute.addChildren([
471
579
  loginRoute,
580
+ activateRoute,
581
+ consentRoute,
472
582
  shellRoute.addChildren([
473
583
  indexRoute,
474
584
  openRoute,
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  * App icons by name (`app.icon` in a module manifest): the launcher's tiles and the App manager's cards. Names
4
4
  * follow Lucide; an unknown name shows a neutral grid, never a broken image.
5
5
  */
6
- import { BookOpen, Boxes, Briefcase, ChartColumn, FolderKanban, LayoutGrid, Mail, MessageSquare, MessagesSquare, Package, PencilRuler, Receipt, Ruler, Settings, ShieldCheck, ShoppingCart, Users, Wallet, Warehouse, Workflow, } from 'lucide-react';
6
+ import { BookOpen, Boxes, Briefcase, Building, Building2, ChartColumn, FolderKanban, LayoutGrid, LockKeyhole, Mail, MessageSquare, MessagesSquare, Package, PencilRuler, Plug, Receipt, Ruler, ScrollText, Settings, ShieldCheck, ShoppingCart, Sparkles, Users, Wallet, Warehouse, Workflow, } from 'lucide-react';
7
7
  import { cn } from '../lib/cn.js';
8
8
  const ICONS = {
9
9
  settings: Settings,
@@ -37,6 +37,16 @@ const ICONS = {
37
37
  shield: ShieldCheck,
38
38
  permissions: ShieldCheck,
39
39
  briefcase: Briefcase,
40
+ plug: Plug,
41
+ integrations: Plug,
42
+ cart: ShoppingCart,
43
+ company: Building2,
44
+ companies: Building,
45
+ security: LockKeyhole,
46
+ audit: ScrollText,
47
+ apps: LayoutGrid,
48
+ assistant: Sparkles,
49
+ sparkles: Sparkles,
40
50
  };
41
51
  /** A tinted square with the app's icon (decorative: the app's name is always shown next to it). */
42
52
  export function AppIcon({ name, className, }) {
@@ -0,0 +1,21 @@
1
+ import { type ReactElement } from 'react';
2
+ /** The URL of a photo (`user`) or logo (`company`), or null when there is none. */
3
+ export declare function imageUrl(kind: 'user' | 'company', id: number, version: string | null | undefined): string | null;
4
+ /** Up to two initials of a name. */
5
+ export declare function initialsOf(name: string): string;
6
+ export declare function Avatar({ name, src, shape, className, }: {
7
+ readonly name: string;
8
+ readonly src: string | null;
9
+ /** People are round; companies square. */
10
+ readonly shape?: 'circle' | 'square';
11
+ readonly className?: string;
12
+ }): ReactElement;
13
+ export declare function ImagePicker({ name, src, fit, onChoose, onRemove, busy, }: {
14
+ readonly name: string;
15
+ readonly src: string | null;
16
+ readonly fit: 'photo' | 'logo';
17
+ /** The image as a `data:` URL, already small. */
18
+ readonly onChoose: (dataUrl: string) => void;
19
+ readonly onRemove: () => void;
20
+ readonly busy?: boolean;
21
+ }): ReactElement;
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Photos and logos: `Avatar` shows a person's photo or a company's logo (the server's image route, versioned so the
4
+ * browser caches it), else the initials on a tint. `ImagePicker` chooses a file and makes it small in the browser —
5
+ * a 256 px square, cropped to the centre for photos, fitted whole for logos — before anything is sent.
6
+ */
7
+ import { ImageUp, Trash2 } from 'lucide-react';
8
+ import { useId, useRef, useState } from 'react';
9
+ import { useTranslation } from 'react-i18next';
10
+ import { cn } from '../lib/cn.js';
11
+ import { Button } from './primitives.js';
12
+ /** The URL of a photo (`user`) or logo (`company`), or null when there is none. */
13
+ export function imageUrl(kind, id, version) {
14
+ return version === null || version === undefined
15
+ ? null
16
+ : `/api/v1/images/${kind}/${id}?v=${version}`;
17
+ }
18
+ /** Up to two initials of a name. */
19
+ export function initialsOf(name) {
20
+ return name
21
+ .split(/\s+/)
22
+ .filter((w) => w !== '')
23
+ .map((w) => w.slice(0, 1))
24
+ .join('')
25
+ .slice(0, 2)
26
+ .toUpperCase();
27
+ }
28
+ export function Avatar({ name, src, shape = 'circle', className, }) {
29
+ const [failed, setFailed] = useState(false);
30
+ const round = shape === 'circle' ? 'rounded-full' : 'rounded-md';
31
+ if (src !== null && !failed)
32
+ return (_jsx("img", { src: src, alt: "", "aria-hidden": true, onError: () => setFailed(true), className: cn('size-7 shrink-0 bg-surface object-cover', shape === 'square' && 'object-contain', round, className) }));
33
+ return (_jsx("span", { "aria-hidden": true, className: cn('grid size-7 shrink-0 place-items-center bg-primary text-xs font-medium text-primary-foreground', round, className), children: initialsOf(name) }));
34
+ }
35
+ const SIZE = 256;
36
+ /** Draws `file` on a 256 px square: covered and centred (`photo`) or fitted whole (`logo`). */
37
+ async function shrink(file, fit) {
38
+ const bitmap = await createImageBitmap(file);
39
+ const canvas = document.createElement('canvas');
40
+ canvas.width = SIZE;
41
+ canvas.height = SIZE;
42
+ const ctx = canvas.getContext('2d');
43
+ if (ctx === null)
44
+ throw new Error('No canvas');
45
+ const scale = fit === 'photo'
46
+ ? Math.max(SIZE / bitmap.width, SIZE / bitmap.height)
47
+ : Math.min(SIZE / bitmap.width, SIZE / bitmap.height);
48
+ const w = bitmap.width * scale;
49
+ const h = bitmap.height * scale;
50
+ ctx.imageSmoothingQuality = 'high';
51
+ ctx.drawImage(bitmap, (SIZE - w) / 2, (SIZE - h) / 2, w, h);
52
+ bitmap.close();
53
+ // Photos as WebP (small); logos as PNG (they keep transparency and sharp edges).
54
+ return fit === 'photo' ? canvas.toDataURL('image/webp', 0.86) : canvas.toDataURL('image/png');
55
+ }
56
+ export function ImagePicker({ name, src, fit, onChoose, onRemove, busy, }) {
57
+ const { t } = useTranslation();
58
+ const id = useId();
59
+ const input = useRef(null);
60
+ const [error, setError] = useState(null);
61
+ return (_jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(Avatar, { name: name, src: src, shape: fit === 'photo' ? 'circle' : 'square', className: "size-16 text-lg" }), _jsxs("div", { className: "grid gap-1.5", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx("input", { ref: input, id: id, type: "file", accept: "image/png,image/jpeg,image/webp", className: "sr-only", onChange: (e) => {
62
+ const file = e.target.files?.[0];
63
+ e.target.value = '';
64
+ if (file === undefined)
65
+ return;
66
+ setError(null);
67
+ shrink(file, fit)
68
+ .then(onChoose)
69
+ .catch(() => setError(t('image.unreadable')));
70
+ } }), _jsxs(Button, { size: "sm", busy: busy === true, onClick: () => input.current?.click(), children: [_jsx(ImageUp, { "aria-hidden": true }), fit === 'photo' ? t('image.choosePhoto') : t('image.chooseLogo')] }), src === null ? null : (_jsxs(Button, { size: "sm", variant: "ghost", onClick: onRemove, children: [_jsx(Trash2, { "aria-hidden": true }), t('image.remove')] }))] }), _jsx("p", { className: "text-xs text-muted-foreground", children: error ?? t('image.hint') })] })] }));
71
+ }
@@ -1,4 +1,4 @@
1
- import { Checkbox as RCheckbox, DropdownMenu as RMenu, Tabs as RTabs } from 'radix-ui';
1
+ import { Checkbox as RCheckbox, DropdownMenu as RMenu, Popover as RPopover, Tabs as RTabs } from 'radix-ui';
2
2
  import type { ComponentProps, ReactElement, ReactNode } from 'react';
3
3
  export type ButtonProps = ComponentProps<'button'> & {
4
4
  readonly variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'link';
@@ -10,6 +10,10 @@ export declare function Button({ className, variant, size, asChild, busy, childr
10
10
  export declare function Input({ className, ...props }: ComponentProps<'input'>): ReactElement;
11
11
  export declare function Textarea({ className, ...props }: ComponentProps<'textarea'>): ReactElement;
12
12
  export declare function Label({ className, ...props }: ComponentProps<'label'>): ReactElement;
13
+ /** The badge's classes, for elements that look like one (the status chip). */
14
+ export declare const badgeVariants: (props?: {
15
+ readonly tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger' | null;
16
+ }) => string;
13
17
  export declare function Badge({ className, tone, ...props }: ComponentProps<'span'> & {
14
18
  readonly tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger' | null;
15
19
  }): ReactElement;
@@ -36,6 +40,10 @@ export declare function MenuCheckboxItem({ className, children, ...props }: Comp
36
40
  export declare function MenuLabel({ className, ...props }: ComponentProps<typeof RMenu.Label>): ReactElement;
37
41
  export declare function MenuSeparator(): ReactElement;
38
42
  /** A modal dialog with a title (required for screen readers) and a close button. */
43
+ /** A popover (Radix): focus moves in and back, Escape closes, positioned beside its trigger. */
44
+ export declare const Popover: typeof RPopover.Root;
45
+ export declare const PopoverTrigger: typeof RPopover.Trigger;
46
+ export declare function PopoverContent({ className, align, sideOffset, ...props }: ComponentProps<typeof RPopover.Content>): ReactElement;
39
47
  export declare function Dialog({ open, onOpenChange, title, description, children, footer, wide, }: {
40
48
  readonly open: boolean;
41
49
  readonly onOpenChange: (open: boolean) => void;
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  */
7
7
  import { cva } from 'class-variance-authority';
8
8
  import { Check, ChevronDown, Loader2, X } from 'lucide-react';
9
- import { Checkbox as RCheckbox, Dialog as RDialog, DropdownMenu as RMenu, Select as RSelect, Tabs as RTabs, Slot, } from 'radix-ui';
9
+ import { Checkbox as RCheckbox, Dialog as RDialog, DropdownMenu as RMenu, Popover as RPopover, Select as RSelect, Tabs as RTabs, Slot, } from 'radix-ui';
10
10
  import { cn } from '../lib/cn.js';
11
11
  const buttonVariants = cva('inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0', {
12
12
  variants: {
@@ -17,7 +17,7 @@ const buttonVariants = cva('inline-flex items-center justify-center gap-2 whites
17
17
  danger: 'bg-danger text-danger-foreground hover:bg-danger/90',
18
18
  link: 'text-primary underline-offset-4 hover:underline',
19
19
  },
20
- size: { sm: 'h-8 px-3', md: 'h-9 px-4', icon: 'size-9' },
20
+ size: { sm: 'h-8 px-3', md: 'h-[var(--control-h)] px-4', icon: 'size-[var(--control-h)]' },
21
21
  },
22
22
  defaultVariants: { variant: 'secondary', size: 'md' },
23
23
  });
@@ -27,7 +27,7 @@ export function Button({ className, variant, size, asChild, busy, children, disa
27
27
  }
28
28
  const field = 'w-full rounded-md border border-input bg-surface px-3 text-sm text-surface-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60 read-only:bg-muted aria-invalid:border-danger';
29
29
  export function Input({ className, ...props }) {
30
- return _jsx("input", { className: cn(field, 'h-9', className), ...props });
30
+ return _jsx("input", { className: cn(field, 'h-[var(--control-h)]', className), ...props });
31
31
  }
32
32
  export function Textarea({ className, ...props }) {
33
33
  return _jsx("textarea", { className: cn(field, 'min-h-20 py-2', className), ...props });
@@ -47,6 +47,8 @@ const badge = cva('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-
47
47
  },
48
48
  defaultVariants: { tone: 'neutral' },
49
49
  });
50
+ /** The badge's classes, for elements that look like one (the status chip). */
51
+ export const badgeVariants = badge;
50
52
  export function Badge({ className, tone, ...props }) {
51
53
  return _jsx("span", { className: cn(badge({ tone }), className), ...props });
52
54
  }
@@ -55,7 +57,7 @@ export function Checkbox({ className, ...props }) {
55
57
  }
56
58
  /** A select over `options` (`null` value: empty). */
57
59
  export function Select({ value, options, onChange, disabled, placeholder, id, label, className, }) {
58
- return (_jsxs(RSelect.Root, { ...(value === null ? {} : { value }), onValueChange: onChange, ...(disabled === true ? { disabled } : {}), children: [_jsxs(RSelect.Trigger, { ...(id === undefined ? {} : { id }), "aria-label": label, className: cn(field, 'flex h-9 items-center justify-between gap-2 text-start', className), children: [_jsx(RSelect.Value, { placeholder: placeholder }), _jsx(RSelect.Icon, { children: _jsx(ChevronDown, { className: "size-4 opacity-60", "aria-hidden": true }) })] }), _jsx(RSelect.Portal, { children: _jsx(RSelect.Content, { position: "popper", sideOffset: 4, className: "z-50 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-md border bg-surface text-surface-foreground shadow-lg", children: _jsx(RSelect.Viewport, { className: "p-1", children: options.map((o) => (_jsxs(RSelect.Item, { value: o.value, className: "relative flex cursor-default select-none items-center rounded-sm py-1.5 pe-2 ps-7 text-sm outline-none data-[highlighted]:bg-muted", children: [_jsx(RSelect.ItemIndicator, { className: "absolute start-2", children: _jsx(Check, { className: "size-3.5", "aria-hidden": true }) }), _jsx(RSelect.ItemText, { children: o.label })] }, o.value))) }) }) })] }));
60
+ return (_jsxs(RSelect.Root, { ...(value === null ? {} : { value }), onValueChange: onChange, ...(disabled === true ? { disabled } : {}), children: [_jsxs(RSelect.Trigger, { ...(id === undefined ? {} : { id }), "aria-label": label, className: cn(field, 'flex h-[var(--control-h)] items-center justify-between gap-2 text-start', className), children: [_jsx(RSelect.Value, { placeholder: placeholder }), _jsx(RSelect.Icon, { children: _jsx(ChevronDown, { className: "size-4 opacity-60", "aria-hidden": true }) })] }), _jsx(RSelect.Portal, { children: _jsx(RSelect.Content, { position: "popper", sideOffset: 4, className: "z-50 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-md border bg-surface text-surface-foreground shadow-lg", children: _jsx(RSelect.Viewport, { className: "p-1", children: options.map((o) => (_jsxs(RSelect.Item, { value: o.value, className: "relative flex cursor-default select-none items-center rounded-sm py-1.5 pe-2 ps-7 text-sm outline-none data-[highlighted]:bg-muted", children: [_jsx(RSelect.ItemIndicator, { className: "absolute start-2", children: _jsx(Check, { className: "size-3.5", "aria-hidden": true }) }), _jsx(RSelect.ItemText, { children: o.label })] }, o.value))) }) }) })] }));
59
61
  }
60
62
  export const Menu = RMenu.Root;
61
63
  export const MenuTrigger = RMenu.Trigger;
@@ -75,6 +77,12 @@ export function MenuSeparator() {
75
77
  return _jsx(RMenu.Separator, { className: "my-1 h-px bg-border" });
76
78
  }
77
79
  /** A modal dialog with a title (required for screen readers) and a close button. */
80
+ /** A popover (Radix): focus moves in and back, Escape closes, positioned beside its trigger. */
81
+ export const Popover = RPopover.Root;
82
+ export const PopoverTrigger = RPopover.Trigger;
83
+ export function PopoverContent({ className, align = 'start', sideOffset = 6, ...props }) {
84
+ return (_jsx(RPopover.Portal, { children: _jsx(RPopover.Content, { align: align, sideOffset: sideOffset, className: cn('z-50 rounded-lg border bg-surface p-3 text-surface-foreground shadow-lg outline-none', className), ...props }) }));
85
+ }
78
86
  export function Dialog({ open, onOpenChange, title, description, children, footer, wide, }) {
79
87
  return (_jsx(RDialog.Root, { open: open, onOpenChange: onOpenChange, children: _jsxs(RDialog.Portal, { children: [_jsx(RDialog.Overlay, { className: "fixed inset-0 z-40 bg-black/40" }), _jsxs(RDialog.Content, { className: cn('fixed start-1/2 top-1/2 z-50 -translate-y-1/2 rtl:translate-x-1/2 ltr:-translate-x-1/2 rounded-lg border bg-surface p-5 text-surface-foreground shadow-xl', wide === true
80
88
  ? 'max-h-[calc(100vh-2rem)] w-[min(56rem,calc(100vw-2rem))] overflow-y-auto'
@@ -17,16 +17,22 @@ export interface KoshContextValue {
17
17
  readonly format: Formatter;
18
18
  readonly theme: ThemeChoice;
19
19
  setTheme(theme: ThemeChoice): void;
20
+ /** Comfortable or compact (RFC 0018): lists, forms and controls. */
21
+ readonly density: Density;
22
+ setDensity(density: Density): void;
20
23
  /** The realtime gateway's client, when the app has one: live lists, records and presence. */
21
24
  readonly realtime?: RealtimeClient;
22
25
  /** Changes saved offline, when the app keeps them: a form that cannot reach the server saves here. */
23
26
  readonly offline?: OfflineQueue;
27
+ /** Switches to another of the user's companies, when the shell offers it (Settings › Companies). */
28
+ readonly switchCompany?: (company: number) => void;
24
29
  }
25
30
  /** The renderer context. @throws outside {@link KoshProvider} */
26
31
  export declare function useKosh(): KoshContextValue;
32
+ export type Density = 'comfortable' | 'compact';
27
33
  /** Applies the theme to `<html>` (the `dark` class) and follows the system when asked to. */
28
34
  declare function useTheme(): [ThemeChoice, (t: ThemeChoice) => void];
29
- export declare function KoshProvider({ api, registry, uid, companyId, lang, tz, grouping, realtime, offline, children, }: {
35
+ export declare function KoshProvider({ api, registry, uid, companyId, lang, tz, grouping, accent, realtime, offline, switchCompany, children, }: {
30
36
  readonly api: KoshApi;
31
37
  readonly registry: Registry;
32
38
  readonly uid: number;
@@ -35,8 +41,11 @@ export declare function KoshProvider({ api, registry, uid, companyId, lang, tz,
35
41
  readonly tz: string;
36
42
  /** The company's number grouping (RFC 0016). */
37
43
  readonly grouping?: 'international' | 'lakh';
44
+ /** The company's brand colour (`#rrggbb`, RFC 0018). */
45
+ readonly accent?: string | null;
38
46
  readonly realtime?: RealtimeClient;
39
47
  readonly offline?: OfflineQueue;
48
+ readonly switchCompany?: (company: number) => void;
40
49
  readonly children: ReactNode;
41
50
  }): ReactElement;
42
51
  /** Theme switching without a signed-in user (the sign-in page). */