shutro-erp 0.3.3 → 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 (85) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +128 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +128 -1
  5. package/dist/contacts/.kosh/bindings.js +1 -1
  6. package/dist/contacts/.kosh/schema.d.ts +128 -1
  7. package/dist/integrations/index.js +12 -0
  8. package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
  9. package/dist/integrations/web/OAuthAppsPage.js +79 -0
  10. package/dist/integrations/web/index.d.ts +1 -0
  11. package/dist/integrations/web/index.js +3 -0
  12. package/dist/integrations/web/messages.js +58 -0
  13. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  14. package/dist/l10n_bd/.kosh/schema.d.ts +128 -1
  15. package/dist/l10n_in/.kosh/bindings.js +1 -1
  16. package/dist/l10n_in/.kosh/schema.d.ts +128 -1
  17. package/dist/messaging/web/index.js +1 -1
  18. package/dist/notify/web/index.js +1 -1
  19. package/dist/payment/.kosh/bindings.js +1 -1
  20. package/dist/payment/.kosh/schema.d.ts +128 -1
  21. package/dist/portal/.kosh/bindings.js +1 -1
  22. package/dist/portal/.kosh/schema.d.ts +128 -1
  23. package/dist/product/.kosh/bindings.js +1 -1
  24. package/dist/product/.kosh/schema.d.ts +128 -1
  25. package/dist/project/.kosh/bindings.js +1 -1
  26. package/dist/project/.kosh/schema.d.ts +128 -1
  27. package/dist/project_portal/.kosh/bindings.js +1 -1
  28. package/dist/project_portal/.kosh/schema.d.ts +128 -1
  29. package/dist/purchase/.kosh/bindings.js +1 -1
  30. package/dist/purchase/.kosh/schema.d.ts +128 -1
  31. package/dist/purchase_account/.kosh/bindings.js +1 -1
  32. package/dist/purchase_account/.kosh/schema.d.ts +128 -1
  33. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  34. package/dist/purchase_portal/.kosh/schema.d.ts +128 -1
  35. package/dist/sale_account/.kosh/bindings.js +1 -1
  36. package/dist/sale_account/.kosh/schema.d.ts +128 -1
  37. package/dist/sale_portal/.kosh/bindings.js +1 -1
  38. package/dist/sale_portal/.kosh/schema.d.ts +128 -1
  39. package/dist/sale_project/.kosh/bindings.js +1 -1
  40. package/dist/sale_project/.kosh/schema.d.ts +128 -1
  41. package/dist/sales/.kosh/bindings.js +1 -1
  42. package/dist/sales/.kosh/schema.d.ts +128 -1
  43. package/dist/server/auth/base.js +76 -0
  44. package/dist/server/auth/oauth.d.ts +21 -0
  45. package/dist/server/auth/oauth.js +579 -0
  46. package/dist/server/auth/service.d.ts +4 -0
  47. package/dist/server/auth/service.js +17 -0
  48. package/dist/server/identity.d.ts +2 -0
  49. package/dist/server/images.d.ts +12 -0
  50. package/dist/server/images.js +139 -0
  51. package/dist/server/index.d.ts +1 -0
  52. package/dist/server/index.js +1 -0
  53. package/dist/server/server.d.ts +4 -0
  54. package/dist/server/server.js +9 -1
  55. package/dist/settings/company.js +3 -1
  56. package/dist/settings/people.js +3 -1
  57. package/dist/settings/web/CompaniesPage.js +2 -2
  58. package/dist/settings/web/CompanyPage.js +13 -2
  59. package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
  60. package/dist/settings/web/ConnectedAppsPage.js +33 -0
  61. package/dist/settings/web/OverviewPage.js +12 -1
  62. package/dist/settings/web/PeoplePage.js +3 -12
  63. package/dist/settings/web/ProfilePage.js +9 -7
  64. package/dist/settings/web/index.d.ts +1 -0
  65. package/dist/settings/web/index.js +5 -0
  66. package/dist/settings/web/messages.js +28 -0
  67. package/dist/ui/api.d.ts +4 -0
  68. package/dist/ui/app.js +49 -16
  69. package/dist/ui/components/AppIcon.js +11 -1
  70. package/dist/ui/components/Avatar.d.ts +21 -0
  71. package/dist/ui/components/Avatar.js +71 -0
  72. package/dist/ui/i18n/messages.d.ts +30 -0
  73. package/dist/ui/i18n/messages.js +60 -0
  74. package/dist/ui/index.d.ts +1 -0
  75. package/dist/ui/index.js +1 -0
  76. package/dist/ui/registry.d.ts +7 -1
  77. package/dist/ui/registry.js +10 -1
  78. package/dist/ui/search/ViewTabs.d.ts +14 -0
  79. package/dist/ui/search/ViewTabs.js +42 -0
  80. package/dist/ui/shell/Consent.d.ts +5 -0
  81. package/dist/ui/shell/Consent.js +35 -0
  82. package/dist/ui/shell/Home.js +85 -16
  83. package/dist/ui/shell/Sidebar.js +15 -22
  84. package/dist/ui/views/ListView.js +49 -22
  85. package/package.json +1 -1
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Saved views as tabs (RFC 0018 §4.3): above the search, *All* and the saved searches of this model and view type —
4
+ * the user's own and the shared ones —, the one matching the current search marked. A tab applies its search; more
5
+ * than a handful go into a *More* menu. Saving and the full list stay in the search bar's favorites menu; with
6
+ * nothing saved, there are no tabs.
7
+ */
8
+ import { encodeSearch } from '../../search/index.js';
9
+ import { useQuery } from '@tanstack/react-query';
10
+ import { ChevronDown, Users } from 'lucide-react';
11
+ import { useTranslation } from 'react-i18next';
12
+ import { Button, Menu, MenuContent, MenuItem, MenuTrigger } from '../components/primitives.js';
13
+ import { useKosh } from '../context.js';
14
+ import { cn } from '../lib/cn.js';
15
+ /** Tabs shown before the rest go into *More*. */
16
+ const VISIBLE = 6;
17
+ /** The search without its order: a tab matches whatever the columns are sorted by. */
18
+ const key = (state) => encodeSearch({ ...state, order: [] });
19
+ export function ViewTabs({ model, viewType, state, onChange, }) {
20
+ const { api } = useKosh();
21
+ const { t } = useTranslation();
22
+ // The search bar's favorites query: one request, one cache.
23
+ const favorites = useQuery({
24
+ queryKey: ['saved-views', model],
25
+ queryFn: () => api.list('kosh.saved_view', {
26
+ filter: { targetModel: model },
27
+ fields: ['name', 'state', 'shared', 'viewType'],
28
+ }),
29
+ });
30
+ const views = (favorites.data?.items ?? []).filter((v) => v['viewType'] === viewType || v['viewType'] === null);
31
+ if (views.length === 0)
32
+ return null;
33
+ const current = key(state);
34
+ const stateOf = (v) => v['state'] ?? {};
35
+ const active = views.find((v) => key(stateOf(v)) === current);
36
+ const tab = (label, selected, apply, shared = false) => (_jsxs(Button, { size: "sm", variant: "ghost", "aria-current": selected ? 'true' : undefined, onClick: apply, className: cn('h-8 rounded-none border-b-2 border-transparent px-3 text-muted-foreground hover:text-foreground', selected && 'border-primary font-medium text-foreground'), children: [label, shared ? _jsx(Users, { className: "size-3.5 opacity-60", "aria-label": t('search.shared') }) : null] }));
37
+ const shown = views.slice(0, VISIBLE);
38
+ const more = views.slice(VISIBLE);
39
+ return (_jsxs("nav", { "aria-label": t('search.savedViews'), className: "-mb-1 flex items-end gap-1 border-b", children: [tab(t('search.allRecords'), current === '' && active === undefined, () => onChange({})), shown.map((v) => (_jsx("span", { className: "contents", children: tab(String(v['name']), v === active, () => onChange(stateOf(v)), v['shared'] === true) }, v.id))), more.length === 0 ? null : (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsxs(Button, { size: "sm", variant: "ghost", className: cn('h-8 rounded-none border-b-2 border-transparent px-3 text-muted-foreground', active !== undefined && more.includes(active) && 'border-primary text-foreground'), children: [active !== undefined && more.includes(active)
40
+ ? String(active['name'])
41
+ : t('search.moreViews', { count: more.length }), _jsx(ChevronDown, { className: "size-3.5", "aria-hidden": true })] }) }), _jsx(MenuContent, { align: "start", children: more.map((v) => (_jsx(MenuItem, { onSelect: () => onChange(stateOf(v)), children: String(v['name']) }, v.id))) })] }))] }));
42
+ }
@@ -0,0 +1,5 @@
1
+ import type { ReactElement } from 'react';
2
+ import { type KoshApi } from '../api.js';
3
+ export declare function Consent({ api }: {
4
+ readonly api: KoshApi;
5
+ }): ReactElement;
@@ -0,0 +1,35 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The OAuth consent page (RFC 0017 §5.2), at `/oauth/authorize`: an app asks to act for the signed-in user. It names
4
+ * the app, where it sends the user back, and what it may do in words; *Allow* or *Cancel* — either returns to the
5
+ * app. The server checks the request (registered redirect, allowed scopes, PKCE) before anything is shown; signed out,
6
+ * the person signs in and comes back here.
7
+ */
8
+ import { useMutation, useQuery } from '@tanstack/react-query';
9
+ import { ExternalLink, ShieldCheck } from 'lucide-react';
10
+ import { useTranslation } from 'react-i18next';
11
+ import { ApiError } from '../api.js';
12
+ import { Alert, Button, Card, Spinner } from '../components/primitives.js';
13
+ export function Consent({ api }) {
14
+ const { t } = useTranslation();
15
+ const search = window.location.search;
16
+ const params = Object.fromEntries(new URLSearchParams(search));
17
+ const me = useQuery({ queryKey: ['me'], queryFn: () => api.me(), retry: false });
18
+ const screen = useQuery({
19
+ queryKey: ['oauth', 'consent', search],
20
+ queryFn: () => api.call('GET', `/api/v1/oauth/authorize${search}`),
21
+ enabled: me.isSuccess,
22
+ retry: false,
23
+ });
24
+ const decide = useMutation({
25
+ mutationFn: (decision) => api.call('POST', '/api/v1/oauth/authorize', { ...params, decision }),
26
+ onSuccess: ({ redirect }) => window.location.assign(redirect),
27
+ });
28
+ if (me.error instanceof ApiError && me.error.status === 401) {
29
+ // Signed out: sign in, then back here with the same request.
30
+ window.location.assign(`/login?next=${encodeURIComponent(`/oauth/authorize${search}`)}`);
31
+ return _jsx(Spinner, { label: t('app.loading') });
32
+ }
33
+ const error = me.error ?? screen.error ?? decide.error;
34
+ return (_jsx("main", { className: "grid min-h-screen place-items-center bg-background p-4", children: _jsx(Card, { className: "grid w-full max-w-md gap-5 p-6", children: error !== null ? (_jsx(Alert, { children: error instanceof ApiError ? error.message : String(error) })) : screen.data === undefined || me.data === undefined ? (_jsx(Spinner, { label: t('app.loading') })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid gap-1", children: [_jsx("span", { "aria-hidden": true, className: "mb-2 grid size-10 place-items-center rounded-lg bg-accent text-accent-foreground", children: _jsx(ShieldCheck, { className: "size-5" }) }), _jsx("h1", { className: "text-xl font-semibold", children: t('oauth.title', { app: screen.data.app.name }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('oauth.as', { name: me.data.name, login: me.data.login }) })] }), _jsxs("div", { className: "grid gap-2", children: [_jsx("h2", { className: "text-sm font-medium", children: t('oauth.may') }), _jsx("ul", { className: "grid gap-1.5 rounded-md border p-3 text-sm", children: screen.data.scopes.map((s) => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx(ShieldCheck, { className: "size-4 text-success", "aria-hidden": true }), s.label] }, s.scope))) }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('oauth.limits') })] }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [t('oauth.back', { host: screen.data.redirectHost }), screen.data.app.homepage === null ? null : (_jsxs(_Fragment, { children: [' ', _jsxs("a", { className: "inline-flex items-center gap-1 text-primary underline", href: screen.data.app.homepage, target: "_blank", rel: "noreferrer noopener", children: [t('oauth.homepage'), _jsx(ExternalLink, { className: "size-3", "aria-hidden": true })] })] }))] }), screen.data.alreadyAllowed ? _jsx(Alert, { tone: "info", children: t('oauth.already') }) : null, _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { busy: decide.isPending && decide.variables === 'deny', onClick: () => decide.mutate('deny'), children: t('oauth.cancel') }), _jsx(Button, { variant: "primary", busy: decide.isPending && decide.variables === 'allow', onClick: () => decide.mutate('allow'), children: t('oauth.allow') })] })] })) }) }));
35
+ }
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * The home page (RFC 0010 §3.8): a greeting, the palette's search box, what needs the user's attention (approvals
4
- * they may decide, activities due), recently opened records, and the apps. Everything on it comes from routes the
5
- * server already filters per user; apps that are not installed (Permissions, Messaging) simply contribute nothing.
3
+ * Today, the home page (RFC 0010 §3.8, RFC 0018 §4.4): a greeting with the date, the palette's search box, what needs
4
+ * the user — each approval waiting for them by record, the activities due, unread mentions — as a list with its
5
+ * action, recently opened records, the saved views they keep (lists, pivots, charts: one click back to them), and the
6
+ * apps. Everything comes from routes the server already filters per user; apps that are not installed (Permissions,
7
+ * Messaging, Notifications) simply contribute nothing.
6
8
  */
9
+ import { encodeSearch } from '../../search/index.js';
7
10
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
8
11
  import { Link, useNavigate } from '@tanstack/react-router';
9
- import { CalendarClock, CircleCheckBig, History, Search } from 'lucide-react';
12
+ import { AtSign, BarChart3, CalendarClock, CircleCheckBig, History, List, Search, ShieldCheck, Star, Table2, } from 'lucide-react';
10
13
  import { useTranslation } from 'react-i18next';
11
14
  import { AppIcon } from '../components/AppIcon.js';
12
15
  import { Badge, Button, Card, Skeleton } from '../components/primitives.js';
@@ -22,6 +25,15 @@ const TINTS = [
22
25
  'bg-primary/10 text-primary',
23
26
  'bg-danger/10 text-danger',
24
27
  ];
28
+ const VIEW_ICONS = {
29
+ pivot: Table2,
30
+ graph: BarChart3,
31
+ chart: BarChart3,
32
+ };
33
+ /** One row of "Needs your attention": an icon, what it is, and its actions. */
34
+ function Item({ icon, title, detail, children, }) {
35
+ return (_jsxs("li", { className: "flex items-center gap-3 px-4 py-3", children: [_jsx("span", { "aria-hidden": true, className: "grid size-8 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: icon }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: title }), detail === undefined ? null : (_jsx("p", { className: "truncate text-xs text-muted-foreground", children: detail }))] }), _jsx("div", { className: "flex shrink-0 items-center gap-1", children: children })] }));
36
+ }
25
37
  export function Home() {
26
38
  const { api, uid, format } = useKosh();
27
39
  const { t } = useTranslation();
@@ -34,10 +46,25 @@ export function Home() {
34
46
  // Each is a module's route: absent (404) when the module is not installed, so an absent list is empty.
35
47
  const approvals = useQuery({
36
48
  queryKey: ['home', 'approvals'],
37
- queryFn: () => api
38
- .call('GET', '/api/v1/approvals/inbox')
39
- .then((r) => r.requests)
40
- .catch(() => []),
49
+ queryFn: async () => {
50
+ const requests = await api
51
+ .call('GET', '/api/v1/approvals/inbox')
52
+ .then((r) => r.requests)
53
+ .catch(() => []);
54
+ // The records' names, one request per model.
55
+ const names = new Map();
56
+ for (const model of new Set(requests.map((r) => r.targetModel)))
57
+ for (const n of await api
58
+ .names(model, {
59
+ ids: requests.filter((r) => r.targetModel === model).map((r) => r.targetId),
60
+ })
61
+ .catch(() => []))
62
+ names.set(`${model}:${n.id}`, n.displayName);
63
+ return requests.map((r) => ({
64
+ ...r,
65
+ record: names.get(`${r.targetModel}:${r.targetId}`) ?? `#${r.targetId}`,
66
+ }));
67
+ },
41
68
  });
42
69
  const activities = useQuery({
43
70
  queryKey: ['home', 'activities'],
@@ -46,13 +73,38 @@ export function Home() {
46
73
  .then((r) => r.activities)
47
74
  .catch(() => []),
48
75
  });
76
+ const mentions = useQuery({
77
+ queryKey: ['home', 'mentions'],
78
+ queryFn: () => api
79
+ .call('GET', '/api/v1/notifications?unread=true&limit=50')
80
+ .then((r) => r.items.filter((n) => n.kind === 'mention'))
81
+ .catch(() => []),
82
+ });
83
+ const saved = useQuery({
84
+ queryKey: ['home', 'saved-views'],
85
+ queryFn: () => api
86
+ .list('kosh.saved_view', {
87
+ fields: ['name', 'targetModel', 'viewType', 'state'],
88
+ limit: 12,
89
+ })
90
+ .then((r) => r.items)
91
+ .catch(() => []),
92
+ });
49
93
  const done = useMutation({
50
94
  mutationFn: (id) => api.call('POST', `/api/v1/activities/${id}/done`, {}),
51
95
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['home', 'activities'] }),
52
96
  });
53
- const hour = new Date().getHours();
97
+ const read = useMutation({
98
+ mutationFn: (id) => api.call('POST', `/api/v1/notifications/${id}/read`, {}),
99
+ onSuccess: () => {
100
+ void queryClient.invalidateQueries({ queryKey: ['home', 'mentions'] });
101
+ void queryClient.invalidateQueries({ queryKey: ['notifications'] });
102
+ },
103
+ });
104
+ const now = new Date();
105
+ const hour = now.getHours();
54
106
  const greeting = hour < 12 ? 'morning' : hour < 18 ? 'afternoon' : 'evening';
55
- const today = new Date().toISOString().slice(0, 10);
107
+ const today = now.toISOString().slice(0, 10);
56
108
  const due = (activities.data ?? []).filter((a) => a.deadline <= today);
57
109
  const open = (model, id) => {
58
110
  const route = routes.data?.[model];
@@ -63,13 +115,30 @@ export function Home() {
63
115
  params: { app: route.app, action: route.action, id: String(id) },
64
116
  });
65
117
  };
118
+ const openSaved = (view) => {
119
+ const route = routes.data?.[view.targetModel];
120
+ if (route?.app === undefined)
121
+ return;
122
+ const q = encodeSearch((view.state ?? {}));
123
+ void navigate({
124
+ to: '/$app/a/$action',
125
+ params: { app: route.app, action: route.action },
126
+ search: { view: view.viewType, ...(q === '' ? {} : { q }) },
127
+ });
128
+ };
66
129
  const categories = [...new Set((apps.data ?? []).map((a) => a.category ?? ''))].sort((a, b) => a.localeCompare(b));
67
130
  const tint = (a) => TINTS[categories.indexOf(a.category ?? '') % TINTS.length];
68
131
  const recent = recentRecords(uid);
69
- const waiting = (approvals.data?.length ?? 0) + due.length;
70
- return (_jsxs("div", { className: "mx-auto grid max-w-5xl gap-8 py-4", children: [_jsxs("header", { className: "grid gap-4", children: [_jsx("h1", { className: "text-2xl font-semibold", children: me.data === undefined ? (_jsx(Skeleton, { className: "h-8 w-64" })) : (t(`home.${greeting}`, { name: me.data.name.split(' ')[0] })) }), _jsxs("button", { type: "button", onClick: openPalette, className: "flex h-12 w-full items-center gap-3 rounded-xl border bg-surface px-4 text-start text-muted-foreground shadow-sm hover:bg-muted", children: [_jsx(Search, { className: "size-5", "aria-hidden": true }), _jsx("span", { className: "flex-1", children: t('home.search') }), _jsx("kbd", { className: "rounded border px-1.5 text-xs", children: "Ctrl K" })] })] }), _jsxs("section", { "aria-labelledby": "home-attention", className: "grid gap-3", children: [_jsx("h2", { id: "home-attention", className: "text-sm font-semibold text-muted-foreground", children: t('home.attention') }), approvals.isPending || activities.isPending ? (_jsx(Skeleton, { className: "h-16" })) : waiting === 0 ? (_jsxs(Card, { className: "flex items-center gap-3 p-4 text-sm text-muted-foreground", children: [_jsx(CircleCheckBig, { className: "size-5 text-success", "aria-hidden": true }), t('home.nothing')] })) : (_jsxs("ul", { className: "grid gap-2 sm:grid-cols-2", children: [(approvals.data?.length ?? 0) === 0 ? null : (_jsx("li", { children: _jsxs(Card, { className: "flex items-center justify-between gap-3 p-4", children: [_jsxs("div", { className: "grid gap-1", children: [_jsx("span", { className: "font-medium", children: t('home.approvals', { count: approvals.data?.length ?? 0 }) }), _jsx("span", { className: "text-xs text-muted-foreground", children: [...new Set((approvals.data ?? []).map((a) => a.levelName))].join(' · ') })] }), approvals.data?.[0] === undefined ? null : (_jsx(Button, { size: "sm", onClick: () => {
71
- const first = approvals.data?.[0];
72
- if (first !== undefined)
73
- open(first.targetModel, first.targetId);
74
- }, children: t('home.open') }))] }) })), due.length === 0 ? null : (_jsx("li", { children: _jsxs(Card, { className: "grid gap-2 p-4", children: [_jsx("span", { className: "font-medium", children: t('home.activities', { count: due.length }) }), _jsx("ul", { className: "grid gap-1 text-sm", children: due.slice(0, 5).map((a) => (_jsxs("li", { className: "flex items-center justify-between gap-2", children: [_jsxs("button", { type: "button", className: "flex min-w-0 items-center gap-2 text-start hover:underline", onClick: () => open(a.model, a.resId), children: [_jsx(CalendarClock, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }), _jsxs("span", { className: "truncate", children: [a.summary ?? a.type.name, " \u00B7 ", a.record] }), a.deadline < today ? (_jsx(Badge, { tone: "danger", children: t('home.overdue') })) : null] }), _jsx(Button, { size: "sm", variant: "ghost", busy: done.isPending && done.variables === a.id, onClick: () => done.mutate(a.id), children: t('home.done') })] }, a.id))) })] }) }))] }))] }), recent.length === 0 ? null : (_jsxs("section", { "aria-labelledby": "home-recent", className: "grid gap-3", children: [_jsx("h2", { id: "home-recent", className: "text-sm font-semibold text-muted-foreground", children: t('home.recent') }), _jsx("ul", { className: "flex flex-wrap gap-2", children: recent.slice(0, 8).map((r) => (_jsx("li", { children: _jsxs(Link, { to: "/$app/a/$action/$id", params: { app: r.app, action: r.action, id: String(r.id) }, className: "flex items-center gap-2 rounded-full border bg-surface px-3 py-1.5 text-sm hover:bg-muted", children: [_jsx(History, { className: "size-3.5 text-muted-foreground", "aria-hidden": true }), _jsx("span", { className: "max-w-56 truncate", children: r.title }), _jsx("span", { className: "text-xs text-muted-foreground", children: format.date(new Date(r.at).toISOString().slice(0, 10)) })] }) }, `${r.model}:${r.id}`))) })] })), _jsxs("section", { "aria-labelledby": "apps-title", className: "grid gap-3", children: [_jsx("h2", { id: "apps-title", className: "text-sm font-semibold text-muted-foreground", children: t('home.apps') }), apps.isPending ? (_jsx("div", { className: "grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3", children: [0, 1, 2, 3].map((i) => (_jsx(Skeleton, { className: "h-28" }, i))) })) : (apps.data ?? []).length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: t('shell.noApps') })) : (_jsx("ul", { className: "grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3", children: (apps.data ?? []).map((a) => (_jsx("li", { children: _jsx(Link, { to: "/$app", params: { app: a.code }, className: "block h-full", children: _jsxs(Card, { className: "flex h-full flex-col gap-3 p-4 hover:bg-muted", children: [_jsx(AppIcon, { name: a.icon ?? a.code, className: cn('size-11', tint(a)) }), _jsxs("div", { className: "grid gap-0.5", children: [_jsx("span", { className: "font-medium", children: a.label }), a.summary === undefined ? null : (_jsx("span", { className: "line-clamp-2 text-xs text-muted-foreground", children: a.summary }))] })] }) }) }, a.code))) }))] })] }));
132
+ const waiting = (approvals.data?.length ?? 0) + due.length + (mentions.data?.length ?? 0);
133
+ const views = (saved.data ?? []).filter((v) => routes.data?.[v.targetModel]?.app !== undefined);
134
+ return (_jsxs("div", { className: "mx-auto grid max-w-5xl gap-8 py-4", children: [_jsxs("header", { className: "grid gap-4", children: [_jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium text-muted-foreground", children: t('home.today', { date: format.date(today) }) }), _jsx("h1", { className: "text-2xl font-semibold", children: me.data === undefined ? (_jsx(Skeleton, { className: "h-8 w-64" })) : (t(`home.${greeting}`, { name: me.data.name.split(' ')[0] })) })] }), _jsxs("button", { type: "button", onClick: openPalette, className: "flex h-12 w-full items-center gap-3 rounded-xl border bg-surface px-4 text-start text-muted-foreground shadow-sm hover:bg-muted", children: [_jsx(Search, { className: "size-5", "aria-hidden": true }), _jsx("span", { className: "flex-1", children: t('home.search') }), _jsx("kbd", { className: "rounded border px-1.5 text-xs", children: "Ctrl K" })] })] }), _jsxs("section", { "aria-labelledby": "home-attention", className: "grid gap-3", children: [_jsxs("h2", { id: "home-attention", className: "flex items-center gap-2 text-sm font-semibold text-muted-foreground", children: [t('home.attention'), waiting === 0 ? null : _jsx(Badge, { tone: "info", children: waiting })] }), approvals.isPending || activities.isPending || mentions.isPending ? (_jsx(Skeleton, { className: "h-16" })) : waiting === 0 ? (_jsxs(Card, { className: "flex items-center gap-3 p-4 text-sm text-muted-foreground", children: [_jsx(CircleCheckBig, { className: "size-5 text-success", "aria-hidden": true }), t('home.nothing')] })) : (_jsx(Card, { className: "p-0", children: _jsxs("ul", { className: "divide-y", children: [(approvals.data ?? []).map((a) => (_jsx(Item, { icon: _jsx(ShieldCheck, { className: "size-4" }), title: t('home.approve', { record: a.record }), detail: a.levelName, children: _jsx(Button, { size: "sm", onClick: () => open(a.targetModel, a.targetId), children: t('home.review') }) }, `approval-${a.id}`))), due.map((a) => (_jsxs(Item, { icon: _jsx(CalendarClock, { className: "size-4" }), title: _jsxs("span", { className: "flex items-center gap-2", children: [_jsx("span", { className: "truncate", children: a.summary ?? a.type.name }), a.deadline < today ? (_jsx(Badge, { tone: "danger", children: t('home.overdue') })) : (_jsx(Badge, { tone: "warning", children: t('home.dueToday') }))] }), detail: `${a.type.name} · ${a.record}`, children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: () => open(a.model, a.resId), children: t('home.open') }), _jsx(Button, { size: "sm", busy: done.isPending && done.variables === a.id, onClick: () => done.mutate(a.id), children: t('home.done') })] }, `activity-${a.id}`))), (mentions.data ?? []).map((m) => (_jsx(Item, { icon: _jsx(AtSign, { className: "size-4" }), title: t('home.mentioned', {
135
+ actor: m.params.actor ?? '',
136
+ record: m.params.record ?? '',
137
+ }), detail: m.params.preview ?? format.datetime(m.createdAt), children: _jsx(Button, { size: "sm", onClick: () => {
138
+ read.mutate(m.id);
139
+ open(m.model, m.resId);
140
+ }, children: t('home.open') }) }, `mention-${m.id}`)))] }) }))] }), recent.length === 0 ? null : (_jsxs("section", { "aria-labelledby": "home-recent", className: "grid gap-3", children: [_jsx("h2", { id: "home-recent", className: "text-sm font-semibold text-muted-foreground", children: t('home.recent') }), _jsx("ul", { className: "flex flex-wrap gap-2", children: recent.slice(0, 8).map((r) => (_jsx("li", { children: _jsxs(Link, { to: "/$app/a/$action/$id", params: { app: r.app, action: r.action, id: String(r.id) }, className: "flex items-center gap-2 rounded-full border bg-surface px-3 py-1.5 text-sm hover:bg-muted", children: [_jsx(History, { className: "size-3.5 text-muted-foreground", "aria-hidden": true }), _jsx("span", { className: "max-w-56 truncate", children: r.title }), _jsx("span", { className: "text-xs text-muted-foreground", children: format.date(new Date(r.at).toISOString().slice(0, 10)) })] }) }, `${r.model}:${r.id}`))) })] })), views.length === 0 ? null : (_jsxs("section", { "aria-labelledby": "home-saved", className: "grid gap-3", children: [_jsx("h2", { id: "home-saved", className: "text-sm font-semibold text-muted-foreground", children: t('home.saved') }), _jsx("ul", { className: "grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] gap-2", children: views.map((v) => {
141
+ const Icon = VIEW_ICONS[v.viewType] ?? List;
142
+ return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => openSaved(v), className: "flex w-full items-center gap-2 rounded-lg border bg-surface px-3 py-2 text-start text-sm hover:bg-muted", children: [_jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: v.name }), _jsx(Star, { className: "size-3.5 shrink-0 text-muted-foreground", "aria-hidden": true })] }) }, v.id));
143
+ }) })] })), _jsxs("section", { "aria-labelledby": "apps-title", className: "grid gap-3", children: [_jsx("h2", { id: "apps-title", className: "text-sm font-semibold text-muted-foreground", children: t('home.apps') }), apps.isPending ? (_jsx("div", { className: "grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3", children: [0, 1, 2, 3].map((i) => (_jsx(Skeleton, { className: "h-16" }, i))) })) : (apps.data ?? []).length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: t('shell.noApps') })) : (_jsx("ul", { className: "grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-2", children: (apps.data ?? []).map((a) => (_jsx("li", { children: _jsx(Link, { to: "/$app", params: { app: a.code }, className: "block h-full", children: _jsxs(Card, { className: "flex h-full items-center gap-3 p-3 hover:bg-muted", children: [_jsx(AppIcon, { name: a.icon ?? a.code, className: cn('size-9', tint(a)) }), _jsxs("div", { className: "grid min-w-0 gap-0.5", children: [_jsx("span", { className: "truncate font-medium", children: a.label }), a.summary === undefined ? null : (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: a.summary }))] })] }) }) }, a.code))) }))] })] }));
75
144
  }
@@ -1,18 +1,18 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * The Workspace sidebar (RFC 0018 §4.1): the company at the top, search, Home, then the user's apps — each a button
4
- * that opens its sections (the current app opens itself; one at a time) — and the user at the bottom. It collapses
5
- * to an icon rail (`Ctrl+B`, remembered per browser) and becomes a drawer on small screens. The server decides which
6
- * apps and menus the user sees; the sidebar shows them.
3
+ * The Workspace sidebar (RFC 0018 §4.1), shown inside an app: the company at the top, search, Home, then the open
4
+ * app — its name switches to another app — with only its own sections, and the user at the bottom. The home page has
5
+ * no sidebar. It collapses to an icon rail (`Ctrl+B`, remembered per browser) and becomes a drawer on small screens.
6
+ * The server decides which apps and menus the user sees; the sidebar shows them.
7
7
  */
8
8
  import { useQuery } from '@tanstack/react-query';
9
9
  import { Link, useNavigate } from '@tanstack/react-router';
10
- import { ChevronDown, House, PanelLeftClose, PanelLeftOpen, Search, X } from 'lucide-react';
10
+ import { ChevronsUpDown, House, PanelLeftClose, PanelLeftOpen, Search, X } from 'lucide-react';
11
11
  import { Dialog } from 'radix-ui';
12
- import { useEffect, useState } from 'react';
12
+ import { useState } from 'react';
13
13
  import { useTranslation } from 'react-i18next';
14
14
  import { AppIcon } from '../components/AppIcon.js';
15
- import { Button } from '../components/primitives.js';
15
+ import { Button, Menu, MenuCheckboxItem, MenuContent, MenuLabel, MenuTrigger, } from '../components/primitives.js';
16
16
  import { cn } from '../lib/cn.js';
17
17
  const SIDEBAR_KEY = 'kosh.sidebar';
18
18
  /** Whether the sidebar is collapsed to its rail, remembered per browser. */
@@ -56,26 +56,19 @@ function AppSections({ api, app, company, onNavigate, }) {
56
56
  queryKey: ['menus', app, company],
57
57
  queryFn: () => api.menus(app),
58
58
  });
59
- return (_jsx("ul", { className: "ms-4 grid gap-0.5 border-s ps-2 pt-1 pb-2", children: entriesOf(menus.data ?? []).map((node) => node.children.length > 0 ? (_jsxs("li", { className: "pt-1", children: [_jsx("p", { className: "px-2 pb-0.5 text-xs font-medium tracking-wide text-muted-foreground uppercase", children: node.label }), _jsx("ul", { className: "grid gap-0.5", children: node.children.map((child) => (_jsx("li", { children: _jsx(SectionLink, { app: app, node: child, onNavigate: onNavigate }) }, child.id))) })] }, node.id)) : (_jsx("li", { children: _jsx(SectionLink, { app: app, node: node, onNavigate: onNavigate }) }, node.id))) }));
59
+ return (_jsx("ul", { className: "grid gap-0.5 pt-1 pb-2", children: entriesOf(menus.data ?? []).map((node) => node.children.length > 0 ? (_jsxs("li", { className: "pt-1", children: [_jsx("p", { className: "px-2 pb-0.5 text-xs font-medium tracking-wide text-muted-foreground uppercase", children: node.label }), _jsx("ul", { className: "grid gap-0.5", children: node.children.map((child) => (_jsx("li", { children: _jsx(SectionLink, { app: app, node: child, onNavigate: onNavigate }) }, child.id))) })] }, node.id)) : (_jsx("li", { children: _jsx(SectionLink, { app: app, node: node, onNavigate: onNavigate }) }, node.id))) }));
60
60
  }
61
61
  export function Sidebar({ api, apps, current, company, collapsed, onCollapse, onSearch, top, bottom, onNavigate, }) {
62
62
  const { t } = useTranslation();
63
63
  const navigate = useNavigate();
64
- const [open, setOpen] = useState(current);
65
- useEffect(() => setOpen(current), [current]);
64
+ const app = apps.find((a) => a.code === current);
66
65
  const item = 'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm hover:bg-muted focus-visible:outline-2';
67
- return (_jsxs("div", { className: "flex h-full flex-col gap-1 p-2", children: [_jsx("div", { className: cn('pb-1', collapsed && 'flex justify-center'), children: top }), _jsxs("button", { type: "button", className: cn(item, 'text-muted-foreground', collapsed && 'justify-center'), onClick: onSearch, "aria-keyshortcuts": "Control+K Meta+K", "aria-label": collapsed ? t('palette.button') : undefined, title: collapsed ? t('palette.button') : undefined, children: [_jsx(Search, { className: "size-4 shrink-0", "aria-hidden": true }), collapsed ? null : (_jsxs(_Fragment, { children: [_jsx("span", { className: "flex-1 text-start", children: t('palette.button') }), _jsx("kbd", { className: "rounded border bg-surface px-1 text-[0.65rem]", children: "Ctrl K" })] }))] }), _jsxs(Link, { to: "/", onClick: onNavigate, className: cn(item, collapsed && 'justify-center'), activeOptions: { exact: true }, activeProps: { 'aria-current': 'page', className: 'bg-muted font-medium' }, "aria-label": collapsed ? t('shell.home') : undefined, title: collapsed ? t('shell.home') : undefined, children: [_jsx(House, { className: "size-4 shrink-0", "aria-hidden": true }), collapsed ? null : t('shell.home')] }), _jsx("nav", { "aria-label": t('shell.menu'), className: "-mx-2 mt-2 flex-1 overflow-y-auto border-t px-2 pt-2", children: _jsx("ul", { className: "grid gap-0.5", children: apps.map((a) => {
68
- const expanded = !collapsed && open === a.code;
69
- return (_jsxs("li", { children: [_jsxs("button", { type: "button", "aria-expanded": collapsed ? undefined : expanded, "aria-label": t('shell.appNamed', { app: a.label }), title: collapsed ? a.label : undefined, className: cn(item, current === a.code && 'font-medium', collapsed && 'justify-center'), onClick: () => {
70
- if (collapsed || a.code !== current) {
71
- setOpen(a.code);
72
- void navigate({ to: '/$app', params: { app: a.code } });
73
- onNavigate?.();
74
- }
75
- else
76
- setOpen(expanded ? undefined : a.code);
77
- }, children: [_jsx(AppIcon, { name: a.icon ?? a.code, className: "size-6 rounded-md [&_svg]:size-3.5" }), collapsed ? null : (_jsxs(_Fragment, { children: [_jsx("span", { className: "flex-1 truncate text-start", children: a.label }), _jsx(ChevronDown, { className: cn('size-3.5 text-muted-foreground transition-transform', !expanded && '-rotate-90 rtl:rotate-90'), "aria-hidden": true })] }))] }), expanded ? (_jsx(AppSections, { api: api, app: a.code, company: company, onNavigate: onNavigate })) : null] }, a.code));
78
- }) }) }), _jsxs("div", { className: cn('flex items-center gap-1 border-t pt-2', collapsed && 'flex-col'), children: [_jsx("div", { className: "min-w-0 flex-1", children: bottom }), onCollapse === undefined ? null : (_jsx(Button, { variant: "ghost", size: "icon", "aria-label": collapsed ? t('shell.expandSidebar') : t('shell.collapseSidebar'), title: `${collapsed ? t('shell.expandSidebar') : t('shell.collapseSidebar')} (Ctrl B)`, "aria-keyshortcuts": "Control+B Meta+B", onClick: () => onCollapse(!collapsed), children: collapsed ? (_jsx(PanelLeftOpen, { className: "rtl:rotate-180", "aria-hidden": true })) : (_jsx(PanelLeftClose, { className: "rtl:rotate-180", "aria-hidden": true })) }))] })] }));
66
+ return (_jsxs("div", { className: "flex h-full flex-col gap-1 p-2", children: [_jsx("div", { className: cn('pb-1', collapsed && 'flex justify-center'), children: top }), _jsxs("button", { type: "button", className: cn(item, 'text-muted-foreground', collapsed && 'justify-center'), onClick: onSearch, "aria-keyshortcuts": "Control+K Meta+K", "aria-label": collapsed ? t('palette.button') : undefined, title: collapsed ? t('palette.button') : undefined, children: [_jsx(Search, { className: "size-4 shrink-0", "aria-hidden": true }), collapsed ? null : (_jsxs(_Fragment, { children: [_jsx("span", { className: "flex-1 text-start", children: t('palette.button') }), _jsx("kbd", { className: "rounded border bg-surface px-1 text-[0.65rem]", children: "Ctrl K" })] }))] }), _jsxs(Link, { to: "/", onClick: onNavigate, className: cn(item, collapsed && 'justify-center'), activeOptions: { exact: true }, activeProps: { 'aria-current': 'page', className: 'bg-muted font-medium' }, "aria-label": collapsed ? t('shell.home') : undefined, title: collapsed ? t('shell.home') : undefined, children: [_jsx(House, { className: "size-4 shrink-0", "aria-hidden": true }), collapsed ? null : t('shell.home')] }), _jsx("nav", { "aria-label": t('shell.menu'), className: "-mx-2 mt-2 flex-1 overflow-y-auto border-t px-2 pt-2", children: app === undefined ? null : (_jsxs(_Fragment, { children: [_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": t('shell.appNamed', { app: app.label }), title: collapsed ? app.label : undefined, className: cn(item, 'font-semibold', collapsed && 'justify-center'), children: [_jsx(AppIcon, { name: app.icon ?? app.code, className: "size-6 rounded-md [&_svg]:size-3.5" }), collapsed ? null : (_jsxs(_Fragment, { children: [_jsx("span", { className: "flex-1 truncate text-start", children: app.label }), _jsx(ChevronsUpDown, { className: "size-3.5 text-muted-foreground", "aria-hidden": true })] }))] }) }), _jsxs(MenuContent, { align: "start", className: "w-60", children: [_jsx(MenuLabel, { children: t('shell.apps') }), apps.map((a) => (_jsxs(MenuCheckboxItem, { checked: a.code === current, onCheckedChange: () => {
67
+ if (a.code === current)
68
+ return;
69
+ void navigate({ to: '/$app', params: { app: a.code } });
70
+ onNavigate?.();
71
+ }, children: [_jsx(AppIcon, { name: a.icon ?? a.code, className: "size-5 rounded [&_svg]:size-3" }), a.label] }, a.code)))] })] }), collapsed ? null : (_jsx(AppSections, { api: api, app: app.code, company: company, onNavigate: onNavigate }))] })) }), _jsxs("div", { className: cn('flex items-center gap-1 border-t pt-2', collapsed && 'flex-col'), children: [_jsx("div", { className: "min-w-0 flex-1", children: bottom }), onCollapse === undefined ? null : (_jsx(Button, { variant: "ghost", size: "icon", "aria-label": collapsed ? t('shell.expandSidebar') : t('shell.collapseSidebar'), title: `${collapsed ? t('shell.expandSidebar') : t('shell.collapseSidebar')} (Ctrl B)`, "aria-keyshortcuts": "Control+B Meta+B", onClick: () => onCollapse(!collapsed), children: collapsed ? (_jsx(PanelLeftOpen, { className: "rtl:rotate-180", "aria-hidden": true })) : (_jsx(PanelLeftClose, { className: "rtl:rotate-180", "aria-hidden": true })) }))] })] }));
79
72
  }
80
73
  /** The sidebar as a drawer on small screens. */
81
74
  export function SidebarDrawer({ open, onOpenChange, children, }) {
@@ -27,6 +27,11 @@ import { PrefetchedNames, useRowNames } from './widgets.js';
27
27
  const features = tableFeatures({});
28
28
  const helper = createColumnHelper();
29
29
  const HEIGHTS = { comfortable: 40, compact: 32 };
30
+ /** The record's page in a new tab: the list's address with the record's id (`/<app>/a/<action>/<id>`). */
31
+ function openInNewTab(id) {
32
+ const path = window.location.pathname.replace(/\/$/, '');
33
+ window.open(`${path}/${id}`, '_blank', 'noopener');
34
+ }
30
35
  /** Requests a bulk action sends at once. */
31
36
  const PARALLEL = 6;
32
37
  /** How long a bulk delete waits for Undo before it runs. */
@@ -188,7 +193,6 @@ function Rows({ props, where, columns, fields, offset, limit, height, selection,
188
193
  offset,
189
194
  count: true,
190
195
  });
191
- onCount?.(result.count ?? result.items.length);
192
196
  rememberList(model, result.items.map((r) => r.id), offset, result.count ?? result.items.length);
193
197
  return result;
194
198
  },
@@ -199,6 +203,11 @@ function Rows({ props, where, columns, fields, offset, limit, height, selection,
199
203
  if (query.data !== undefined)
200
204
  onRows?.(query.data.items);
201
205
  }, [query.data, onRows]);
206
+ // From the data, not the fetch: a search answered from the cache (back to All) has its count too.
207
+ useEffect(() => {
208
+ if (query.data !== undefined)
209
+ onCount?.(query.data.count ?? query.data.items.length);
210
+ }, [query.data, onCount]);
202
211
  const names = useRowNames(items, Object.values(fields));
203
212
  const tableColumns = useMemo(() => helper.columns(columns.map((node) => helper.accessor((row) => row[node.field], {
204
213
  id: node.id,
@@ -488,7 +497,11 @@ function Rows({ props, where, columns, fields, offset, limit, height, selection,
488
497
  if (query.isError)
489
498
  return (_jsx(Alert, { action: _jsx(Button, { size: "sm", onClick: () => void query.refetch(), children: t('app.retry') }), children: query.error instanceof ApiError ? query.error.message : t('app.error') }));
490
499
  if (items.length === 0)
491
- return (_jsx(EmptyState, { icon: _jsx(Inbox, { "aria-hidden": true }), title: filtered === true ? t('views.emptyFiltered') : t('views.emptyTitle'), ...(filtered === true ? {} : { description: t('views.emptyHint') }), ...(props.served.operations.create && offset === 0
500
+ return (_jsx(EmptyState, { icon: _jsx(Inbox, { "aria-hidden": true }), title: filtered === true
501
+ ? t('views.emptyFiltered')
502
+ : props.served.view.label === undefined
503
+ ? t('views.emptyTitle')
504
+ : t('views.emptyNamed', { name: props.served.view.label.toLocaleLowerCase() }), ...(filtered === true ? {} : { description: t('views.emptyHint') }), ...(props.served.operations.create && offset === 0
492
505
  ? {
493
506
  action: (_jsxs(Button, { variant: "primary", size: "sm", onClick: () => props.onOpen('new'), children: [_jsx(Plus, { "aria-hidden": true }), t('views.createFirst')] })),
494
507
  }
@@ -525,8 +538,20 @@ function Rows({ props, where, columns, fields, offset, limit, height, selection,
525
538
  return null;
526
539
  const id = row.original.id;
527
540
  const isSelected = selection?.selected.has(id) === true;
528
- return (_jsxs("tr", { tabIndex: 0, "aria-selected": selection === undefined ? undefined : isSelected, style: { height }, className: cn('cursor-pointer border-b hover:bg-muted/60 focus-visible:bg-muted', isSelected && 'bg-accent/60 hover:bg-accent/70'), onClick: () => props.onOpen(id), onKeyDown: (e) => {
529
- if (e.key === 'Enter')
541
+ return (_jsxs("tr", { tabIndex: 0, "aria-selected": selection === undefined ? undefined : isSelected, style: { height }, className: cn('cursor-pointer border-b hover:bg-muted/60 focus-visible:bg-muted', isSelected && 'bg-accent/60 hover:bg-accent/70'),
542
+ // Ctrl / Cmd + click or the middle button: the record in a new tab, the list stays.
543
+ onClick: (e) => {
544
+ if (e.ctrlKey || e.metaKey)
545
+ openInNewTab(id);
546
+ else
547
+ props.onOpen(id);
548
+ }, onAuxClick: (e) => {
549
+ if (e.button === 1)
550
+ openInNewTab(id);
551
+ }, onKeyDown: (e) => {
552
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey))
553
+ openInNewTab(id);
554
+ else if (e.key === 'Enter')
530
555
  props.onOpen(id);
531
556
  // Space on the row itself selects it (the checkbox handles its own keys).
532
557
  if (e.key === ' ' && selection !== undefined && e.target === e.currentTarget) {
@@ -814,24 +839,26 @@ export function ListView(props) {
814
839
  tone: 'danger',
815
840
  }),
816
841
  });
817
- const toolbar = ids.length > 0 ? (_jsxs("div", { role: "toolbar", "aria-label": t('views.selected', { count: ids.length }), className: "flex flex-wrap items-center gap-2 border-b bg-accent/40 px-3 py-2 text-sm", children: [_jsx("span", { className: "font-medium", children: t('views.selected', { count: ids.length }) }), _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.clearSelection'), onClick: () => setSelected(new Set()), children: _jsx(X, { "aria-hidden": true }) }), _jsx("span", { className: "flex-1" }), transitions.map((tr) => (_jsx(Button, { size: "sm", busy: transition.isPending && transition.variables?.name === tr.name, onClick: () => transition.mutate({ name: tr.name, what: ids }), children: tr.name.charAt(0).toUpperCase() + tr.name.slice(1).replace(/_/g, ' ') }, tr.name))), archivable ? (_jsxs(Button, { size: "sm", busy: archive.isPending, onClick: () => archive.mutate(ids), children: [_jsx(Archive, { "aria-hidden": true }), t('views.archive')] })) : null, served.operations.unlink ? (_jsxs(Button, { size: "sm", variant: "danger", onClick: () => setConfirmingDelete(true), children: [_jsx(Trash2, { "aria-hidden": true }), t('views.delete')] })) : null, canExport ? (_jsxs(Button, { size: "sm", busy: exportAs.isPending, onClick: () => exportAs.mutate('csv'), children: [_jsx(Download, { "aria-hidden": true }), t('views.export')] })) : null] })) : (_jsxs("div", { className: "flex flex-wrap items-center gap-2 border-b px-3 py-1.5 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: count === null ? (_jsx(Skeleton, { className: "inline-block h-4 w-20" })) : (t('views.records', { count })) }), _jsx("span", { className: "flex-1" }), design.active ? (_jsxs(Button, { size: "sm", variant: "ghost", className: "border border-dashed border-primary/50 text-primary", onClick: () => design.choose({
818
- kind: 'list',
819
- view: view.id,
820
- model,
821
- after: allColumns.at(-1)?.id ?? '',
822
- shown: allColumns.map((c) => c.field),
823
- }), children: [_jsx(Plus, { "aria-hidden": true }), t('views.design.addColumn')] })) : null, _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.density'), "aria-pressed": density === 'compact', title: density === 'compact' ? t('views.comfortable') : t('views.compact'), onClick: () => {
824
- const next = density === 'compact' ? 'comfortable' : 'compact';
825
- setDensity(next);
826
- }, children: _jsx(Rows3, { "aria-hidden": true }) }), _jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.columns'), children: _jsx(Columns3, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "end", children: [_jsx(MenuLabel, { children: t('views.columns') }), allColumns.map((c) => (_jsx(MenuCheckboxItem, { checked: !hidden.has(c.id), onCheckedChange: (checked) => {
827
- const next = new Set(hidden);
828
- if (checked)
829
- next.delete(c.id);
830
- else if (next.size < allColumns.length - 1)
831
- next.add(c.id);
832
- setHidden(next);
833
- stored.set(columnsKey, [...next]);
834
- }, children: c.label ?? fields[c.field]?.label ?? c.field }, c.id)))] })] }), canExport ? (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.export'), busy: exportAs.isPending, children: _jsx(Download, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "end", children: [_jsx(MenuLabel, { children: t('views.export') }), _jsx(MenuItem, { onSelect: () => exportAs.mutate('csv'), children: t('views.exportCsv') }), _jsx(MenuItem, { onSelect: () => exportAs.mutate('xlsx'), children: t('views.exportXlsx') })] })] })) : null] }));
842
+ // Selected rows: a floating bar at the bottom of the screen (RFC 0018 §4.3); the list's own toolbar stays put.
843
+ const floating = ids.length > 0 ? (_jsxs("div", { role: "toolbar", "aria-label": t('views.selected', { count: ids.length }), "data-kosh-selection-bar": true, className: "fixed inset-x-0 bottom-4 z-30 mx-auto flex w-fit max-w-[calc(100vw-2rem)] flex-wrap items-center gap-2 rounded-xl border bg-surface px-3 py-2 text-sm shadow-lg", children: [_jsx("span", { className: "font-medium", children: t('views.selected', { count: ids.length }) }), _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.clearSelection'), onClick: () => setSelected(new Set()), children: _jsx(X, { "aria-hidden": true }) }), _jsx("span", { className: "flex-1" }), transitions.map((tr) => (_jsx(Button, { size: "sm", busy: transition.isPending && transition.variables?.name === tr.name, onClick: () => transition.mutate({ name: tr.name, what: ids }), children: tr.name.charAt(0).toUpperCase() + tr.name.slice(1).replace(/_/g, ' ') }, tr.name))), archivable ? (_jsxs(Button, { size: "sm", busy: archive.isPending, onClick: () => archive.mutate(ids), children: [_jsx(Archive, { "aria-hidden": true }), t('views.archive')] })) : null, served.operations.unlink ? (_jsxs(Button, { size: "sm", variant: "danger", onClick: () => setConfirmingDelete(true), children: [_jsx(Trash2, { "aria-hidden": true }), t('views.delete')] })) : null, canExport ? (_jsxs(Button, { size: "sm", busy: exportAs.isPending, onClick: () => exportAs.mutate('csv'), children: [_jsx(Download, { "aria-hidden": true }), t('views.export')] })) : null] })) : null;
844
+ const toolbar = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 border-b px-3 py-1.5 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: count === null ? (_jsx(Skeleton, { className: "inline-block h-4 w-20" })) : (t('views.records', { count })) }), _jsx("span", { className: "flex-1" }), design.active ? (_jsxs(Button, { size: "sm", variant: "ghost", className: "border border-dashed border-primary/50 text-primary", onClick: () => design.choose({
845
+ kind: 'list',
846
+ view: view.id,
847
+ model,
848
+ after: allColumns.at(-1)?.id ?? '',
849
+ shown: allColumns.map((c) => c.field),
850
+ }), children: [_jsx(Plus, { "aria-hidden": true }), t('views.design.addColumn')] })) : null, _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.density'), "aria-pressed": density === 'compact', title: density === 'compact' ? t('views.comfortable') : t('views.compact'), onClick: () => {
851
+ const next = density === 'compact' ? 'comfortable' : 'compact';
852
+ setDensity(next);
853
+ }, children: _jsx(Rows3, { "aria-hidden": true }) }), _jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.columns'), children: _jsx(Columns3, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "end", children: [_jsx(MenuLabel, { children: t('views.columns') }), allColumns.map((c) => (_jsx(MenuCheckboxItem, { checked: !hidden.has(c.id), onCheckedChange: (checked) => {
854
+ const next = new Set(hidden);
855
+ if (checked)
856
+ next.delete(c.id);
857
+ else if (next.size < allColumns.length - 1)
858
+ next.add(c.id);
859
+ setHidden(next);
860
+ stored.set(columnsKey, [...next]);
861
+ }, children: c.label ?? fields[c.field]?.label ?? c.field }, c.id)))] })] }), canExport ? (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('views.export'), busy: exportAs.isPending, children: _jsx(Download, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "end", children: [_jsx(MenuLabel, { children: t('views.export') }), _jsx(MenuItem, { onSelect: () => exportAs.mutate('csv'), children: t('views.exportCsv') }), _jsx(MenuItem, { onSelect: () => exportAs.mutate('xlsx'), children: t('views.exportXlsx') })] })] })) : null] }), floating] }));
835
862
  return (_jsxs("div", { className: "rounded-lg border bg-surface", children: [props.groupBy.length > 0 ? (_jsx(Groups, { props: props, columns: columns, fields: fields, height: height })) : (_jsxs(_Fragment, { children: [toolbar, _jsx(Rows, { props: props, where: props.where, columns: columns, fields: fields, offset: offset, limit: pageSize, height: height, selection: { selected, onToggle: toggle, onToggleAll: toggleAll }, hiddenIds: pendingDelete, onCount: setCount, onRows: setPageRows, filtered: filtered }), count !== null && count > pageSize ? (_jsxs("nav", { "aria-label": "Pagination", className: "flex items-center justify-end gap-2 border-t px-3 py-2 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: t('views.range', {
836
863
  from: offset + 1,
837
864
  to: Math.min(offset + pageSize, count),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shutro-erp",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Shutro ERP (Kosh): a modern ERP framework on TypeScript, PostgreSQL and the Shutro ORM — typed end to end, permissions enforced on the server, metadata-driven views, reporting, documents and a chatter on every record.",
5
5
  "keywords": [
6
6
  "erp",