@protobase/ui 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/dist/activity-chart/activity-chart.js +125 -0
- package/dist/activity-chart/activity-math.js +11 -0
- package/dist/activity-chart/activity-tooltip.js +24 -0
- package/dist/app/action-icon.js +10 -0
- package/dist/app/app.d.ts +24 -0
- package/dist/app/app.js +123 -0
- package/dist/app/batch-failure.js +21 -0
- package/dist/app/breadcrumb-for.js +27 -0
- package/dist/app/create/create-page.js +118 -0
- package/dist/app/create/create-record-form.js +68 -0
- package/dist/app/create/create-values.js +52 -0
- package/dist/app/delete/dialogs.js +109 -0
- package/dist/app/delete/record-diff.js +17 -0
- package/dist/app/delete/use-delete-flow.js +193 -0
- package/dist/app/delete/use-restore.js +9 -0
- package/dist/app/draft-storage.js +18 -0
- package/dist/app/error-banner.js +28 -0
- package/dist/app/field-editor.js +88 -0
- package/dist/app/icons.js +27 -0
- package/dist/app/index.d.ts +2 -0
- package/dist/app/index.html +12 -0
- package/dist/app/list-chart.js +36 -0
- package/dist/app/list-order.js +15 -0
- package/dist/app/list-page.js +213 -0
- package/dist/app/list-search.js +30 -0
- package/dist/app/main.d.ts +1 -0
- package/dist/app/main.js +14 -0
- package/dist/app/menu-icons.js +21 -0
- package/dist/app/meta-gate.js +46 -0
- package/dist/app/nav-from-meta.js +65 -0
- package/dist/app/nav-recent.js +33 -0
- package/dist/app/pages/action-href.js +7 -0
- package/dist/app/pages/blocks/action.js +38 -0
- package/dist/app/pages/blocks/block-props.js +5 -0
- package/dist/app/pages/blocks/card-frame.js +34 -0
- package/dist/app/pages/blocks/card-row.js +55 -0
- package/dist/app/pages/blocks/card.js +19 -0
- package/dist/app/pages/blocks/field.js +45 -0
- package/dist/app/pages/blocks/grid.js +34 -0
- package/dist/app/pages/blocks/link.js +27 -0
- package/dist/app/pages/blocks/modal-form.js +149 -0
- package/dist/app/pages/blocks/page-block.js +20 -0
- package/dist/app/pages/blocks/progress.js +54 -0
- package/dist/app/pages/blocks/record-card.js +56 -0
- package/dist/app/pages/blocks/show.js +43 -0
- package/dist/app/pages/blocks/stat.js +78 -0
- package/dist/app/pages/blocks/table.js +65 -0
- package/dist/app/pages/composed-page.js +19 -0
- package/dist/app/pages/confirm-action.js +20 -0
- package/dist/app/pages/field-value.js +58 -0
- package/dist/app/pages/project-ui.d.ts +30 -0
- package/dist/app/pages/project-ui.js +13 -0
- package/dist/app/pages/record-actions.js +40 -0
- package/dist/app/pages/record-scope.d.ts +16 -0
- package/dist/app/pages/record-scope.js +12 -0
- package/dist/app/pages/record-table.js +110 -0
- package/dist/app/pages/render-node.js +84 -0
- package/dist/app/pages/use-layout-data.js +73 -0
- package/dist/app/pages/use-path-value.js +65 -0
- package/dist/app/pages/use-relation-labels.js +30 -0
- package/dist/app/pages/use-run-action.js +88 -0
- package/dist/app/permissions.js +15 -0
- package/dist/app/record-lines.js +39 -0
- package/dist/app/record-page.js +318 -0
- package/dist/app/related-section.js +126 -0
- package/dist/app/relation-picker.js +69 -0
- package/dist/app/router.js +155 -0
- package/dist/app/search/search-model.js +70 -0
- package/dist/app/search/use-global-search.js +68 -0
- package/dist/app/sensitive-field.js +65 -0
- package/dist/app/use-lines-draft.js +45 -0
- package/dist/app/use-lines.js +19 -0
- package/dist/app/use-live-save.js +54 -0
- package/dist/app/use-nav-recent.js +41 -0
- package/dist/app/use-record-editor.js +154 -0
- package/dist/app/use-related-records.js +31 -0
- package/dist/app/user-menu-from-meta.js +36 -0
- package/dist/app-shell/app-shell.d.ts +4 -0
- package/dist/app-shell/app-shell.js +57 -0
- package/dist/app-shell/breadcrumb.js +37 -0
- package/dist/app-shell/drawer.js +30 -0
- package/dist/app-shell/global-search.d.ts +1 -0
- package/dist/app-shell/global-search.js +145 -0
- package/dist/app-shell/index.d.ts +9 -0
- package/dist/app-shell/logo.js +46 -0
- package/dist/app-shell/nav-open-storage.js +10 -0
- package/dist/app-shell/nav.d.ts +2 -0
- package/dist/app-shell/nav.js +85 -0
- package/dist/app-shell/page-header.d.ts +1 -0
- package/dist/app-shell/page-header.js +17 -0
- package/dist/app-shell/profile-menu.d.ts +2 -0
- package/dist/app-shell/profile-menu.js +115 -0
- package/dist/app-shell/sidebar-group.js +94 -0
- package/dist/app-shell/sidebar-item.js +46 -0
- package/dist/app-shell/sidebar-mode.d.ts +4 -0
- package/dist/app-shell/sidebar-mode.js +13 -0
- package/dist/app-shell/sidebar.d.ts +2 -0
- package/dist/app-shell/sidebar.js +64 -0
- package/dist/app-shell/status-dot.d.ts +1 -0
- package/dist/app-shell/status-dot.js +20 -0
- package/dist/app-shell/top-bar.js +82 -0
- package/dist/assistant-dock/assistant-dock.d.ts +15 -0
- package/dist/assistant-dock/assistant-dock.js +81 -0
- package/dist/assistant-dock/chat-message.js +9 -0
- package/dist/assistant-dock/composer.js +39 -0
- package/dist/assistant-dock/dock-item.d.ts +4 -0
- package/dist/assistant-dock/dock-item.js +37 -0
- package/dist/assistant-dock/model.d.ts +84 -0
- package/dist/assistant-dock/model.js +26 -0
- package/dist/assistant-dock/plan-card.d.ts +13 -0
- package/dist/assistant-dock/plan-card.js +133 -0
- package/dist/assistant-dock/progress-card.js +18 -0
- package/dist/assistant-dock/query-card.js +52 -0
- package/dist/assistant-dock/queue-card.js +31 -0
- package/dist/assistant-dock/result-card.js +77 -0
- package/dist/assistant-panel/content.d.ts +7 -0
- package/dist/assistant-panel/step-list.js +30 -0
- package/dist/auth/auth-gate.js +70 -0
- package/dist/auth/auth-layout.js +29 -0
- package/dist/auth/auth-provider.js +71 -0
- package/dist/auth/first-run-page.js +51 -0
- package/dist/auth/forgot-password-form.js +75 -0
- package/dist/auth/forgot-password-page.js +35 -0
- package/dist/auth/reset-link.js +29 -0
- package/dist/auth/reset-password-form.js +88 -0
- package/dist/auth/reset-password-page.js +43 -0
- package/dist/auth/sign-in-form.js +90 -0
- package/dist/auth/sign-in-page.js +39 -0
- package/dist/auth/user-to-shell.js +18 -0
- package/dist/data/api-provider.d.ts +54 -0
- package/dist/data/api-provider.js +53 -0
- package/dist/data/meta-data.d.ts +14 -0
- package/dist/data/meta-data.js +9 -0
- package/dist/data/query-keys.js +95 -0
- package/dist/data/use-aggregates.d.ts +8 -0
- package/dist/data/use-aggregates.js +43 -0
- package/dist/data/use-list.d.ts +231 -0
- package/dist/data/use-list.js +31 -0
- package/dist/data/use-meta.d.ts +8 -0
- package/dist/data/use-meta.js +35 -0
- package/dist/data/use-record.d.ts +6 -0
- package/dist/data/use-record.js +16 -0
- package/dist/data/use-update-record.d.ts +15 -0
- package/dist/data/use-update-record.js +26 -0
- package/dist/data-grid/cells.js +85 -0
- package/dist/data-grid/column-spec.js +4 -0
- package/dist/data-grid/columns.js +16 -0
- package/dist/data-grid/data-grid.js +238 -0
- package/dist/data-grid/geometry.js +32 -0
- package/dist/data-grid/grid-footer.js +33 -0
- package/dist/data-grid/grid-header.js +69 -0
- package/dist/data-grid/grid-row.js +83 -0
- package/dist/data-grid/grid-template.js +12 -0
- package/dist/data-grid/header-menu.js +52 -0
- package/dist/data-grid/page-cache.js +27 -0
- package/dist/data-grid/pinned-storage.js +13 -0
- package/dist/data-grid/row-menu.js +88 -0
- package/dist/data-grid/use-grid-scroll.js +80 -0
- package/dist/data-grid/use-horizontal-scroll.js +38 -0
- package/dist/data-grid/use-paged-rows.js +50 -0
- package/dist/data-grid/use-pinned-columns.js +16 -0
- package/dist/filter-panel/active-filter-chips.js +34 -0
- package/dist/filter-panel/date-presets.js +20 -0
- package/dist/filter-panel/facet-group.js +48 -0
- package/dist/filter-panel/filter-config.js +18 -0
- package/dist/filter-panel/filter-menu.js +41 -0
- package/dist/filter-panel/filter-panel.js +112 -0
- package/dist/filter-panel/filter-section.js +11 -0
- package/dist/filter-panel/filter-state.js +63 -0
- package/dist/filter-panel/range-filter.js +54 -0
- package/dist/filter-panel/range-math.js +11 -0
- package/dist/filter-panel/range-slider.css +32 -0
- package/dist/filter-panel/toggle-row.js +13 -0
- package/dist/filter-panel/use-filter-state.js +15 -0
- package/dist/format/date.js +37 -0
- package/dist/format/money.js +15 -0
- package/dist/format/number.js +12 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +11 -0
- package/dist/inline-child-table/columns.js +4 -0
- package/dist/inline-child-table/inline-child-table.js +120 -0
- package/dist/inline-child-table/line-menu.js +54 -0
- package/dist/inline-child-table/line-row.js +91 -0
- package/dist/inline-child-table/number-cell.js +25 -0
- package/dist/inline-child-table/reorder.js +10 -0
- package/dist/inline-child-table/totals-footer.js +42 -0
- package/dist/lib/cn.js +4 -0
- package/dist/lib/guarded.js +12 -0
- package/dist/lib/use-debounced.js +13 -0
- package/dist/lib/use-focus-trap.js +46 -0
- package/dist/lib/use-media-query.js +12 -0
- package/dist/live/columns-from-model.js +93 -0
- package/dist/live/display-value.js +24 -0
- package/dist/live/field-values.js +32 -0
- package/dist/live/filter-string.js +135 -0
- package/dist/live/grid-loader.js +63 -0
- package/dist/live/model-helpers.js +30 -0
- package/dist/live/naming.js +9 -0
- package/dist/live/relation-labels.js +53 -0
- package/dist/live/use-filter-config.js +88 -0
- package/dist/live/use-live-rows.js +45 -0
- package/dist/live/view-fields.js +35 -0
- package/dist/mocks/activity.js +24 -0
- package/dist/mocks/invoices.js +13 -0
- package/dist/mocks/products.js +167 -0
- package/dist/mocks/rng.js +20 -0
- package/dist/mocks/users.js +63 -0
- package/dist/primitives/avatar.js +22 -0
- package/dist/primitives/badge.d.ts +1 -0
- package/dist/primitives/badge.js +25 -0
- package/dist/primitives/button.js +23 -0
- package/dist/primitives/checkbox.js +21 -0
- package/dist/primitives/dialog.js +41 -0
- package/dist/primitives/input.js +16 -0
- package/dist/primitives/kbd.js +8 -0
- package/dist/primitives/segmented.js +14 -0
- package/dist/primitives/sheet.js +44 -0
- package/dist/primitives/skeleton.js +6 -0
- package/dist/primitives/spinner.js +10 -0
- package/dist/primitives/switch.js +14 -0
- package/dist/primitives/tooltip.js +13 -0
- package/dist/record-view/action-rail.js +28 -0
- package/dist/record-view/field.js +62 -0
- package/dist/record-view/record-header.js +50 -0
- package/dist/record-view/record-sidebar.js +58 -0
- package/dist/record-view/section.js +21 -0
- package/dist/styles.css +132 -0
- package/dist/toasts/toast-provider.js +69 -0
- package/dist/toasts/toast.js +45 -0
- package/package.json +45 -3
- package/README.md +0 -3
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/format/date.ts
|
|
2
|
+
const day = new Intl.DateTimeFormat("en-GB", {
|
|
3
|
+
day: "numeric",
|
|
4
|
+
month: "short",
|
|
5
|
+
year: "numeric",
|
|
6
|
+
timeZone: "UTC"
|
|
7
|
+
});
|
|
8
|
+
const dayShort = new Intl.DateTimeFormat("en-GB", {
|
|
9
|
+
day: "numeric",
|
|
10
|
+
month: "short",
|
|
11
|
+
timeZone: "UTC"
|
|
12
|
+
});
|
|
13
|
+
const time = new Intl.DateTimeFormat("en-GB", {
|
|
14
|
+
hour: "2-digit",
|
|
15
|
+
minute: "2-digit",
|
|
16
|
+
hour12: false,
|
|
17
|
+
timeZone: "UTC"
|
|
18
|
+
});
|
|
19
|
+
const formatDate = (ms) => day.format(ms);
|
|
20
|
+
const formatDayShort = (ms) => dayShort.format(ms);
|
|
21
|
+
const formatDateTime = (ms) => `${day.format(ms)}, ${time.format(ms)}`;
|
|
22
|
+
const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
23
|
+
const units = [
|
|
24
|
+
["year", 31536e3],
|
|
25
|
+
["month", 2592e3],
|
|
26
|
+
["day", 86400],
|
|
27
|
+
["hour", 3600],
|
|
28
|
+
["minute", 60]
|
|
29
|
+
];
|
|
30
|
+
/** "5 minutes ago", "yesterday", "3 years ago"; `seconds` is how long ago it was. */
|
|
31
|
+
const formatRelativeTime = (seconds) => {
|
|
32
|
+
const [unit, size] = units.find(([, size]) => seconds >= size) ?? ["second", 1];
|
|
33
|
+
if (unit === "second" && seconds < 45) return "just now";
|
|
34
|
+
return relative.format(-Math.floor(seconds / size), unit);
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { formatDate, formatDateTime, formatDayShort, formatRelativeTime };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/format/money.ts
|
|
2
|
+
const formatters = /* @__PURE__ */ new Map();
|
|
3
|
+
const formatterFor = (currency) => {
|
|
4
|
+
const cached = formatters.get(currency);
|
|
5
|
+
if (cached) return cached;
|
|
6
|
+
const created = new Intl.NumberFormat("en-IE", {
|
|
7
|
+
style: "currency",
|
|
8
|
+
currency
|
|
9
|
+
});
|
|
10
|
+
formatters.set(currency, created);
|
|
11
|
+
return created;
|
|
12
|
+
};
|
|
13
|
+
const formatMoney = (cents, currency = "EUR") => formatterFor(currency).format(cents / 100);
|
|
14
|
+
//#endregion
|
|
15
|
+
export { formatMoney };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/format/number.ts
|
|
2
|
+
const grouped = new Intl.NumberFormat("en-IE");
|
|
3
|
+
const formatInt = (value) => grouped.format(value);
|
|
4
|
+
const formatSigned = (value) => value > 0 ? `+${grouped.format(value)}` : grouped.format(value).replace("-", "−");
|
|
5
|
+
const formatPercent = (value) => `${value}%`;
|
|
6
|
+
const formatApproxRows = (count) => {
|
|
7
|
+
if (count < 1e4) return `${formatInt(count)} rows`;
|
|
8
|
+
const magnitude = 10 ** (Math.floor(Math.log10(count)) - 3);
|
|
9
|
+
return `about ${formatInt(Math.round(count / magnitude) * magnitude)} rows`;
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { formatApproxRows, formatInt, formatPercent, formatSigned };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ActionContext, ActionHandler, ProjectUi, defineUi } from "./app/pages/project-ui.js";
|
|
2
|
+
import { App, AppProps } from "./app/app.js";
|
|
3
|
+
import "./app/index.js";
|
|
4
|
+
import { ApiProvider, ApiProviderProps, useClient } from "./data/api-provider.js";
|
|
5
|
+
import { MetaData } from "./data/meta-data.js";
|
|
6
|
+
import { useMeta } from "./data/use-meta.js";
|
|
7
|
+
import { useList } from "./data/use-list.js";
|
|
8
|
+
import { useRecord } from "./data/use-record.js";
|
|
9
|
+
import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
|
|
10
|
+
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
11
|
+
import { LayoutRecord, useLayoutRecord } from "./app/pages/record-scope.js";
|
|
12
|
+
import { DockItem, JobResult, Plan, PlanSize, QueryResult } from "./assistant-dock/model.js";
|
|
13
|
+
import { AssistantDock, AssistantDockProps } from "./assistant-dock/assistant-dock.js";
|
|
14
|
+
export { type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, type AppProps, AssistantDock, type AssistantDockProps, type DockItem, type JobResult, type LayoutRecord, type MetaData, type Plan, type PlanSize, type ProjectUi, type QueryResult, defineUi, isConflict, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ApiProvider, useClient } from "./data/api-provider.js";
|
|
2
|
+
import { useRecord } from "./data/use-record.js";
|
|
3
|
+
import { useMeta } from "./data/use-meta.js";
|
|
4
|
+
import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
|
|
5
|
+
import { useLayoutRecord } from "./app/pages/record-scope.js";
|
|
6
|
+
import { defineUi } from "./app/pages/project-ui.js";
|
|
7
|
+
import { useList } from "./data/use-list.js";
|
|
8
|
+
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
9
|
+
import { App } from "./app/app.js";
|
|
10
|
+
import { AssistantDock } from "./assistant-dock/assistant-dock.js";
|
|
11
|
+
export { ApiProvider, App, AssistantDock, defineUi, isConflict, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Button } from "../primitives/button.js";
|
|
2
|
+
import { productAt } from "../mocks/products.js";
|
|
3
|
+
import { lineGrid } from "./columns.js";
|
|
4
|
+
import { LineRow } from "./line-row.js";
|
|
5
|
+
import { reorder } from "./reorder.js";
|
|
6
|
+
import { TotalsFooter } from "./totals-footer.js";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { Plus } from "lucide-react";
|
|
9
|
+
import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
|
|
10
|
+
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
11
|
+
//#region src/inline-child-table/inline-child-table.tsx
|
|
12
|
+
const headerCell = "px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground";
|
|
13
|
+
const instructions = "To pick up a line, press space or enter on its drag handle. Use the up and down arrow keys to move it, space or enter to drop it, and escape to cancel. The row menu also has Move up and Move down.";
|
|
14
|
+
const announcementsFor = (lines) => {
|
|
15
|
+
const name = (id) => lines.find((line) => line.id === id)?.description ?? "line";
|
|
16
|
+
const place = (id) => lines.findIndex((line) => line.id === id) + 1;
|
|
17
|
+
return {
|
|
18
|
+
onDragStart: ({ active }) => `Picked up ${name(active.id)}, line ${place(active.id)} of ${lines.length}.`,
|
|
19
|
+
onDragOver: ({ active, over }) => over ? `${name(active.id)} is over line ${place(over.id)} of ${lines.length}.` : void 0,
|
|
20
|
+
onDragEnd: ({ active, over }) => over ? `${name(active.id)} was dropped at line ${place(over.id)} of ${lines.length}.` : `${name(active.id)} was dropped where it started.`,
|
|
21
|
+
onDragCancel: ({ active }) => `Moving ${name(active.id)} was cancelled. It is back at line ${place(active.id)}.`
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
const InlineChildTable = ({ lines, onChange, onReorder, vatRate = .21, readOnly, reorderable = !readOnly }) => {
|
|
25
|
+
const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), useSensor(TouchSensor, { activationConstraint: {
|
|
26
|
+
delay: 250,
|
|
27
|
+
tolerance: 5
|
|
28
|
+
} }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
|
29
|
+
const move = (from, to) => {
|
|
30
|
+
const line = lines[from];
|
|
31
|
+
if (!line || to < 0 || to >= lines.length || from === to) return;
|
|
32
|
+
(onReorder ?? ((next) => onChange(next)))(reorder(lines, from, to), {
|
|
33
|
+
id: line.id,
|
|
34
|
+
from,
|
|
35
|
+
to
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const onDragEnd = ({ active, over }) => {
|
|
39
|
+
if (!over || active.id === over.id) return;
|
|
40
|
+
move(lines.findIndex((line) => line.id === active.id), lines.findIndex((line) => line.id === over.id));
|
|
41
|
+
};
|
|
42
|
+
const addLine = () => {
|
|
43
|
+
const product = productAt(lines.length * 3 + 1);
|
|
44
|
+
onChange([...lines, {
|
|
45
|
+
id: `new-${lines.length}-${product.id}`,
|
|
46
|
+
productId: product.id,
|
|
47
|
+
description: product.name,
|
|
48
|
+
quantity: 1,
|
|
49
|
+
unitPriceCents: product.priceCents
|
|
50
|
+
}]);
|
|
51
|
+
};
|
|
52
|
+
return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("div", {
|
|
53
|
+
className: "overflow-hidden rounded-lg border border-border bg-background",
|
|
54
|
+
children: [
|
|
55
|
+
/* @__PURE__ */ jsxs("div", {
|
|
56
|
+
className: `${lineGrid} border-b border-border bg-surface px-2 py-2`,
|
|
57
|
+
children: [
|
|
58
|
+
/* @__PURE__ */ jsx("span", {}),
|
|
59
|
+
/* @__PURE__ */ jsx("span", {
|
|
60
|
+
className: headerCell,
|
|
61
|
+
children: "Item"
|
|
62
|
+
}),
|
|
63
|
+
/* @__PURE__ */ jsx("span", {
|
|
64
|
+
className: `${headerCell} text-right`,
|
|
65
|
+
children: "Qty"
|
|
66
|
+
}),
|
|
67
|
+
/* @__PURE__ */ jsx("span", {
|
|
68
|
+
className: `${headerCell} text-right`,
|
|
69
|
+
children: "Unit price"
|
|
70
|
+
}),
|
|
71
|
+
/* @__PURE__ */ jsx("span", {
|
|
72
|
+
className: `${headerCell} text-right`,
|
|
73
|
+
children: "Line total"
|
|
74
|
+
}),
|
|
75
|
+
/* @__PURE__ */ jsx("span", {})
|
|
76
|
+
]
|
|
77
|
+
}),
|
|
78
|
+
/* @__PURE__ */ jsx(DndContext, {
|
|
79
|
+
sensors,
|
|
80
|
+
collisionDetection: closestCenter,
|
|
81
|
+
onDragEnd,
|
|
82
|
+
accessibility: {
|
|
83
|
+
announcements: announcementsFor(lines),
|
|
84
|
+
screenReaderInstructions: { draggable: instructions }
|
|
85
|
+
},
|
|
86
|
+
children: /* @__PURE__ */ jsx(SortableContext, {
|
|
87
|
+
items: lines.map((line) => line.id),
|
|
88
|
+
strategy: verticalListSortingStrategy,
|
|
89
|
+
children: lines.map((line, index) => /* @__PURE__ */ jsx(LineRow, {
|
|
90
|
+
line,
|
|
91
|
+
position: index,
|
|
92
|
+
count: lines.length,
|
|
93
|
+
reorderable,
|
|
94
|
+
editable: !readOnly,
|
|
95
|
+
onChange: (patch) => onChange(lines.map((item) => item.id === line.id ? {
|
|
96
|
+
...item,
|
|
97
|
+
...patch
|
|
98
|
+
} : item)),
|
|
99
|
+
onRemove: () => onChange(lines.filter((item) => item.id !== line.id)),
|
|
100
|
+
onMove: (to) => move(index, to)
|
|
101
|
+
}, line.id))
|
|
102
|
+
})
|
|
103
|
+
}),
|
|
104
|
+
!readOnly && /* @__PURE__ */ jsx("div", {
|
|
105
|
+
className: "border-t border-border bg-surface px-2 py-1.5",
|
|
106
|
+
children: /* @__PURE__ */ jsxs(Button, {
|
|
107
|
+
variant: "ghost",
|
|
108
|
+
size: "sm",
|
|
109
|
+
onClick: addLine,
|
|
110
|
+
children: [/* @__PURE__ */ jsx(Plus, { className: "size-3.5" }), "Add line"]
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
]
|
|
114
|
+
}), /* @__PURE__ */ jsx(TotalsFooter, {
|
|
115
|
+
lines,
|
|
116
|
+
vatRate
|
|
117
|
+
})] });
|
|
118
|
+
};
|
|
119
|
+
//#endregion
|
|
120
|
+
export { InlineChildTable };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { MoreHorizontal } from "lucide-react";
|
|
5
|
+
//#region src/inline-child-table/line-menu.tsx
|
|
6
|
+
/** The "..." at the end of a line: the keyboard-and-screen-reader way to reorder, besides the drag handle. */
|
|
7
|
+
const LineMenu = ({ items, label }) => {
|
|
8
|
+
const [open, setOpen] = useState(false);
|
|
9
|
+
const root = useRef(null);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!open) return;
|
|
12
|
+
const close = (event) => {
|
|
13
|
+
if (event instanceof KeyboardEvent ? event.key === "Escape" : !root.current?.contains(event.target)) setOpen(false);
|
|
14
|
+
};
|
|
15
|
+
document.addEventListener("mousedown", close);
|
|
16
|
+
document.addEventListener("keydown", close);
|
|
17
|
+
return () => {
|
|
18
|
+
document.removeEventListener("mousedown", close);
|
|
19
|
+
document.removeEventListener("keydown", close);
|
|
20
|
+
};
|
|
21
|
+
}, [open]);
|
|
22
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
23
|
+
ref: root,
|
|
24
|
+
className: "relative flex justify-center",
|
|
25
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
26
|
+
type: "button",
|
|
27
|
+
"aria-label": label,
|
|
28
|
+
"aria-haspopup": "menu",
|
|
29
|
+
"aria-expanded": open,
|
|
30
|
+
onClick: () => setOpen(!open),
|
|
31
|
+
className: "inline-flex size-7 items-center justify-center rounded text-faint-foreground hover:bg-muted hover:text-foreground pointer-coarse:size-11",
|
|
32
|
+
children: /* @__PURE__ */ jsx(MoreHorizontal, { className: "size-4" })
|
|
33
|
+
}), open && /* @__PURE__ */ jsx("ul", {
|
|
34
|
+
role: "menu",
|
|
35
|
+
className: "absolute right-0 top-full z-30 mt-1 min-w-36 rounded-md border bg-background py-1 text-[13px] shadow-pop",
|
|
36
|
+
children: items.map((item) => /* @__PURE__ */ jsx("li", {
|
|
37
|
+
role: "none",
|
|
38
|
+
children: /* @__PURE__ */ jsx("button", {
|
|
39
|
+
type: "button",
|
|
40
|
+
role: "menuitem",
|
|
41
|
+
disabled: item.disabled,
|
|
42
|
+
className: cn("block min-h-9 w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-muted disabled:text-faint-foreground disabled:hover:bg-transparent", item.destructive && "text-danger-text"),
|
|
43
|
+
onClick: () => {
|
|
44
|
+
setOpen(false);
|
|
45
|
+
item.onSelect();
|
|
46
|
+
},
|
|
47
|
+
children: item.label
|
|
48
|
+
})
|
|
49
|
+
}, item.label))
|
|
50
|
+
})]
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { LineMenu };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { lineTotalCents } from "../mocks/invoices.js";
|
|
3
|
+
import { lineGrid } from "./columns.js";
|
|
4
|
+
import { formatMoney } from "../format/money.js";
|
|
5
|
+
import { LineMenu } from "./line-menu.js";
|
|
6
|
+
import { NumberCell } from "./number-cell.js";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { GripVertical } from "lucide-react";
|
|
9
|
+
import { useSortable } from "@dnd-kit/sortable";
|
|
10
|
+
//#region src/inline-child-table/line-row.tsx
|
|
11
|
+
const parseEuros = (text) => Math.round(Number(text.replace(",", ".").replace(/[^0-9.]/g, "")) * 100);
|
|
12
|
+
const LineRow = ({ line, position, count, reorderable, editable, onChange, onRemove, onMove }) => {
|
|
13
|
+
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
|
|
14
|
+
id: line.id,
|
|
15
|
+
disabled: !reorderable
|
|
16
|
+
});
|
|
17
|
+
const menu = [...reorderable ? [{
|
|
18
|
+
label: "Move up",
|
|
19
|
+
disabled: position === 0,
|
|
20
|
+
onSelect: () => onMove(position - 1)
|
|
21
|
+
}, {
|
|
22
|
+
label: "Move down",
|
|
23
|
+
disabled: position === count - 1,
|
|
24
|
+
onSelect: () => onMove(position + 1)
|
|
25
|
+
}] : [], ...editable ? [{
|
|
26
|
+
label: "Remove line",
|
|
27
|
+
destructive: true,
|
|
28
|
+
onSelect: onRemove
|
|
29
|
+
}] : []];
|
|
30
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
31
|
+
ref: setNodeRef,
|
|
32
|
+
"data-line-id": line.id,
|
|
33
|
+
style: {
|
|
34
|
+
transform: transform ? `translate3d(0, ${transform.y}px, 0)` : void 0,
|
|
35
|
+
transition
|
|
36
|
+
},
|
|
37
|
+
className: cn("group relative border-b border-border bg-background px-2 py-1 last:border-b-0 hover:bg-surface", lineGrid, isDragging && "z-10 bg-surface opacity-80 shadow-pop"),
|
|
38
|
+
children: [
|
|
39
|
+
reorderable ? /* @__PURE__ */ jsx("button", {
|
|
40
|
+
ref: setActivatorNodeRef,
|
|
41
|
+
type: "button",
|
|
42
|
+
"aria-label": `Drag to reorder ${line.description}`,
|
|
43
|
+
...attributes,
|
|
44
|
+
...listeners,
|
|
45
|
+
style: { touchAction: "none" },
|
|
46
|
+
className: "flex size-7 cursor-grab items-center justify-center rounded text-faint-foreground opacity-60 hover:bg-muted hover:opacity-100 focus-visible:opacity-100 active:cursor-grabbing pointer-coarse:size-11",
|
|
47
|
+
children: /* @__PURE__ */ jsx(GripVertical, { className: "size-4" })
|
|
48
|
+
}) : /* @__PURE__ */ jsx("span", {}),
|
|
49
|
+
/* @__PURE__ */ jsxs("div", {
|
|
50
|
+
className: "min-w-0",
|
|
51
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
52
|
+
className: "truncate text-[13px] text-foreground",
|
|
53
|
+
children: line.description
|
|
54
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
55
|
+
className: "font-mono text-[11px] text-faint-foreground",
|
|
56
|
+
children: line.productId
|
|
57
|
+
})]
|
|
58
|
+
}),
|
|
59
|
+
editable ? /* @__PURE__ */ jsx(NumberCell, {
|
|
60
|
+
label: "Quantity",
|
|
61
|
+
value: line.quantity,
|
|
62
|
+
format: String,
|
|
63
|
+
parse: (text) => Math.round(Number(text)),
|
|
64
|
+
onCommit: (quantity) => onChange({ quantity })
|
|
65
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
66
|
+
className: "px-2 text-right text-[13px] tabular-nums",
|
|
67
|
+
children: line.quantity
|
|
68
|
+
}),
|
|
69
|
+
editable ? /* @__PURE__ */ jsx(NumberCell, {
|
|
70
|
+
label: "Unit price",
|
|
71
|
+
value: line.unitPriceCents,
|
|
72
|
+
format: (cents) => (cents / 100).toFixed(2),
|
|
73
|
+
parse: parseEuros,
|
|
74
|
+
onCommit: (unitPriceCents) => onChange({ unitPriceCents })
|
|
75
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
76
|
+
className: "px-2 text-right text-[13px] tabular-nums",
|
|
77
|
+
children: (line.unitPriceCents / 100).toFixed(2)
|
|
78
|
+
}),
|
|
79
|
+
/* @__PURE__ */ jsx("span", {
|
|
80
|
+
className: "px-2 text-right text-[13px] font-medium tabular-nums text-foreground",
|
|
81
|
+
children: formatMoney(lineTotalCents(line))
|
|
82
|
+
}),
|
|
83
|
+
menu.length > 0 ? /* @__PURE__ */ jsx(LineMenu, {
|
|
84
|
+
label: `Actions for ${line.description}`,
|
|
85
|
+
items: menu
|
|
86
|
+
}) : /* @__PURE__ */ jsx("span", {})
|
|
87
|
+
]
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { LineRow };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/inline-child-table/number-cell.tsx
|
|
5
|
+
const NumberCell = ({ value, onCommit, format, parse, label, width = "w-full" }) => {
|
|
6
|
+
const [draft, setDraft] = useState(null);
|
|
7
|
+
const commit = () => {
|
|
8
|
+
if (draft === null) return;
|
|
9
|
+
const parsed = parse(draft);
|
|
10
|
+
setDraft(null);
|
|
11
|
+
if (Number.isFinite(parsed) && parsed >= 0) onCommit(parsed);
|
|
12
|
+
};
|
|
13
|
+
return /* @__PURE__ */ jsx("input", {
|
|
14
|
+
"aria-label": label,
|
|
15
|
+
inputMode: "decimal",
|
|
16
|
+
value: draft ?? format(value),
|
|
17
|
+
onFocus: (event) => event.currentTarget.select(),
|
|
18
|
+
onChange: (event) => setDraft(event.target.value),
|
|
19
|
+
onBlur: commit,
|
|
20
|
+
onKeyDown: (event) => event.key === "Enter" && event.currentTarget.blur(),
|
|
21
|
+
className: cn("h-7 rounded-md border border-transparent bg-transparent px-2 text-right text-[13px] tabular-nums text-foreground outline-none hover:border-border-strong focus:border-primary focus:bg-background focus:ring-2 focus:ring-primary/20", width)
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { NumberCell };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/inline-child-table/reorder.ts
|
|
2
|
+
const reorder = (items, from, to) => {
|
|
3
|
+
if (from === to || from < 0 || from >= items.length) return [...items];
|
|
4
|
+
const next = [...items];
|
|
5
|
+
const [moved] = next.splice(from, 1);
|
|
6
|
+
next.splice(Math.min(Math.max(to, 0), next.length), 0, moved);
|
|
7
|
+
return next;
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
export { reorder };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { invoiceTotals } from "../mocks/invoices.js";
|
|
2
|
+
import { formatMoney } from "../format/money.js";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/inline-child-table/totals-footer.tsx
|
|
5
|
+
const TotalsFooter = ({ lines, vatRate }) => {
|
|
6
|
+
const totals = invoiceTotals(lines, vatRate);
|
|
7
|
+
return /* @__PURE__ */ jsxs("dl", {
|
|
8
|
+
className: "ml-auto grid w-64 grid-cols-[1fr_auto] gap-x-6 gap-y-1 px-2 pt-3 text-[13px] tabular-nums",
|
|
9
|
+
children: [
|
|
10
|
+
/* @__PURE__ */ jsx("dt", {
|
|
11
|
+
className: "text-muted-foreground",
|
|
12
|
+
children: "Subtotal"
|
|
13
|
+
}),
|
|
14
|
+
/* @__PURE__ */ jsx("dd", {
|
|
15
|
+
className: "text-right text-foreground",
|
|
16
|
+
children: formatMoney(totals.subtotalCents)
|
|
17
|
+
}),
|
|
18
|
+
/* @__PURE__ */ jsxs("dt", {
|
|
19
|
+
className: "text-muted-foreground",
|
|
20
|
+
children: [
|
|
21
|
+
"VAT ",
|
|
22
|
+
Math.round(vatRate * 100),
|
|
23
|
+
"%"
|
|
24
|
+
]
|
|
25
|
+
}),
|
|
26
|
+
/* @__PURE__ */ jsx("dd", {
|
|
27
|
+
className: "text-right text-foreground",
|
|
28
|
+
children: formatMoney(totals.vatCents)
|
|
29
|
+
}),
|
|
30
|
+
/* @__PURE__ */ jsx("dt", {
|
|
31
|
+
className: "mt-1 border-t border-border pt-2 font-semibold text-foreground",
|
|
32
|
+
children: "Total"
|
|
33
|
+
}),
|
|
34
|
+
/* @__PURE__ */ jsx("dd", {
|
|
35
|
+
className: "mt-1 border-t border-border pt-2 text-right text-[15px] font-semibold text-foreground",
|
|
36
|
+
children: formatMoney(totals.totalCents)
|
|
37
|
+
})
|
|
38
|
+
]
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
export { TotalsFooter };
|
package/dist/lib/cn.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/lib/guarded.ts
|
|
2
|
+
/** Runs a storage access; storage can be unavailable (private windows, blocked site data), which is a DOMException. Anything else is a bug. */
|
|
3
|
+
const guarded = (action, fallback) => {
|
|
4
|
+
try {
|
|
5
|
+
return action();
|
|
6
|
+
} catch (error) {
|
|
7
|
+
if (!(error instanceof DOMException)) throw error;
|
|
8
|
+
return fallback;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { guarded };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
//#region src/lib/use-debounced.ts
|
|
3
|
+
/** `value`, once it has stayed the same for `ms`. */
|
|
4
|
+
const useDebounced = (value, ms) => {
|
|
5
|
+
const [settled, setSettled] = useState(value);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const timer = setTimeout(() => setSettled(value), ms);
|
|
8
|
+
return () => clearTimeout(timer);
|
|
9
|
+
}, [value, ms]);
|
|
10
|
+
return settled;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { useDebounced };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
//#region src/lib/use-focus-trap.ts
|
|
3
|
+
const focusable = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
|
|
4
|
+
/**
|
|
5
|
+
* While `active`: moves focus into `container`, keeps Tab inside it, locks body scrolling,
|
|
6
|
+
* calls `onEscape` on Escape, and returns focus to the previously focused element afterwards.
|
|
7
|
+
*/
|
|
8
|
+
const useFocusTrap = (container, active, onEscape) => {
|
|
9
|
+
const escape = useRef(onEscape);
|
|
10
|
+
escape.current = onEscape;
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!active || !container.current) return;
|
|
13
|
+
const root = container.current;
|
|
14
|
+
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
15
|
+
const overflow = document.body.style.overflow;
|
|
16
|
+
document.body.style.overflow = "hidden";
|
|
17
|
+
const items = () => [...root.querySelectorAll(focusable)];
|
|
18
|
+
(items()[0] ?? root).focus();
|
|
19
|
+
const onKey = (event) => {
|
|
20
|
+
if (event.key === "Escape") return escape.current();
|
|
21
|
+
if (event.key !== "Tab") return;
|
|
22
|
+
const list = items();
|
|
23
|
+
if (list.length === 0) return event.preventDefault();
|
|
24
|
+
const first = list[0];
|
|
25
|
+
const last = list[list.length - 1];
|
|
26
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
last.focus();
|
|
29
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
first.focus();
|
|
32
|
+
} else if (!root.contains(document.activeElement)) {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
first.focus();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
document.addEventListener("keydown", onKey);
|
|
38
|
+
return () => {
|
|
39
|
+
document.removeEventListener("keydown", onKey);
|
|
40
|
+
document.body.style.overflow = overflow;
|
|
41
|
+
previous?.focus();
|
|
42
|
+
};
|
|
43
|
+
}, [active, container]);
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
export { useFocusTrap };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
//#region src/lib/use-media-query.ts
|
|
3
|
+
/** Follows a CSS media query, for layout decisions that cannot be made with classes alone. */
|
|
4
|
+
const useMediaQuery = (query) => useSyncExternalStore((onChange) => {
|
|
5
|
+
const list = window.matchMedia(query);
|
|
6
|
+
list.addEventListener("change", onChange);
|
|
7
|
+
return () => list.removeEventListener("change", onChange);
|
|
8
|
+
}, () => window.matchMedia(query).matches, () => false);
|
|
9
|
+
/** The `md` breakpoint: where the sidebar stops being a drawer and tables stop squeezing. */
|
|
10
|
+
const useIsDesktop = () => useMediaQuery("(min-width: 768px)");
|
|
11
|
+
//#endregion
|
|
12
|
+
export { useIsDesktop, useMediaQuery };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { humanize } from "./naming.js";
|
|
2
|
+
import { enumLabel } from "@protobase/schema";
|
|
3
|
+
//#region src/live/columns-from-model.ts
|
|
4
|
+
const tones = {
|
|
5
|
+
draft: "neutral",
|
|
6
|
+
confirmed: "blue",
|
|
7
|
+
picking: "amber",
|
|
8
|
+
shipped: "violet",
|
|
9
|
+
delivered: "green",
|
|
10
|
+
cancelled: "red",
|
|
11
|
+
sent: "blue",
|
|
12
|
+
paid: "green",
|
|
13
|
+
overdue: "red",
|
|
14
|
+
lead: "blue",
|
|
15
|
+
active: "green",
|
|
16
|
+
dormant: "neutral",
|
|
17
|
+
receipt: "green",
|
|
18
|
+
issue: "blue",
|
|
19
|
+
transfer: "violet",
|
|
20
|
+
adjustment: "amber"
|
|
21
|
+
};
|
|
22
|
+
const toneFor = (value) => tones[value] ?? "neutral";
|
|
23
|
+
const widths = {
|
|
24
|
+
text: 200,
|
|
25
|
+
id: 140,
|
|
26
|
+
money: 120,
|
|
27
|
+
date: 110,
|
|
28
|
+
datetime: 170,
|
|
29
|
+
status: 150,
|
|
30
|
+
relation: 200,
|
|
31
|
+
boolean: 64,
|
|
32
|
+
number: 100,
|
|
33
|
+
signed: 110,
|
|
34
|
+
percent: 110,
|
|
35
|
+
user: 130
|
|
36
|
+
};
|
|
37
|
+
/** Room for a header at 12px plus cell padding, the sort arrow and the column menu button. */
|
|
38
|
+
const headerWidth = (header, sortable) => Math.ceil(header.length * 7.2) + 24 + (sortable ? 16 : 0) + 28;
|
|
39
|
+
/** A status badge: dot, padding and the longest label. */
|
|
40
|
+
const badgeWidth = (labels) => Math.ceil(Math.max(0, ...labels.map((label) => label.length)) * 7.2) + 56;
|
|
41
|
+
const fixedKinds = /* @__PURE__ */ new Set([
|
|
42
|
+
"datetime",
|
|
43
|
+
"date",
|
|
44
|
+
"boolean",
|
|
45
|
+
"percent"
|
|
46
|
+
]);
|
|
47
|
+
const idLike = /^(number|sku|reference|code)$/;
|
|
48
|
+
const kindOf = (field, view, index) => {
|
|
49
|
+
switch (field.type) {
|
|
50
|
+
case "relation": return "relation";
|
|
51
|
+
case "enum": return "status";
|
|
52
|
+
case "boolean": return "boolean";
|
|
53
|
+
case "timestamp": return "datetime";
|
|
54
|
+
case "date": return "date";
|
|
55
|
+
case "decimal": return view?.format === "percent" ? "percent" : view?.prefix === "€" ? "money" : "number";
|
|
56
|
+
case "integer":
|
|
57
|
+
case "bigint": return /quantity|delta/.test(field.name) ? "signed" : "number";
|
|
58
|
+
default: return idLike.test(field.name) || index === 0 && field.name === "id" ? "id" : "text";
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const currencyCents = (value) => Math.round(Number.parseFloat(String(value)) * 100);
|
|
62
|
+
/** Grid columns for a view's list: kinds follow the field types and the view's format hints. */
|
|
63
|
+
const columnsFromModel = (resource, view, highlight = []) => {
|
|
64
|
+
return (view?.list?.columns ?? Object.keys(resource.fields).slice(0, 6)).flatMap((name, index) => {
|
|
65
|
+
const field = resource.fields[name];
|
|
66
|
+
if (!field) return [];
|
|
67
|
+
const viewField = view?.fields[name];
|
|
68
|
+
const kind = kindOf(field, viewField, index);
|
|
69
|
+
const labels = Object.fromEntries((field.enumValues ?? []).map((value) => [value, enumLabel(value, viewField?.valueLabels)]));
|
|
70
|
+
return [{
|
|
71
|
+
id: name,
|
|
72
|
+
header: viewField?.label ?? humanize(name),
|
|
73
|
+
kind,
|
|
74
|
+
width: name === "name" ? 260 : widths[kind],
|
|
75
|
+
sortable: field.sortable,
|
|
76
|
+
minWidth: Math.max(kind === "id" ? 150 : 0, headerWidth(viewField?.label ?? humanize(name), field.sortable), kind === "status" ? badgeWidth(Object.values(labels)) : 0),
|
|
77
|
+
...fixedKinds.has(kind) && { fixed: true },
|
|
78
|
+
...highlight.includes(name) && { highlight: true },
|
|
79
|
+
...field.type === "enum" && {
|
|
80
|
+
tones: Object.fromEntries((field.enumValues ?? []).map((value) => [value, toneFor(value)])),
|
|
81
|
+
labels
|
|
82
|
+
},
|
|
83
|
+
...kind === "money" && {
|
|
84
|
+
currency: "EUR",
|
|
85
|
+
value: (row) => row[name] == null ? null : currencyCents(row[name])
|
|
86
|
+
},
|
|
87
|
+
...(kind === "datetime" || kind === "date") && { value: (row) => row[name] == null ? null : Date.parse(String(row[name])) },
|
|
88
|
+
...(kind === "percent" || kind === "number" && field.type === "decimal") && { value: (row) => row[name] == null ? null : Number(row[name]) }
|
|
89
|
+
}];
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
//#endregion
|
|
93
|
+
export { columnsFromModel, headerWidth, toneFor };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { formatDate, formatDateTime } from "../format/date.js";
|
|
2
|
+
import { enumLabel } from "@protobase/schema";
|
|
3
|
+
//#region src/live/display-value.ts
|
|
4
|
+
/** A read-only rendering of a stored value, following the view's prefix, decimals, format and value label hints. */
|
|
5
|
+
const displayValue = (field, value, hints = {}) => {
|
|
6
|
+
if (value === null || value === void 0 || value === "") return "—";
|
|
7
|
+
switch (field.type) {
|
|
8
|
+
case "boolean": return value ? "Yes" : "No";
|
|
9
|
+
case "enum": return enumLabel(String(value), hints.valueLabels);
|
|
10
|
+
case "timestamp": return formatDateTime(Date.parse(String(value)));
|
|
11
|
+
case "date": return formatDate(Date.parse(String(value)));
|
|
12
|
+
case "decimal": {
|
|
13
|
+
const text = hints.decimals === void 0 ? String(value) : Number(value).toFixed(hints.decimals);
|
|
14
|
+
if (hints.format === "percent") return `${Number(value)}%`;
|
|
15
|
+
return `${hints.prefix ?? ""}${Number(text).toLocaleString("en-IE", {
|
|
16
|
+
minimumFractionDigits: hints.decimals ?? 0,
|
|
17
|
+
maximumFractionDigits: hints.decimals ?? 20
|
|
18
|
+
})}`;
|
|
19
|
+
}
|
|
20
|
+
default: return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
export { displayValue };
|