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.
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +156 -1
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +156 -1
- package/dist/assistant/index.js +1 -1
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +156 -1
- package/dist/integrations/index.js +12 -0
- package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
- package/dist/integrations/web/OAuthAppsPage.js +79 -0
- package/dist/integrations/web/index.d.ts +1 -0
- package/dist/integrations/web/index.js +3 -0
- package/dist/integrations/web/messages.js +58 -0
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +156 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +156 -4
- package/dist/l10n_in/module.d.ts +0 -62
- package/dist/l10n_in/module.js +2 -4
- package/dist/messaging/web/index.js +1 -1
- package/dist/notify/web/index.js +1 -1
- package/dist/payment/.kosh/bindings.js +1 -1
- package/dist/payment/.kosh/schema.d.ts +157 -2
- package/dist/payment/bkash.d.ts +28 -0
- package/dist/payment/bkash.js +169 -0
- package/dist/payment/index.d.ts +4 -0
- package/dist/payment/index.js +80 -24
- package/dist/payment/module.d.ts +7 -4
- package/dist/payment/module.js +3 -0
- package/dist/payment/money.d.ts +7 -0
- package/dist/payment/money.js +37 -0
- package/dist/payment/providers.d.ts +13 -2
- package/dist/payment/razorpay.d.ts +12 -0
- package/dist/payment/razorpay.js +149 -0
- package/dist/payment/sslcommerz.js +1 -1
- package/dist/payment/stripe.d.ts +15 -0
- package/dist/payment/stripe.js +182 -0
- package/dist/permissions/admin.d.ts +3 -0
- package/dist/permissions/admin.js +108 -10
- package/dist/permissions/index.d.ts +1 -0
- package/dist/permissions/index.js +3 -0
- package/dist/permissions/roles.d.ts +2 -0
- package/dist/permissions/roles.js +236 -0
- package/dist/portal/.kosh/bindings.js +1 -1
- package/dist/portal/.kosh/schema.d.ts +156 -1
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +156 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +156 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +156 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +156 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +156 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +156 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +156 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +156 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +156 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +156 -1
- package/dist/server/auth/base.js +98 -0
- package/dist/server/auth/oauth.d.ts +21 -0
- package/dist/server/auth/oauth.js +579 -0
- package/dist/server/auth/routes.d.ts +3 -0
- package/dist/server/auth/routes.js +29 -0
- package/dist/server/auth/service.d.ts +56 -0
- package/dist/server/auth/service.js +185 -3
- package/dist/server/identity.d.ts +4 -0
- package/dist/server/images.d.ts +12 -0
- package/dist/server/images.js +139 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +1 -0
- package/dist/server/server.d.ts +4 -0
- package/dist/server/server.js +20 -4
- package/dist/settings/company.d.ts +34 -0
- package/dist/settings/company.js +189 -0
- package/dist/settings/index.d.ts +10 -10
- package/dist/settings/index.js +159 -30
- package/dist/settings/people.d.ts +16 -0
- package/dist/settings/people.js +507 -0
- package/dist/settings/security.d.ts +13 -0
- package/dist/settings/security.js +158 -0
- package/dist/settings/web/AuditPage.d.ts +8 -0
- package/dist/settings/web/AuditPage.js +74 -0
- package/dist/settings/web/CompaniesPage.d.ts +8 -0
- package/dist/settings/web/CompaniesPage.js +52 -0
- package/dist/settings/web/CompanyPage.d.ts +9 -0
- package/dist/settings/web/CompanyPage.js +105 -0
- package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
- package/dist/settings/web/ConnectedAppsPage.js +33 -0
- package/dist/settings/web/OverviewPage.d.ts +9 -0
- package/dist/settings/web/OverviewPage.js +77 -0
- package/dist/settings/web/PeoplePage.d.ts +9 -0
- package/dist/settings/web/PeoplePage.js +154 -0
- package/dist/settings/web/ProfilePage.d.ts +14 -0
- package/dist/settings/web/ProfilePage.js +196 -0
- package/dist/settings/web/RolesEditor.d.ts +35 -0
- package/dist/settings/web/RolesEditor.js +49 -0
- package/dist/settings/web/RolesPage.d.ts +9 -0
- package/dist/settings/web/RolesPage.js +296 -0
- package/dist/settings/web/SecurityPage.d.ts +8 -0
- package/dist/settings/web/SecurityPage.js +81 -0
- package/dist/settings/web/index.d.ts +10 -0
- package/dist/settings/web/index.js +31 -1
- package/dist/settings/web/messages.js +594 -0
- package/dist/ui/api.d.ts +8 -0
- package/dist/ui/app.d.ts +5 -0
- package/dist/ui/app.js +143 -33
- package/dist/ui/components/AppIcon.js +11 -1
- package/dist/ui/components/Avatar.d.ts +21 -0
- package/dist/ui/components/Avatar.js +71 -0
- package/dist/ui/components/primitives.d.ts +9 -1
- package/dist/ui/components/primitives.js +12 -4
- package/dist/ui/context.d.ts +10 -1
- package/dist/ui/context.js +60 -2
- package/dist/ui/i18n/messages.d.ts +76 -0
- package/dist/ui/i18n/messages.js +152 -0
- package/dist/ui/index.d.ts +3 -1
- package/dist/ui/index.js +3 -1
- package/dist/ui/lib/accent.d.ts +13 -0
- package/dist/ui/lib/accent.js +36 -0
- package/dist/ui/registry.d.ts +7 -1
- package/dist/ui/registry.js +10 -1
- package/dist/ui/search/ViewTabs.d.ts +14 -0
- package/dist/ui/search/ViewTabs.js +42 -0
- package/dist/ui/shell/Activate.d.ts +6 -0
- package/dist/ui/shell/Activate.js +39 -0
- package/dist/ui/shell/CommandPalette.d.ts +7 -1
- package/dist/ui/shell/CommandPalette.js +16 -2
- package/dist/ui/shell/Consent.d.ts +5 -0
- package/dist/ui/shell/Consent.js +35 -0
- package/dist/ui/shell/Home.js +85 -16
- package/dist/ui/shell/Sidebar.d.ts +27 -0
- package/dist/ui/shell/Sidebar.js +77 -0
- package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
- package/dist/ui/shell/TwoFactorSetup.js +32 -0
- package/dist/ui/shell/shortcuts.d.ts +3 -0
- package/dist/ui/shell/shortcuts.js +13 -4
- package/dist/ui/styles.css +70 -28
- package/dist/ui/views/DateField.d.ts +10 -0
- package/dist/ui/views/DateField.js +129 -0
- package/dist/ui/views/FormView.js +161 -21
- package/dist/ui/views/ListView.d.ts +2 -0
- package/dist/ui/views/ListView.js +57 -25
- package/dist/ui/views/StatusChip.d.ts +10 -0
- package/dist/ui/views/StatusChip.js +22 -0
- package/dist/ui/views/widgets.d.ts +3 -0
- package/dist/ui/views/widgets.js +26 -8
- package/dist/views/types.d.ts +7 -0
- package/dist/views/validate.js +4 -0
- 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,
|
|
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 {
|
|
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
|
-
|
|
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 >
|
|
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
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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: () =>
|
|
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-
|
|
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-
|
|
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-
|
|
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'
|
package/dist/ui/context.d.ts
CHANGED
|
@@ -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). */
|