shutro-erp 0.3.2 → 0.3.3
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 +29 -1
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +29 -1
- package/dist/assistant/index.js +1 -1
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +29 -1
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +29 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +29 -4
- package/dist/l10n_in/module.d.ts +0 -62
- package/dist/l10n_in/module.js +2 -4
- package/dist/payment/.kosh/bindings.js +1 -1
- package/dist/payment/.kosh/schema.d.ts +30 -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 +29 -1
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +29 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +29 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +29 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +29 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +29 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +29 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +29 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +29 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +29 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +29 -1
- package/dist/server/auth/base.js +22 -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 +52 -0
- package/dist/server/auth/service.js +168 -3
- package/dist/server/identity.d.ts +2 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/server.js +11 -3
- package/dist/settings/company.d.ts +34 -0
- package/dist/settings/company.js +187 -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 +505 -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 +94 -0
- package/dist/settings/web/OverviewPage.d.ts +9 -0
- package/dist/settings/web/OverviewPage.js +66 -0
- package/dist/settings/web/PeoplePage.d.ts +9 -0
- package/dist/settings/web/PeoplePage.js +163 -0
- package/dist/settings/web/ProfilePage.d.ts +14 -0
- package/dist/settings/web/ProfilePage.js +194 -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 +9 -0
- package/dist/settings/web/index.js +26 -1
- package/dist/settings/web/messages.js +566 -0
- package/dist/ui/api.d.ts +4 -0
- package/dist/ui/app.d.ts +5 -0
- package/dist/ui/app.js +108 -31
- 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 +46 -0
- package/dist/ui/i18n/messages.js +92 -0
- package/dist/ui/index.d.ts +2 -1
- package/dist/ui/index.js +2 -1
- package/dist/ui/lib/accent.d.ts +13 -0
- package/dist/ui/lib/accent.js +36 -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/Sidebar.d.ts +27 -0
- package/dist/ui/shell/Sidebar.js +84 -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 +8 -3
- 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/styles.css
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Kosh design tokens (RFC 0007): colors, radius and
|
|
2
|
+
* Kosh design tokens (RFC 0007, refreshed by RFC 0018): colors, radius, typography and density as CSS variables, light
|
|
3
|
+
* and dark themes with the company's accent, Tailwind v4
|
|
3
4
|
* utilities mapped onto them. Components use logical properties only (ms/me, ps/pe, start/end), so RTL needs no
|
|
4
5
|
* overrides. Import once from the app: `@import './styles.css';` after `@import 'tailwindcss';`.
|
|
5
6
|
*/
|
|
@@ -30,23 +31,33 @@
|
|
|
30
31
|
--font-sans: var(--font-family);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
:
|
|
34
|
-
|
|
34
|
+
/* `.light` (and `.dark`) re-declare the tokens on a part of the page: a preview in the other theme, or with another
|
|
35
|
+
accent — custom properties are computed where they are declared, so the accent recomputes there. */
|
|
36
|
+
:root,
|
|
37
|
+
.light {
|
|
38
|
+
/* 6 px controls; cards and popovers use --radius-lg (10 px). */
|
|
39
|
+
--radius: 0.375rem;
|
|
35
40
|
--font-family: "Inter", "Noto Sans Bengali", "Noto Sans Arabic", system-ui, sans-serif;
|
|
36
|
-
/*
|
|
37
|
-
--
|
|
38
|
-
--
|
|
41
|
+
/* The accent (RFC 0018): a company's brand hue and chroma, indigo by default; each theme fixes its lightness. */
|
|
42
|
+
--accent-h: 277;
|
|
43
|
+
--accent-c: 0.2;
|
|
44
|
+
/* Density (RFC 0018): comfortable by default, compact as a user setting. */
|
|
45
|
+
--control-h: 2.25rem;
|
|
46
|
+
--cell-py: 0.375rem;
|
|
47
|
+
/* Light: neutral (zinc) surfaces; text contrast ≥ 4.5:1 on background and surface (WCAG AA). */
|
|
48
|
+
--background: oklch(0.985 0.002 286);
|
|
49
|
+
--foreground: oklch(0.21 0.006 286);
|
|
39
50
|
--surface: oklch(1 0 0);
|
|
40
|
-
--surface-foreground: oklch(0.21 0.
|
|
41
|
-
--muted: oklch(0.
|
|
42
|
-
--muted-foreground: oklch(0.
|
|
43
|
-
--border: oklch(0.
|
|
44
|
-
--input: oklch(0.
|
|
45
|
-
--ring: oklch(0.55
|
|
46
|
-
--primary: oklch(0.5
|
|
51
|
+
--surface-foreground: oklch(0.21 0.006 286);
|
|
52
|
+
--muted: oklch(0.965 0.003 286);
|
|
53
|
+
--muted-foreground: oklch(0.47 0.014 286);
|
|
54
|
+
--border: oklch(0.92 0.004 286);
|
|
55
|
+
--input: oklch(0.87 0.007 286);
|
|
56
|
+
--ring: oklch(0.55 var(--accent-c) var(--accent-h));
|
|
57
|
+
--primary: oklch(0.5 var(--accent-c) var(--accent-h));
|
|
47
58
|
--primary-foreground: oklch(0.99 0 0);
|
|
48
|
-
--accent: oklch(0.
|
|
49
|
-
--accent-foreground: oklch(0.
|
|
59
|
+
--accent: oklch(0.96 calc(var(--accent-c) * 0.16) var(--accent-h));
|
|
60
|
+
--accent-foreground: oklch(0.38 var(--accent-c) var(--accent-h));
|
|
50
61
|
--danger: oklch(0.52 0.2 25);
|
|
51
62
|
--danger-foreground: oklch(0.99 0 0);
|
|
52
63
|
--success: oklch(0.5 0.13 150);
|
|
@@ -56,20 +67,25 @@
|
|
|
56
67
|
color-scheme: light;
|
|
57
68
|
}
|
|
58
69
|
|
|
70
|
+
[data-density="compact"] {
|
|
71
|
+
--control-h: 2rem;
|
|
72
|
+
--cell-py: 0.125rem;
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
.dark {
|
|
60
|
-
--background: oklch(0.
|
|
61
|
-
--foreground: oklch(0.
|
|
62
|
-
--surface: oklch(0.
|
|
63
|
-
--surface-foreground: oklch(0.
|
|
64
|
-
--muted: oklch(0.
|
|
65
|
-
--muted-foreground: oklch(0.72 0.
|
|
66
|
-
--border: oklch(0.
|
|
67
|
-
--input: oklch(0.
|
|
68
|
-
--ring: oklch(0.7 0.
|
|
69
|
-
--primary: oklch(0.
|
|
70
|
-
--primary-foreground: oklch(0.16 0.
|
|
71
|
-
--accent: oklch(0.
|
|
72
|
-
--accent-foreground: oklch(0.
|
|
76
|
+
--background: oklch(0.145 0.004 286);
|
|
77
|
+
--foreground: oklch(0.95 0.003 286);
|
|
78
|
+
--surface: oklch(0.185 0.005 286);
|
|
79
|
+
--surface-foreground: oklch(0.95 0.003 286);
|
|
80
|
+
--muted: oklch(0.24 0.006 286);
|
|
81
|
+
--muted-foreground: oklch(0.72 0.012 286);
|
|
82
|
+
--border: oklch(0.29 0.006 286);
|
|
83
|
+
--input: oklch(0.35 0.008 286);
|
|
84
|
+
--ring: oklch(0.7 min(var(--accent-c), 0.16) var(--accent-h));
|
|
85
|
+
--primary: oklch(0.7 min(var(--accent-c), 0.16) var(--accent-h));
|
|
86
|
+
--primary-foreground: oklch(0.16 0.01 var(--accent-h));
|
|
87
|
+
--accent: oklch(0.28 calc(var(--accent-c) * 0.3) var(--accent-h));
|
|
88
|
+
--accent-foreground: oklch(0.9 calc(var(--accent-c) * 0.35) var(--accent-h));
|
|
73
89
|
--danger: oklch(0.65 0.19 25);
|
|
74
90
|
--danger-foreground: oklch(0.16 0.02 260);
|
|
75
91
|
--success: oklch(0.7 0.14 150);
|
|
@@ -87,6 +103,21 @@
|
|
|
87
103
|
background: var(--background);
|
|
88
104
|
color: var(--foreground);
|
|
89
105
|
font-family: var(--font-family);
|
|
106
|
+
font-size: 0.875rem;
|
|
107
|
+
line-height: 1.5;
|
|
108
|
+
/* Inter: the single-storey a and open digits; numbers line up in columns. */
|
|
109
|
+
font-feature-settings: "cv11", "ss01";
|
|
110
|
+
-webkit-font-smoothing: antialiased;
|
|
111
|
+
}
|
|
112
|
+
h1,
|
|
113
|
+
h2,
|
|
114
|
+
h3 {
|
|
115
|
+
letter-spacing: -0.011em;
|
|
116
|
+
}
|
|
117
|
+
td,
|
|
118
|
+
.tabular-nums,
|
|
119
|
+
input[inputmode="decimal"] {
|
|
120
|
+
font-variant-numeric: tabular-nums;
|
|
90
121
|
}
|
|
91
122
|
:focus-visible {
|
|
92
123
|
outline: 2px solid var(--ring);
|
|
@@ -103,3 +134,14 @@
|
|
|
103
134
|
}
|
|
104
135
|
}
|
|
105
136
|
}
|
|
137
|
+
|
|
138
|
+
/* Outside the layers on purpose: unlayered styles win over Tailwind's utilities (the inputs' border classes). */
|
|
139
|
+
/* Saved records read first (RFC 0018 §4.2): their inputs show no box until hovered or focused. */
|
|
140
|
+
.kosh-quiet
|
|
141
|
+
:is(input:not([type="checkbox"]):not([type="color"]), textarea, button[role="combobox"]):not(
|
|
142
|
+
:hover
|
|
143
|
+
):not(:focus-visible):not(:focus):not([aria-invalid="true"]) {
|
|
144
|
+
border-color: transparent;
|
|
145
|
+
background-color: transparent;
|
|
146
|
+
box-shadow: none;
|
|
147
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
|
+
/** A typed date as ISO, or null: `2026-10-28`, or `28/10/2026` (`10/28/2026` where months come first). */
|
|
3
|
+
export declare function parseTypedDate(text: string, lang: string): string | null;
|
|
4
|
+
export declare function DateField({ id, value, onChange, invalid, required, }: {
|
|
5
|
+
readonly id?: string;
|
|
6
|
+
readonly value: string | null;
|
|
7
|
+
readonly onChange: (iso: string | null) => void;
|
|
8
|
+
readonly invalid?: boolean;
|
|
9
|
+
readonly required?: boolean;
|
|
10
|
+
}): ReactElement;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The date field (RFC 0018 §4.2): the date as the user's language writes it (`28 Oct 2026`, `২৮ অক্টো, ২০২৬`), typed
|
|
4
|
+
* (ISO `2026-10-28`, or day, month and year in the language's order) or picked on a calendar — arrows move by
|
|
5
|
+
* day and week, Page Up / Page Down by month, Home / End to the week's ends, Enter picks. Values stay ISO dates.
|
|
6
|
+
*/
|
|
7
|
+
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
8
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
+
import { useTranslation } from 'react-i18next';
|
|
10
|
+
import { Button, Input, Popover, PopoverContent, PopoverTrigger, } from '../components/primitives.js';
|
|
11
|
+
import { useKosh } from '../context.js';
|
|
12
|
+
import { cn } from '../lib/cn.js';
|
|
13
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
14
|
+
const iso = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
15
|
+
const fromIso = (value) => {
|
|
16
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
|
17
|
+
if (m === null)
|
|
18
|
+
return null;
|
|
19
|
+
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
20
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
21
|
+
};
|
|
22
|
+
const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
|
23
|
+
const addMonths = (d, n) => {
|
|
24
|
+
const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
|
|
25
|
+
const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
|
|
26
|
+
return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), last));
|
|
27
|
+
};
|
|
28
|
+
/** The order of day, month and year in `lang`'s numeric dates (`dmy`, `mdy`, `ymd`). */
|
|
29
|
+
function orderOf(lang) {
|
|
30
|
+
try {
|
|
31
|
+
return new Intl.DateTimeFormat(lang, { day: 'numeric', month: 'numeric', year: 'numeric' })
|
|
32
|
+
.formatToParts(new Date(2026, 9, 28))
|
|
33
|
+
.map((p) => p.type[0])
|
|
34
|
+
.filter((c) => c === 'd' || c === 'm' || c === 'y')
|
|
35
|
+
.join('');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return 'dmy';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** A typed date as ISO, or null: `2026-10-28`, or `28/10/2026` (`10/28/2026` where months come first). */
|
|
42
|
+
export function parseTypedDate(text, lang) {
|
|
43
|
+
const raw = text.trim();
|
|
44
|
+
if (raw === '')
|
|
45
|
+
return null;
|
|
46
|
+
const isoDate = fromIso(raw);
|
|
47
|
+
if (isoDate !== null && /^\d{4}-\d{2}-\d{2}$/.test(raw))
|
|
48
|
+
return iso(isoDate);
|
|
49
|
+
// Any digits (Bengali ones too) separated by / . - or spaces.
|
|
50
|
+
const digits = raw.replace(/[০-৯]/g, (c) => String(c.charCodeAt(0) - 0x09e6));
|
|
51
|
+
const parts = digits.split(/[\s/.-]+/).filter((p) => p !== '');
|
|
52
|
+
if (parts.length !== 3 || parts.some((p) => !/^\d+$/.test(p)))
|
|
53
|
+
return null;
|
|
54
|
+
const order = orderOf(lang);
|
|
55
|
+
const at = (c) => Number(parts[order.indexOf(c)]);
|
|
56
|
+
let year = at('y');
|
|
57
|
+
if (year < 100)
|
|
58
|
+
year += 2000;
|
|
59
|
+
const d = new Date(year, at('m') - 1, at('d'));
|
|
60
|
+
return d.getMonth() === at('m') - 1 && d.getDate() === at('d') ? iso(d) : null;
|
|
61
|
+
}
|
|
62
|
+
/** The first day of the week in `lang` (0 Sunday … 6 Saturday). */
|
|
63
|
+
function weekStart(lang) {
|
|
64
|
+
try {
|
|
65
|
+
const locale = new Intl.Locale(lang);
|
|
66
|
+
const first = (locale.getWeekInfo?.() ?? locale.weekInfo)?.firstDay;
|
|
67
|
+
return first === undefined ? 1 : first % 7;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function Calendar({ value, onPick, lang, }) {
|
|
74
|
+
const { t } = useTranslation();
|
|
75
|
+
const { format } = useKosh();
|
|
76
|
+
const today = useMemo(() => new Date(), []);
|
|
77
|
+
const [focus, setFocus] = useState(() => (value === null ? null : fromIso(value)) ?? today);
|
|
78
|
+
const grid = useRef(null);
|
|
79
|
+
const first = weekStart(lang);
|
|
80
|
+
const month = new Date(focus.getFullYear(), focus.getMonth(), 1);
|
|
81
|
+
const start = addDays(month, -((month.getDay() - first + 7) % 7));
|
|
82
|
+
const days = Array.from({ length: 42 }, (_, i) => addDays(start, i));
|
|
83
|
+
const weekday = new Intl.DateTimeFormat(lang, { weekday: 'narrow' });
|
|
84
|
+
const title = new Intl.DateTimeFormat(lang, { month: 'long', year: 'numeric' }).format(month);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
grid.current?.querySelector(`[data-day="${iso(focus)}"]`)?.focus();
|
|
87
|
+
}, [focus]);
|
|
88
|
+
const onKey = (e) => {
|
|
89
|
+
const moves = {
|
|
90
|
+
ArrowLeft: () => addDays(focus, document.dir === 'rtl' ? 1 : -1),
|
|
91
|
+
ArrowRight: () => addDays(focus, document.dir === 'rtl' ? -1 : 1),
|
|
92
|
+
ArrowUp: () => addDays(focus, -7),
|
|
93
|
+
ArrowDown: () => addDays(focus, 7),
|
|
94
|
+
PageUp: () => addMonths(focus, -1),
|
|
95
|
+
PageDown: () => addMonths(focus, 1),
|
|
96
|
+
Home: () => addDays(focus, -((focus.getDay() - first + 7) % 7)),
|
|
97
|
+
End: () => addDays(focus, 6 - ((focus.getDay() - first + 7) % 7)),
|
|
98
|
+
};
|
|
99
|
+
const move = moves[e.key];
|
|
100
|
+
if (move === undefined)
|
|
101
|
+
return;
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
setFocus(move());
|
|
104
|
+
};
|
|
105
|
+
return (_jsxs("div", { className: "grid gap-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Button, { type: "button", variant: "ghost", size: "icon", "aria-label": t('views.previousMonth'), onClick: () => setFocus(addMonths(focus, -1)), children: _jsx(ChevronLeft, { className: "rtl:rotate-180", "aria-hidden": true }) }), _jsx("p", { className: "text-sm font-medium", "aria-live": "polite", children: title }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", "aria-label": t('views.nextMonth'), onClick: () => setFocus(addMonths(focus, 1)), children: _jsx(ChevronRight, { className: "rtl:rotate-180", "aria-hidden": true }) })] }), _jsxs("table", { ref: grid, "aria-label": title, className: "border-separate border-spacing-0.5", onKeyDown: onKey, children: [_jsx("thead", { children: _jsx("tr", { children: days.slice(0, 7).map((d) => (_jsx("th", { scope: "col", className: "py-1 text-center text-xs font-normal text-muted-foreground", children: weekday.format(d) }, iso(d)))) }) }), _jsx("tbody", { children: [0, 1, 2, 3, 4, 5].map((week) => (_jsx("tr", { children: days.slice(week * 7, week * 7 + 7).map((d) => {
|
|
106
|
+
const key = iso(d);
|
|
107
|
+
const selected = key === value;
|
|
108
|
+
const inMonth = d.getMonth() === month.getMonth();
|
|
109
|
+
return (_jsx("td", { className: "p-0", children: _jsx("button", { type: "button", "data-day": key, tabIndex: key === iso(focus) ? 0 : -1, "aria-pressed": selected, "aria-label": format.date(key), onClick: () => onPick(key), className: cn('size-8 rounded-md text-sm tabular-nums hover:bg-muted', !inMonth && 'text-muted-foreground/60', key === iso(today) && 'font-semibold underline underline-offset-4', selected && 'bg-primary text-primary-foreground hover:bg-primary'), children: new Intl.NumberFormat(lang).format(d.getDate()) }) }, key));
|
|
110
|
+
}) }, week))) })] }), _jsxs("div", { className: "flex justify-between", children: [_jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => onPick(iso(today)), children: t('views.today') }), _jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => onPick(null), children: t('views.clear') })] })] }));
|
|
111
|
+
}
|
|
112
|
+
export function DateField({ id, value, onChange, invalid, required, }) {
|
|
113
|
+
const { t, i18n } = useTranslation();
|
|
114
|
+
const { format } = useKosh();
|
|
115
|
+
const lang = i18n.resolvedLanguage ?? i18n.language ?? 'en';
|
|
116
|
+
const [typing, setTyping] = useState(null);
|
|
117
|
+
const [open, setOpen] = useState(false);
|
|
118
|
+
const shown = typing ?? (value === null ? '' : format.date(value));
|
|
119
|
+
const bad = typing !== null && typing.trim() !== '' && parseTypedDate(typing, lang) === null;
|
|
120
|
+
return (_jsxs("div", { className: "relative", children: [_jsx(Input, { id: id, value: shown, required: required, placeholder: t('views.datePlaceholder'), "aria-invalid": invalid === true || bad ? true : undefined, className: "pe-10", onFocus: () => setTyping(value ?? ''), onChange: (e) => {
|
|
121
|
+
setTyping(e.target.value);
|
|
122
|
+
const parsed = parseTypedDate(e.target.value, lang);
|
|
123
|
+
if (parsed !== null || e.target.value.trim() === '')
|
|
124
|
+
onChange(parsed);
|
|
125
|
+
}, onBlur: () => setTyping(null) }), _jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "absolute inset-y-0 end-0 h-full", "aria-label": t('views.pickDate'), children: _jsx(CalendarDays, { "aria-hidden": true }) }) }), _jsx(PopoverContent, { align: "end", className: "w-auto", children: _jsx(Calendar, { value: value, lang: lang, onPick: (picked) => {
|
|
126
|
+
onChange(picked);
|
|
127
|
+
setOpen(false);
|
|
128
|
+
} }) })] })] }));
|
|
129
|
+
}
|
|
@@ -8,7 +8,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
8
8
|
import { evaluate, holds, } from '../../views/index.js';
|
|
9
9
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
10
10
|
import { useBlocker, useParams } from '@tanstack/react-router';
|
|
11
|
-
import { ArrowLeft, Check, ChevronLeft, ChevronRight, CircleDot, MoreHorizontal, Plus, Trash2, } from 'lucide-react';
|
|
11
|
+
import { ArrowLeft, Check, ChevronLeft, ChevronRight, CircleDot, MoreHorizontal, PanelRight, Plus, Trash2, } from 'lucide-react';
|
|
12
12
|
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
13
13
|
import { useTranslation } from 'react-i18next';
|
|
14
14
|
import { ApiError, NetworkError, } from '../api.js';
|
|
@@ -18,7 +18,9 @@ import { useDesign } from '../design.js';
|
|
|
18
18
|
import { cn } from '../lib/cn.js';
|
|
19
19
|
import { useLiveRecord } from '../live.js';
|
|
20
20
|
import { useToast } from '../shell/toast.js';
|
|
21
|
+
import { StatusChip } from './StatusChip.js';
|
|
21
22
|
import { PrefetchedNames, useRowNames } from './widgets.js';
|
|
23
|
+
const pick = (row, keys) => Object.fromEntries(keys.filter((k) => k in row).map((k) => [k, row[k]]));
|
|
22
24
|
/** A line's values as the server takes them: many2many as `{ set }` (a line's taxes). */
|
|
23
25
|
function lineValues(values, fields) {
|
|
24
26
|
const out = {};
|
|
@@ -26,6 +28,58 @@ function lineValues(values, fields) {
|
|
|
26
28
|
out[key] = fields[key]?.kind === 'many2many' && Array.isArray(value) ? { set: value } : value;
|
|
27
29
|
return out;
|
|
28
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The one2many and many2many fields a form shows (its lines, its tags): a record's default fields leave them out,
|
|
33
|
+
* so the form asks for them by name.
|
|
34
|
+
*/
|
|
35
|
+
const RAIL_KEY = 'kosh.rail';
|
|
36
|
+
/** Whether the record pages' right rail is open (RFC 0018: open by default), remembered per browser. */
|
|
37
|
+
function useRail() {
|
|
38
|
+
const [open, setOpen] = useState(() => {
|
|
39
|
+
try {
|
|
40
|
+
return localStorage.getItem(RAIL_KEY) !== 'closed';
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return [
|
|
47
|
+
open,
|
|
48
|
+
(next) => {
|
|
49
|
+
setOpen(next);
|
|
50
|
+
try {
|
|
51
|
+
if (next)
|
|
52
|
+
localStorage.removeItem(RAIL_KEY);
|
|
53
|
+
else
|
|
54
|
+
localStorage.setItem(RAIL_KEY, 'closed');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// storage unavailable: the choice lasts for this page
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
function relationFieldsOf(sheet, fields) {
|
|
63
|
+
const out = new Set();
|
|
64
|
+
const walk = (nodes) => {
|
|
65
|
+
for (const node of nodes) {
|
|
66
|
+
if (node.kind === 'lines')
|
|
67
|
+
out.add(node.field);
|
|
68
|
+
else if (node.kind === 'field') {
|
|
69
|
+
const kind = fields[node.field]?.kind;
|
|
70
|
+
if (kind === 'one2many' || kind === 'many2many')
|
|
71
|
+
out.add(node.field);
|
|
72
|
+
}
|
|
73
|
+
else if (node.kind === 'group')
|
|
74
|
+
walk(node.children);
|
|
75
|
+
else if (node.kind === 'notebook')
|
|
76
|
+
for (const page of node.pages)
|
|
77
|
+
walk(page.children);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
walk(sheet);
|
|
81
|
+
return [...out];
|
|
82
|
+
}
|
|
29
83
|
function commandsOf(lines, fields) {
|
|
30
84
|
return {
|
|
31
85
|
...(lines.rows.some((r) => r.id === undefined)
|
|
@@ -108,6 +162,7 @@ export function FormView({ served, recordId, defaults, onSaved, onBack, crumbs,
|
|
|
108
162
|
const toast = useToast();
|
|
109
163
|
const queryClient = useQueryClient();
|
|
110
164
|
const formId = useId();
|
|
165
|
+
const [railOpen, setRailOpen] = useRail();
|
|
111
166
|
const route = useParams({ strict: false });
|
|
112
167
|
const view = served.view;
|
|
113
168
|
if (view.type !== 'form')
|
|
@@ -130,7 +185,24 @@ export function FormView({ served, recordId, defaults, onSaved, onBack, crumbs,
|
|
|
130
185
|
},
|
|
131
186
|
etag: null,
|
|
132
187
|
}
|
|
133
|
-
:
|
|
188
|
+
: (async () => {
|
|
189
|
+
// The defaults (what conditions and the header read), then the lines and tags the form shows.
|
|
190
|
+
const relations = relationFieldsOf(view.sheet, fields);
|
|
191
|
+
const [record, related] = await Promise.all([
|
|
192
|
+
api.get(model, recordId),
|
|
193
|
+
relations.length === 0 ? null : api.get(model, recordId, relations),
|
|
194
|
+
]);
|
|
195
|
+
return related === null
|
|
196
|
+
? record
|
|
197
|
+
: {
|
|
198
|
+
...record,
|
|
199
|
+
record: {
|
|
200
|
+
...related.record,
|
|
201
|
+
...record.record,
|
|
202
|
+
...pick(related.record, relations),
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
})(),
|
|
134
206
|
});
|
|
135
207
|
// Proposed values (from a document) are unsaved changes: Save, Discard and the leave guard apply to them.
|
|
136
208
|
// Proposed lines of a one2many are new rows of its line editor.
|
|
@@ -374,13 +446,84 @@ export function FormView({ served, recordId, defaults, onSaved, onBack, crumbs,
|
|
|
374
446
|
(state === undefined || spec.from.includes(String(state))) &&
|
|
375
447
|
visible(b.visibleWhen));
|
|
376
448
|
});
|
|
449
|
+
// The rail (RFC 0018 §4.2): the summary (the view's `summary` fields, else its `totals` group), the record's
|
|
450
|
+
// details, and the timeline.
|
|
451
|
+
const totalsGroup = view.summary === undefined
|
|
452
|
+
? view.sheet.find((n) => n.kind === 'group' && n.id === 'totals')
|
|
453
|
+
: undefined;
|
|
454
|
+
const summaryNodes = view.summary !== undefined
|
|
455
|
+
? view.summary.map((key) => ({ kind: 'field', id: `summary-${key}`, field: key }))
|
|
456
|
+
: totalsGroup?.kind === 'group'
|
|
457
|
+
? totalsGroup.children.filter((n) => n.kind === 'field')
|
|
458
|
+
: [];
|
|
459
|
+
const row = (node) => {
|
|
460
|
+
const field = fields[node.field];
|
|
461
|
+
if (field === undefined || !visible(node.visibleWhen))
|
|
462
|
+
return null;
|
|
463
|
+
const Widget = registry.widget(node, field);
|
|
464
|
+
return (_jsxs("div", { className: "flex items-baseline justify-between gap-3 py-1 text-sm", children: [_jsx("dt", { className: "text-muted-foreground", children: node.label ?? field.label }), _jsx("dd", { className: "text-end font-medium tabular-nums", children: _jsx(Widget, { id: `${formId}-rail-${node.id}`, node: node, field: field, value: values[node.field], record: values, readonly: true }) })] }, node.id));
|
|
465
|
+
};
|
|
466
|
+
const detailNodes = ['createdBy', 'createdAt', 'updatedBy', 'updatedAt', 'company']
|
|
467
|
+
.filter((key) => fields[key] !== undefined && values[key] !== undefined && values[key] !== null)
|
|
468
|
+
.map((key) => ({ kind: 'field', id: `detail-${key}`, field: key }));
|
|
469
|
+
const chatter = view.chatter === true && registry.component('chatter') !== undefined && !isNew
|
|
470
|
+
? registry.component('chatter')
|
|
471
|
+
: undefined;
|
|
472
|
+
const rail = (_jsxs(_Fragment, { children: [summaryNodes.length === 0 ? null : (_jsxs("section", { className: "rounded-lg border bg-surface p-4", "aria-labelledby": `${formId}-summary`, children: [_jsx("h2", { id: `${formId}-summary`, className: "mb-1 text-sm font-semibold", children: t('views.summary') }), _jsx("dl", { className: "divide-y", children: summaryNodes.map(row) })] })), isNew || detailNodes.length === 0 ? null : (_jsxs("section", { className: "rounded-lg border bg-surface p-4", "aria-labelledby": `${formId}-details`, children: [_jsx("h2", { id: `${formId}-details`, className: "mb-1 text-sm font-semibold", children: t('views.details') }), _jsx("dl", { children: detailNodes.map(row) })] })), chatter === undefined
|
|
473
|
+
? null
|
|
474
|
+
: (() => {
|
|
475
|
+
const Chatter = chatter;
|
|
476
|
+
return (_jsx(Chatter, { node: { kind: 'component', id: 'chatter', component: 'chatter' }, record: record ?? null, model: model, served: served, api: api, version: loaded.data?.etag ?? null, reload: () => void loaded.refetch() }));
|
|
477
|
+
})()] }));
|
|
478
|
+
const showRail = railOpen;
|
|
479
|
+
// The main column: each group, notebook and table of lines a card; loose fields together.
|
|
480
|
+
const sections = [];
|
|
481
|
+
for (const node of view.sheet) {
|
|
482
|
+
if (node === totalsGroup)
|
|
483
|
+
continue;
|
|
484
|
+
const loose = node.kind === 'field' || node.kind === 'component';
|
|
485
|
+
const last = sections[sections.length - 1];
|
|
486
|
+
if (loose && last?.key.startsWith('loose-') === true)
|
|
487
|
+
last.nodes.push(node);
|
|
488
|
+
else
|
|
489
|
+
sections.push({ key: loose ? `loose-${node.id}` : node.id, nodes: [node] });
|
|
490
|
+
}
|
|
491
|
+
// The key facts under the title: the view's `subtitle`, else the first many2one, date and monetary fields.
|
|
492
|
+
const flatFields = (nodes) => nodes.flatMap((n) => n.kind === 'field'
|
|
493
|
+
? [n]
|
|
494
|
+
: n.kind === 'group'
|
|
495
|
+
? flatFields(n.children)
|
|
496
|
+
: n.kind === 'notebook'
|
|
497
|
+
? n.pages.flatMap((p) => flatFields(p.children))
|
|
498
|
+
: []);
|
|
499
|
+
const facts = isNew
|
|
500
|
+
? []
|
|
501
|
+
: view.subtitle !== undefined
|
|
502
|
+
? view.subtitle
|
|
503
|
+
.filter((key) => fields[key] !== undefined)
|
|
504
|
+
.map((key) => ({ kind: 'field', id: `fact-${key}`, field: key }))
|
|
505
|
+
: (() => {
|
|
506
|
+
const candidates = flatFields(view.sheet).filter((n) => n.field !== statusbar &&
|
|
507
|
+
values[n.field] !== null &&
|
|
508
|
+
values[n.field] !== undefined &&
|
|
509
|
+
values[n.field] !== '');
|
|
510
|
+
const first = (kinds) => candidates.find((n) => kinds.includes(fields[n.field]?.kind ?? ''));
|
|
511
|
+
// The total, when the summary has one; else its first amount.
|
|
512
|
+
const totals = summaryNodes.find((n) => n.field === 'amountTotal') ??
|
|
513
|
+
summaryNodes.find((n) => fields[n.field]?.kind === 'monetary');
|
|
514
|
+
return [first(['many2one']), first(['date']), totals ?? first(['monetary'])].filter((n) => n !== undefined);
|
|
515
|
+
})();
|
|
377
516
|
const named = [values['displayName'], values['name']].find((v) => typeof v === 'string' && v.trim() !== '');
|
|
378
517
|
const title = named === undefined ? (isNew ? t('views.new') : `#${recordId}`) : String(named);
|
|
379
|
-
return (_jsxs(PrefetchedNames.Provider, { value: names, children: [_jsxs("form", { "aria-label": title, "data-kosh-form": true, className:
|
|
518
|
+
return (_jsxs(PrefetchedNames.Provider, { value: names, children: [_jsxs("form", { "aria-label": title, "data-kosh-form": true, className: cn('grid gap-4', !isNew && 'kosh-quiet'), onSubmit: (e) => {
|
|
380
519
|
e.preventDefault();
|
|
381
520
|
if (dirty && !locked)
|
|
382
521
|
save.mutate();
|
|
383
|
-
}, children: [_jsxs("div", { className: "sticky top-12 z-20 -mx-4 flex flex-wrap items-center justify-between gap-2 border-b bg-background/95 px-4 py-2 backdrop-blur", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "icon", "aria-label": t('views.back'), "data-kosh-back": true, onClick: onBack, children: _jsx(ArrowLeft, { className: "rtl:-scale-x-100", "aria-hidden": true }) }), _jsxs("div", { className: "min-w-0", children: [
|
|
522
|
+
}, children: [_jsxs("div", { className: "sticky top-12 z-20 -mx-4 flex flex-wrap items-center justify-between gap-2 border-b bg-background/95 px-4 py-2 backdrop-blur", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "icon", "aria-label": t('views.back'), "data-kosh-back": true, onClick: onBack, children: _jsx(ArrowLeft, { className: "rtl:-scale-x-100", "aria-hidden": true }) }), _jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2.5", children: [_jsx("h1", { className: "truncate text-xl font-semibold", children: title }), statusbar === undefined || fields[statusbar] === undefined || isNew ? null : (_jsx(StatusChip, { field: fields[statusbar], value: state, transitions: served.transitions.filter((tr) => tr.field === statusbar) })), locked && !isNew ? (_jsx("span", { className: "rounded-full border px-2 py-0.5 text-xs text-muted-foreground", children: t('views.readonly') })) : null] }), facts.length === 0 ? null : (_jsx("p", { className: "mt-0.5 flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", children: facts.map((node, i) => {
|
|
523
|
+
const field = fields[node.field];
|
|
524
|
+
const Widget = registry.widget(node, field);
|
|
525
|
+
return (_jsxs("span", { className: "flex items-center gap-2", title: field.label, children: [i === 0 ? null : _jsx("span", { "aria-hidden": true, children: "\u00B7" }), _jsxs("span", { className: "sr-only", children: [field.label, ": "] }), _jsx(Widget, { id: `${formId}-fact-${node.id}`, node: node, field: field, value: values[node.field], record: values, readonly: true })] }, node.id));
|
|
526
|
+
}) }))] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [live.others.length === 0 ? null : (_jsx("ul", { className: "flex items-center -space-x-1", "aria-label": t('views.viewing', {
|
|
384
527
|
names: live.others.map((u) => u.name).join(', '),
|
|
385
528
|
}), title: t('views.viewing', { names: live.others.map((u) => u.name).join(', ') }), children: live.others.slice(0, 4).map((u) => (_jsx("li", { className: "flex size-6 items-center justify-center rounded-full border-2 border-surface bg-accent text-[10px] font-semibold text-accent-foreground", children: u.name
|
|
386
529
|
.split(/\s+/)
|
|
@@ -390,23 +533,20 @@ export function FormView({ served, recordId, defaults, onSaved, onBack, crumbs,
|
|
|
390
533
|
setChanges({});
|
|
391
534
|
setLines(new Map());
|
|
392
535
|
setProblem(null);
|
|
393
|
-
}, children: t('views.discard') }), _jsx(Button, { type: "submit", variant: "primary", busy: save.isPending, "data-kosh-save": true, children: t('views.save') })] })) : null, !isNew && served.operations.unlink ? (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { type: "button", variant: "ghost", size: "icon", "aria-label": "Actions", children: _jsx(MoreHorizontal, { "aria-hidden": true }) }) }), _jsx(MenuContent, { children: _jsxs(MenuItem, { onSelect: () => setDeleting(true), children: [_jsx(Trash2, { className: "size-4", "aria-hidden": true }), t('views.delete')] }) })] })) : null] })] }), live.changedByOther ? (_jsx(Alert, { tone: "warning", action: _jsx(Button, { type: "button", size: "sm", onClick: () => {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
return (_jsx(Chatter, { node: { kind: 'component', id: 'chatter', component: 'chatter' }, record: record ?? null, model: model, served: served, api: api, version: loaded.data?.etag ?? null, reload: () => void loaded.refetch() }));
|
|
408
|
-
})()
|
|
409
|
-
: null] }), _jsx(Dialog, { open: confirming !== null, onOpenChange: (open) => !open && setConfirming(null), title: confirming?.label ?? confirming?.transition ?? '', description: confirming?.confirm ?? '', footer: _jsxs(_Fragment, { children: [_jsx(Button, { onClick: () => setConfirming(null), children: t('views.no') }), _jsx(Button, { variant: "primary", onClick: () => {
|
|
536
|
+
}, children: t('views.discard') }), _jsx(Button, { type: "submit", variant: "primary", busy: save.isPending, "data-kosh-save": true, children: t('views.save') })] })) : null, _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "hidden xl:inline-flex", "aria-pressed": railOpen, "aria-label": railOpen ? t('views.hidePanel') : t('views.showPanel'), title: railOpen ? t('views.hidePanel') : t('views.showPanel'), onClick: () => setRailOpen(!railOpen), children: _jsx(PanelRight, { className: "rtl:-scale-x-100", "aria-hidden": true }) }), !isNew && served.operations.unlink ? (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { type: "button", variant: "ghost", size: "icon", "aria-label": "Actions", children: _jsx(MoreHorizontal, { "aria-hidden": true }) }) }), _jsx(MenuContent, { children: _jsxs(MenuItem, { onSelect: () => setDeleting(true), children: [_jsx(Trash2, { className: "size-4", "aria-hidden": true }), t('views.delete')] }) })] })) : null] })] }), _jsxs("div", { className: cn('grid gap-4', showRail && 'xl:grid-cols-[minmax(0,1fr)_20rem] xl:items-start'), children: [_jsxs("div", { className: "grid min-w-0 content-start gap-4", children: [live.changedByOther ? (_jsx(Alert, { tone: "warning", action: _jsx(Button, { type: "button", size: "sm", onClick: () => {
|
|
537
|
+
setChanges({});
|
|
538
|
+
setLines(new Map());
|
|
539
|
+
live.dismiss();
|
|
540
|
+
void loaded.refetch();
|
|
541
|
+
}, children: t('views.reload') }), children: t('views.changedByOther') })) : null, problem === null ? null : (_jsx(Alert, { tone: problem.kind === 'stale-record' ? 'warning' : 'danger', action: problem.kind === 'stale-record' ? (_jsx(Button, { type: "button", size: "sm", onClick: () => {
|
|
542
|
+
setChanges({});
|
|
543
|
+
setProblem(null);
|
|
544
|
+
void loaded.refetch();
|
|
545
|
+
}, children: t('views.reload') })) : undefined, children: problem.kind === 'stale-record' ? t('views.stale') : problem.message })), warnings.map((w) => (_jsx(Alert, { tone: "info", children: w }, w))), buttons.length === 0 ? null : (_jsx("div", { role: "toolbar", "aria-label": t('views.recordActions'), className: "flex flex-wrap gap-2", children: _jsx("div", { className: "contents", children: buttons.map((b, i) => (_jsx(Button, { type: "button", size: "sm", variant: b.primary === true || (i === 0 && !buttons.some((x) => x.primary === true))
|
|
546
|
+
? 'primary'
|
|
547
|
+
: 'secondary', busy: transition.isPending && transition.variables === b.transition, onClick: () => b.confirm === undefined ? transition.mutate(b.transition) : setConfirming(b), children: b.label ?? b.transition }, b.id))) }) })), sections.map((section) => (_jsx("section", { className: "rounded-lg border bg-surface p-5", children: _jsx("div", { className: "grid gap-x-6 gap-y-4 sm:grid-cols-2", children: renderNodes(section.nodes) }) }, section.key))), showRail ? null : rail] }), showRail ? (_jsx("aside", { "aria-label": t('views.recordPanel'),
|
|
548
|
+
// Beside the record when wide; under it otherwise (one grid column).
|
|
549
|
+
className: "grid content-start gap-4 xl:sticky xl:top-28", children: rail })) : null] })] }), _jsx(Dialog, { open: confirming !== null, onOpenChange: (open) => !open && setConfirming(null), title: confirming?.label ?? confirming?.transition ?? '', description: confirming?.confirm ?? '', footer: _jsxs(_Fragment, { children: [_jsx(Button, { onClick: () => setConfirming(null), children: t('views.no') }), _jsx(Button, { variant: "primary", onClick: () => {
|
|
410
550
|
if (confirming !== null)
|
|
411
551
|
transition.mutate(confirming.transition);
|
|
412
552
|
setConfirming(null);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type ReactElement } from 'react';
|
|
2
2
|
import type { ViewProps } from '../registry.js';
|
|
3
3
|
/** The default list renderer. */
|
|
4
|
+
/** A sum's label: "Total Credit limit", but "Total" alone for a column already called "Total" (no "Total Total"). */
|
|
5
|
+
export declare function totalLabel(word: string, label: string): string;
|
|
4
6
|
export declare function ListView(props: ViewProps): ReactElement;
|
|
@@ -597,7 +597,7 @@ function Totals({ props, columns, fields, }) {
|
|
|
597
597
|
: v === null || v === undefined
|
|
598
598
|
? '0'
|
|
599
599
|
: String(v);
|
|
600
|
-
return (_jsxs("div", { className: "flex gap-2", children: [
|
|
600
|
+
return (_jsxs("div", { className: "flex gap-2", children: [_jsx("dt", { className: "text-muted-foreground", children: totalLabel(t('views.total'), c.label ?? fields[c.field]?.label ?? c.field) }), _jsx("dd", { className: "font-medium tabular-nums", children: format.decimal(amount) })] }, c.id));
|
|
601
601
|
}) }));
|
|
602
602
|
}
|
|
603
603
|
function Groups({ props, columns, fields, height, }) {
|
|
@@ -653,6 +653,12 @@ function saveFile(blob, filename) {
|
|
|
653
653
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
654
654
|
}
|
|
655
655
|
/** The default list renderer. */
|
|
656
|
+
/** A sum's label: "Total Credit limit", but "Total" alone for a column already called "Total" (no "Total Total"). */
|
|
657
|
+
export function totalLabel(word, label) {
|
|
658
|
+
return label.toLocaleLowerCase().startsWith(word.toLocaleLowerCase())
|
|
659
|
+
? label
|
|
660
|
+
: `${word} ${label}`;
|
|
661
|
+
}
|
|
656
662
|
export function ListView(props) {
|
|
657
663
|
const { api, uid } = useKosh();
|
|
658
664
|
const { t } = useTranslation();
|
|
@@ -669,7 +675,7 @@ export function ListView(props) {
|
|
|
669
675
|
const allColumns = view.columns.filter((c) => fields[c.field] !== undefined);
|
|
670
676
|
const columnsKey = `kosh.columns.${uid}.${view.id}`;
|
|
671
677
|
const [hidden, setHidden] = useState(() => new Set(stored.get(columnsKey, [])));
|
|
672
|
-
const
|
|
678
|
+
const { density, setDensity } = useKosh();
|
|
673
679
|
const height = HEIGHTS[density];
|
|
674
680
|
const columns = allColumns.filter((c) => !hidden.has(c.id));
|
|
675
681
|
const design = useDesign();
|
|
@@ -817,7 +823,6 @@ export function ListView(props) {
|
|
|
817
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: () => {
|
|
818
824
|
const next = density === 'compact' ? 'comfortable' : 'compact';
|
|
819
825
|
setDensity(next);
|
|
820
|
-
stored.set('kosh.density', next);
|
|
821
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) => {
|
|
822
827
|
const next = new Set(hidden);
|
|
823
828
|
if (checked)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import type { ViewField } from '../api.js';
|
|
3
|
+
export declare function StatusChip({ field, value, transitions, }: {
|
|
4
|
+
readonly field: ViewField;
|
|
5
|
+
readonly value: unknown;
|
|
6
|
+
readonly transitions: readonly {
|
|
7
|
+
readonly from: readonly string[];
|
|
8
|
+
readonly to: string;
|
|
9
|
+
}[];
|
|
10
|
+
}): ReactElement;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A record's state as one chip in its meaning's tone (RFC 0018 §4.2), instead of a row of every state: it opens the
|
|
4
|
+
* whole workflow — the states passed, the current one, the ones a transition leads to next. The server serves the
|
|
5
|
+
* transitions; the chip only shows them.
|
|
6
|
+
*/
|
|
7
|
+
import { Check, ChevronDown, Circle } from 'lucide-react';
|
|
8
|
+
import { useTranslation } from 'react-i18next';
|
|
9
|
+
import { badgeVariants, Popover, PopoverContent, PopoverTrigger, } from '../components/primitives.js';
|
|
10
|
+
import { cn } from '../lib/cn.js';
|
|
11
|
+
import { toneOf } from './widgets.js';
|
|
12
|
+
export function StatusChip({ field, value, transitions, }) {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const options = field.options ?? [];
|
|
15
|
+
const index = options.findIndex((o) => o.value === value);
|
|
16
|
+
const current = options[index];
|
|
17
|
+
const next = new Set(transitions.filter((tr) => tr.from.includes(String(value))).map((tr) => tr.to));
|
|
18
|
+
return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": t('views.statusOf', { field: field.label, state: current?.label ?? '—' }), className: cn(badgeVariants({ tone: toneOf(String(value ?? '')) }), 'gap-1.5 px-2.5 py-1 hover:brightness-95 focus-visible:outline-2'), children: [_jsx("span", { className: "size-1.5 rounded-full bg-current", "aria-hidden": true }), current?.label ?? '—', _jsx(ChevronDown, { className: "size-3.5 opacity-70", "aria-hidden": true })] }) }), _jsxs(PopoverContent, { className: "w-64", children: [_jsx("p", { className: "mb-2 px-2 text-xs font-medium text-muted-foreground", children: field.label }), _jsx("ol", { className: "grid gap-0.5", children: options.map((o, i) => {
|
|
19
|
+
const isCurrent = i === index;
|
|
20
|
+
return (_jsxs("li", { "aria-current": isCurrent ? 'step' : undefined, className: cn('flex items-center gap-2 rounded-md px-2 py-1.5 text-sm', isCurrent ? 'bg-accent font-medium text-accent-foreground' : 'text-foreground', i > index && !next.has(o.value) && 'text-muted-foreground'), children: [i < index ? (_jsx(Check, { className: "size-3.5 text-success", "aria-hidden": true })) : (_jsx(Circle, { className: cn('size-3.5', isCurrent && 'fill-current'), "aria-hidden": true })), _jsx("span", { className: "flex-1", children: o.label }), next.has(o.value) ? (_jsx("span", { className: "text-xs text-muted-foreground", children: t('views.nextState') })) : null] }, o.value));
|
|
21
|
+
}) })] })] }));
|
|
22
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Context } from 'react';
|
|
2
2
|
import type { Registry } from '../registry.js';
|
|
3
|
+
type Tone = 'info' | 'success' | 'danger' | 'warning' | 'neutral';
|
|
4
|
+
export declare function toneOf(value: string): Tone;
|
|
3
5
|
/** Display names a list or kanban loaded in one batch per model, so cells do not query one by one. */
|
|
4
6
|
export declare const PrefetchedNames: Context<ReadonlyMap<string, ReadonlyMap<number, string>>>;
|
|
5
7
|
/** Display names of ids: from {@link PrefetchedNames}, else batched through the name-search endpoint (cached). */
|
|
@@ -12,3 +14,4 @@ export declare function useRowNames(rows: readonly Readonly<Record<string, unkno
|
|
|
12
14
|
}[]): ReadonlyMap<string, ReadonlyMap<number, string>>;
|
|
13
15
|
/** Registers the default widgets. */
|
|
14
16
|
export declare function registerDefaultWidgets(registry: Registry): Registry;
|
|
17
|
+
export {};
|