@happyvertical/smrt-svelte 0.55.22 → 0.55.24
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/AGENTS.md +2 -2
- package/README.md +2 -2
- package/agents/overview-surfaces.md +108 -4
- package/agents/screens.md +171 -5
- package/dist/components/overview/index.d.ts +4 -1
- package/dist/components/overview/index.d.ts.map +1 -1
- package/dist/components/overview/index.js +3 -0
- package/dist/components/overview/record.d.ts +51 -0
- package/dist/components/overview/record.d.ts.map +1 -0
- package/dist/components/overview/record.js +146 -0
- package/dist/components/overview/server.d.ts +4 -0
- package/dist/components/overview/server.d.ts.map +1 -1
- package/dist/components/overview/server.js +4 -0
- package/dist/components/overview/types.d.ts +12 -0
- package/dist/components/overview/types.d.ts.map +1 -1
- package/dist/components/overview/widgets/LineItemsWidget.svelte +144 -0
- package/dist/components/overview/widgets/LineItemsWidget.svelte.d.ts +6 -0
- package/dist/components/overview/widgets/LineItemsWidget.svelte.d.ts.map +1 -0
- package/dist/components/overview/widgets/RecordListWidget.svelte +67 -3
- package/dist/components/overview/widgets/RecordListWidget.svelte.d.ts.map +1 -1
- package/dist/components/overview/widgets/RecordTable.svelte +167 -0
- package/dist/components/overview/widgets/RecordTable.svelte.d.ts +21 -0
- package/dist/components/overview/widgets/RecordTable.svelte.d.ts.map +1 -0
- package/dist/components/overview/widgets/RelatedWidget.svelte +117 -0
- package/dist/components/overview/widgets/RelatedWidget.svelte.d.ts +6 -0
- package/dist/components/overview/widgets/RelatedWidget.svelte.d.ts.map +1 -0
- package/dist/components/overview/widgets/core.d.ts +13 -2
- package/dist/components/overview/widgets/core.d.ts.map +1 -1
- package/dist/components/overview/widgets/core.js +43 -2
- package/dist/components/overview/widgets/data.d.ts +97 -8
- package/dist/components/overview/widgets/data.d.ts.map +1 -1
- package/dist/components/overview/widgets/line-totals.d.ts +48 -0
- package/dist/components/overview/widgets/line-totals.d.ts.map +1 -0
- package/dist/components/overview/widgets/line-totals.js +91 -0
- package/dist/components/overview/widgets/record-loaders.d.ts +63 -0
- package/dist/components/overview/widgets/record-loaders.d.ts.map +1 -0
- package/dist/components/overview/widgets/record-loaders.js +150 -0
- package/dist/components/overview/widgets/record-table.d.ts +45 -0
- package/dist/components/overview/widgets/record-table.d.ts.map +1 -0
- package/dist/components/overview/widgets/record-table.js +201 -0
- package/dist/components/overview/widgets/record-widgets.d.ts +27 -0
- package/dist/components/overview/widgets/record-widgets.d.ts.map +1 -0
- package/dist/components/overview/widgets/record-widgets.js +100 -0
- package/dist/components/screens/DetailScreen.svelte +105 -6
- package/dist/components/screens/DetailScreen.svelte.d.ts.map +1 -1
- package/dist/components/screens/EditForm.svelte +39 -3
- package/dist/components/screens/EditForm.svelte.d.ts.map +1 -1
- package/dist/components/screens/RecipeScreens.svelte +18 -18
- package/dist/components/screens/RecipeScreens.svelte.d.ts.map +1 -1
- package/dist/components/screens/RecordHeader.svelte +238 -0
- package/dist/components/screens/RecordHeader.svelte.d.ts +26 -0
- package/dist/components/screens/RecordHeader.svelte.d.ts.map +1 -0
- package/dist/components/screens/RecordScreen.svelte +511 -0
- package/dist/components/screens/RecordScreen.svelte.d.ts +5 -0
- package/dist/components/screens/RecordScreen.svelte.d.ts.map +1 -0
- package/dist/components/screens/columns.d.ts +29 -0
- package/dist/components/screens/columns.d.ts.map +1 -0
- package/dist/components/screens/columns.js +115 -0
- package/dist/components/screens/fields.d.ts +12 -2
- package/dist/components/screens/fields.d.ts.map +1 -1
- package/dist/components/screens/fields.js +37 -5
- package/dist/components/screens/index.d.ts +7 -2
- package/dist/components/screens/index.d.ts.map +1 -1
- package/dist/components/screens/index.js +6 -1
- package/dist/components/screens/record-header.d.ts +51 -0
- package/dist/components/screens/record-header.d.ts.map +1 -0
- package/dist/components/screens/record-header.js +118 -0
- package/dist/components/screens/relations.d.ts +49 -0
- package/dist/components/screens/relations.d.ts.map +1 -0
- package/dist/components/screens/relations.js +98 -0
- package/dist/components/screens/roles.d.ts +56 -0
- package/dist/components/screens/roles.d.ts.map +1 -0
- package/dist/components/screens/roles.js +174 -0
- package/dist/components/screens/types.d.ts +182 -1
- package/dist/components/screens/types.d.ts.map +1 -1
- package/dist/components/screens/values.d.ts +23 -1
- package/dist/components/screens/values.d.ts.map +1 -1
- package/dist/components/screens/values.js +80 -4
- package/dist/i18n/strings.overview.d.ts +25 -0
- package/dist/i18n/strings.overview.d.ts.map +1 -1
- package/dist/i18n/strings.overview.js +25 -0
- package/dist/i18n/strings.screens.d.ts +8 -0
- package/dist/i18n/strings.screens.d.ts.map +1 -1
- package/dist/i18n/strings.screens.js +8 -0
- package/package.json +16 -16
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* RecordScreen - the record page (#3718): a header (back link, the title or
|
|
4
|
+
* a document's "Order GRA-3159", the monospace identifier, a status badge
|
|
5
|
+
* and key facts by role; Edit as the one primary action and an overflow
|
|
6
|
+
* menu with the host's extra actions and Delete), a details card of the
|
|
7
|
+
* remaining fields (empty ones behind "Show empty fields"), and a
|
|
8
|
+
* customizable `OverviewGrid` below: by default a summary strip of metric
|
|
9
|
+
* tiles, the `line-items` table and totals when the model has line items,
|
|
10
|
+
* and one tabbed `related` widget (`recordOverview`, persisted per model
|
|
11
|
+
* kind). The edit form is a mode of the same screen: `mode: 'edit'` opens
|
|
12
|
+
* `EditForm` beside the header on desktop (`min-width: 48rem`) and in a
|
|
13
|
+
* full-screen `PhoneSheet` on phones. Closing the form calls `oncancel`.
|
|
14
|
+
*
|
|
15
|
+
* A related-records panel (a `records` widget with `filter: 'parent'` on one
|
|
16
|
+
* of the relations) is headed `Orders · 3` with its one "View all" link
|
|
17
|
+
* (`viewAllHref`, else the loaded data's `href`); the panel body then lists
|
|
18
|
+
* the rows only, so the count and the link never repeat.
|
|
19
|
+
*
|
|
20
|
+
* The widget loaders run in the browser with the record context
|
|
21
|
+
* (`ctx.record = { model, id }`); the host owns the data access. `related`
|
|
22
|
+
* and `line-items` load through `loaders.records`. The registry and
|
|
23
|
+
* overview are fixed at mount: remount (`{#key}`) to change the model kind.
|
|
24
|
+
* A record change reloads every widget.
|
|
25
|
+
*/
|
|
26
|
+
import { PhoneSheet } from '@happyvertical/smrt-ui/feedback';
|
|
27
|
+
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
28
|
+
import { onDestroy, untrack } from 'svelte';
|
|
29
|
+
import { M } from '../../i18n/strings.screens.js';
|
|
30
|
+
import { createOverview } from '../overview/controller.svelte.js';
|
|
31
|
+
import type { OverviewModelChoice } from '../overview/grid-types.js';
|
|
32
|
+
import { safeHref } from '../overview/markdown.js';
|
|
33
|
+
import OverviewGrid from '../overview/OverviewGrid.svelte';
|
|
34
|
+
import { recordOverview } from '../overview/record.js';
|
|
35
|
+
import { createWidgetRegistry } from '../overview/registry.js';
|
|
36
|
+
import type { OverviewWidget, WidgetLoadContext } from '../overview/types.js';
|
|
37
|
+
import { registerCoreWidgets } from '../overview/widgets/core.js';
|
|
38
|
+
import { isRecord } from '../overview/widgets/data.js';
|
|
39
|
+
import { registerRecordWidgets } from '../overview/widgets/record-widgets.js';
|
|
40
|
+
import DetailScreen from './DetailScreen.svelte';
|
|
41
|
+
import EditForm from './EditForm.svelte';
|
|
42
|
+
import {
|
|
43
|
+
deriveScreenFields,
|
|
44
|
+
screenSourceError,
|
|
45
|
+
screenTitles,
|
|
46
|
+
} from './fields.js';
|
|
47
|
+
import RecordHeader from './RecordHeader.svelte';
|
|
48
|
+
import {
|
|
49
|
+
deriveRecordHeader,
|
|
50
|
+
recordTotals,
|
|
51
|
+
totalsFields,
|
|
52
|
+
} from './record-header.js';
|
|
53
|
+
import type { ScreenRelation } from './relations.js';
|
|
54
|
+
import { currencyCodeField, deriveFieldRoles } from './roles.js';
|
|
55
|
+
import type { RecordScreenProps } from './types.js';
|
|
56
|
+
|
|
57
|
+
let {
|
|
58
|
+
definition,
|
|
59
|
+
policy = null,
|
|
60
|
+
record,
|
|
61
|
+
relations = [],
|
|
62
|
+
mode,
|
|
63
|
+
loaders,
|
|
64
|
+
registry,
|
|
65
|
+
widgetContext,
|
|
66
|
+
overview,
|
|
67
|
+
override,
|
|
68
|
+
onoverviewchange,
|
|
69
|
+
layoutEditing,
|
|
70
|
+
canCustomize = true,
|
|
71
|
+
hrefFor,
|
|
72
|
+
labels,
|
|
73
|
+
viewAllHref,
|
|
74
|
+
title,
|
|
75
|
+
fields,
|
|
76
|
+
currency,
|
|
77
|
+
locale,
|
|
78
|
+
backLabel,
|
|
79
|
+
deleting = false,
|
|
80
|
+
submitting = false,
|
|
81
|
+
fieldErrors,
|
|
82
|
+
error = null,
|
|
83
|
+
onback,
|
|
84
|
+
onedit,
|
|
85
|
+
oncancel,
|
|
86
|
+
onsave,
|
|
87
|
+
ondelete,
|
|
88
|
+
extraActions,
|
|
89
|
+
statusTones,
|
|
90
|
+
derivedFields,
|
|
91
|
+
items,
|
|
92
|
+
children,
|
|
93
|
+
}: RecordScreenProps = $props();
|
|
94
|
+
|
|
95
|
+
const { t } = useI18n();
|
|
96
|
+
|
|
97
|
+
/** Desktop breakpoint: the edit form sits beside the header from here up. */
|
|
98
|
+
const DESKTOP_QUERY = '(min-width: 48rem)';
|
|
99
|
+
|
|
100
|
+
const titles = $derived(screenTitles(definition));
|
|
101
|
+
const idField = $derived(definition.idField ?? 'id');
|
|
102
|
+
const recordId = $derived(
|
|
103
|
+
record[idField] === undefined || record[idField] === null
|
|
104
|
+
? ''
|
|
105
|
+
: String(record[idField]),
|
|
106
|
+
);
|
|
107
|
+
const editing = $derived(mode === 'edit');
|
|
108
|
+
const editTitle = $derived(
|
|
109
|
+
t(M['ui.screens.edit_title'], { name: titles.singular.toLowerCase() }),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const roles = $derived(deriveFieldRoles(definition));
|
|
113
|
+
const viewFields = $derived(
|
|
114
|
+
screenSourceError(definition, policy)
|
|
115
|
+
? []
|
|
116
|
+
: deriveScreenFields(definition, policy, { mode: 'view', include: fields }),
|
|
117
|
+
);
|
|
118
|
+
const header = $derived(
|
|
119
|
+
deriveRecordHeader(definition, viewFields, record, roles, { title }),
|
|
120
|
+
);
|
|
121
|
+
const hasItems = $derived(relations.some((r) => r.kind === 'items'));
|
|
122
|
+
const currencyField = $derived(currencyCodeField(definition));
|
|
123
|
+
/** The record's currency: the prop, else its own currency-code field. */
|
|
124
|
+
const recordCurrency = $derived.by(() => {
|
|
125
|
+
if (currency) return currency;
|
|
126
|
+
const value = currencyField ? record[currencyField] : undefined;
|
|
127
|
+
return typeof value === 'string' && /^[A-Za-z]{3}$/.test(value.trim())
|
|
128
|
+
? value.trim().toUpperCase()
|
|
129
|
+
: undefined;
|
|
130
|
+
});
|
|
131
|
+
/** Derived fields of the edit form: the host's, else the totals under line items. */
|
|
132
|
+
const formDerived = $derived(
|
|
133
|
+
derivedFields ?? (hasItems ? totalsFields(roles) : []),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// The registry and the overview definition are fixed for the life of the
|
|
137
|
+
// component (a controller is built once); the loaders read live props.
|
|
138
|
+
const widgetRegistry = untrack(() => {
|
|
139
|
+
const records = loaders?.records;
|
|
140
|
+
if (registry && !records) return registry;
|
|
141
|
+
// A host registry (recipe widgets) is copied, so the record widgets below
|
|
142
|
+
// join it without touching the host's shared registry.
|
|
143
|
+
const own = createWidgetRegistry();
|
|
144
|
+
if (registry) for (const def of registry.list()) own.register(def);
|
|
145
|
+
else registerCoreWidgets(own, loaders ?? {});
|
|
146
|
+
// `related` and `line-items` load through the `records` loader, one
|
|
147
|
+
// parent-filtered load per relation. A host's own registration wins.
|
|
148
|
+
if (records) {
|
|
149
|
+
const recordWidgets = createWidgetRegistry();
|
|
150
|
+
registerRecordWidgets(recordWidgets, {
|
|
151
|
+
relations,
|
|
152
|
+
records,
|
|
153
|
+
viewAllHref: (relation) => viewAllHref?.(relation),
|
|
154
|
+
totals: () => recordTotals(record, roles, viewFields),
|
|
155
|
+
currency: () => recordCurrency,
|
|
156
|
+
});
|
|
157
|
+
for (const def of recordWidgets.list()) {
|
|
158
|
+
if (!own.has(def.type)) own.register(def);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return own;
|
|
162
|
+
});
|
|
163
|
+
const overviewDefinition = untrack(
|
|
164
|
+
() => overview ?? recordOverview(definition, relations),
|
|
165
|
+
);
|
|
166
|
+
const hostOwned = untrack(() => override !== undefined);
|
|
167
|
+
|
|
168
|
+
/** Relations a records panel can show: related records and line items. */
|
|
169
|
+
const children_ = $derived(relations.filter((r) => r.kind !== 'reference'));
|
|
170
|
+
|
|
171
|
+
/** What a related-records panel's heading shows: its count and View all. */
|
|
172
|
+
interface PanelSummary {
|
|
173
|
+
count: number | null;
|
|
174
|
+
href: string | null;
|
|
175
|
+
}
|
|
176
|
+
let summaries = $state<Record<string, PanelSummary>>({});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A related-records panel's data, with its count and "View all" moved into
|
|
180
|
+
* the heading: the widget body gets the rows only (no `total` / `href`
|
|
181
|
+
* footer), so each panel carries exactly one View all. A partial page of a
|
|
182
|
+
* table keeps its `total`, so the table knows not to sum just the page.
|
|
183
|
+
*/
|
|
184
|
+
function headPanel(widget: OverviewWidget, data: unknown): unknown {
|
|
185
|
+
if (widget.type !== 'records') return data;
|
|
186
|
+
const relation = relationOf(widget);
|
|
187
|
+
if (!relation || !isRecord(data)) return data;
|
|
188
|
+
const { total, href, ...rest } = data;
|
|
189
|
+
const rows = Array.isArray(rest.rows) ? rest.rows.length : null;
|
|
190
|
+
const partialTable =
|
|
191
|
+
typeof total === 'number' &&
|
|
192
|
+
total > (rows ?? 0) &&
|
|
193
|
+
Array.isArray(rest.columns) &&
|
|
194
|
+
rest.columns.length > 0;
|
|
195
|
+
summaries = {
|
|
196
|
+
...summaries,
|
|
197
|
+
[widget.id]: {
|
|
198
|
+
count: typeof total === 'number' && Number.isFinite(total) ? total : rows,
|
|
199
|
+
href:
|
|
200
|
+
viewAllHref?.(relation) ??
|
|
201
|
+
(typeof href === 'string' ? safeHref(href) : null),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
return partialTable ? { ...rest, total } : rest;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const controller = createOverview({
|
|
208
|
+
definition: overviewDefinition,
|
|
209
|
+
registry: widgetRegistry,
|
|
210
|
+
override: hostOwned ? () => override ?? null : undefined,
|
|
211
|
+
onchange: (next) => onoverviewchange?.(next),
|
|
212
|
+
canCustomize: () => canCustomize,
|
|
213
|
+
loadWidget: (widget, signal) => {
|
|
214
|
+
const load = widgetRegistry.get(widget.type)?.load;
|
|
215
|
+
if (!load) return Promise.resolve(undefined);
|
|
216
|
+
const ctx: WidgetLoadContext = {
|
|
217
|
+
...widgetContext,
|
|
218
|
+
overviewId: overviewDefinition.id,
|
|
219
|
+
signal,
|
|
220
|
+
locale:
|
|
221
|
+
typeof widgetContext?.locale === 'string'
|
|
222
|
+
? widgetContext.locale
|
|
223
|
+
: locale,
|
|
224
|
+
record: { model: definition.objectRef, id: recordId },
|
|
225
|
+
};
|
|
226
|
+
return Promise.resolve(load(widget.options, ctx)).then((data) =>
|
|
227
|
+
headPanel(widget, data),
|
|
228
|
+
);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
onDestroy(() => controller.destroy());
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The details card leaves out what the header shows, the currency code
|
|
235
|
+
* (every amount is formatted in it) and, while the overview renders a
|
|
236
|
+
* line-items totals block, the subtotal / discount / tax / total fields.
|
|
237
|
+
*/
|
|
238
|
+
const totalsBlockShown = $derived(
|
|
239
|
+
hasItems && controller.document.widgets.some((w) => w.type === 'line-items'),
|
|
240
|
+
);
|
|
241
|
+
const detailExclude = $derived([
|
|
242
|
+
...header.shown,
|
|
243
|
+
...(currencyField ? [currencyField] : []),
|
|
244
|
+
...(totalsBlockShown ? totalsFields(roles) : []),
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
// A host-owned override can change outside the controller; a record change
|
|
248
|
+
// means every panel reads a different parent.
|
|
249
|
+
let loadedFor = untrack(() => recordId);
|
|
250
|
+
$effect(() => {
|
|
251
|
+
const id = recordId;
|
|
252
|
+
if (id === loadedFor) return;
|
|
253
|
+
loadedFor = id;
|
|
254
|
+
untrack(() => {
|
|
255
|
+
for (const widget of controller.document.widgets)
|
|
256
|
+
controller.reload(widget.id);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
$effect(() => {
|
|
260
|
+
if (!hostOwned) return;
|
|
261
|
+
void override;
|
|
262
|
+
untrack(() => controller.sync());
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const modelChoices = $derived.by((): OverviewModelChoice[] => {
|
|
266
|
+
const out: OverviewModelChoice[] = [];
|
|
267
|
+
for (const relation of children_) {
|
|
268
|
+
if (out.some((c) => c.value === relation.model)) continue;
|
|
269
|
+
out.push({ value: relation.model, label: relation.label });
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
/** The `children` relation a parent-filtered widget shows, if any. */
|
|
275
|
+
function relationOf(widget: OverviewWidget): ScreenRelation | undefined {
|
|
276
|
+
const { filter, model, field } = widget.options;
|
|
277
|
+
if (filter !== 'parent') return undefined;
|
|
278
|
+
return children_.find((r) => r.model === model && r.field === field);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Phones get the edit form as a full-screen sheet; the server and a browser
|
|
282
|
+
// without matchMedia render the desktop layout.
|
|
283
|
+
let desktop = $state(true);
|
|
284
|
+
$effect(() => {
|
|
285
|
+
if (
|
|
286
|
+
typeof window === 'undefined' ||
|
|
287
|
+
typeof window.matchMedia !== 'function'
|
|
288
|
+
) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const media = window.matchMedia(DESKTOP_QUERY);
|
|
292
|
+
const sync = () => {
|
|
293
|
+
desktop = media.matches;
|
|
294
|
+
};
|
|
295
|
+
sync();
|
|
296
|
+
media.addEventListener('change', sync);
|
|
297
|
+
return () => media.removeEventListener('change', sync);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
function cancel(): void {
|
|
301
|
+
oncancel?.();
|
|
302
|
+
}
|
|
303
|
+
</script>
|
|
304
|
+
|
|
305
|
+
{#snippet widgetHeading(widget: OverviewWidget, widgetTitle: string)}
|
|
306
|
+
{@const panel =
|
|
307
|
+
widget.type === 'records' &&
|
|
308
|
+
relationOf(widget) &&
|
|
309
|
+
controller.entry(widget).status === 'ready'
|
|
310
|
+
? summaries[widget.id]
|
|
311
|
+
: undefined}
|
|
312
|
+
<span class="smrt-record-screen__widget-heading">
|
|
313
|
+
<span class="smrt-record-screen__widget-title">
|
|
314
|
+
{widgetTitle}{#if panel && panel.count !== null}<span class="smrt-record-screen__count"
|
|
315
|
+
>{' · '}{new Intl.NumberFormat(locale).format(panel.count)}</span
|
|
316
|
+
>{/if}
|
|
317
|
+
</span>
|
|
318
|
+
{#if panel?.href}
|
|
319
|
+
<a class="smrt-record-screen__view-all" href={panel.href}>{t(M['ui.screens.view_all'])}</a>
|
|
320
|
+
{/if}
|
|
321
|
+
</span>
|
|
322
|
+
{/snippet}
|
|
323
|
+
|
|
324
|
+
{#snippet editForm()}
|
|
325
|
+
{#if onsave}
|
|
326
|
+
{#key recordId}
|
|
327
|
+
<EditForm
|
|
328
|
+
{definition}
|
|
329
|
+
{policy}
|
|
330
|
+
currency={recordCurrency}
|
|
331
|
+
{fields}
|
|
332
|
+
{record}
|
|
333
|
+
showHeader={false}
|
|
334
|
+
{submitting}
|
|
335
|
+
fieldErrors={fieldErrors ?? {}}
|
|
336
|
+
{error}
|
|
337
|
+
onsubmit={onsave}
|
|
338
|
+
oncancel={cancel}
|
|
339
|
+
derivedFields={formDerived}
|
|
340
|
+
/>
|
|
341
|
+
{/key}
|
|
342
|
+
{/if}
|
|
343
|
+
{#if items}
|
|
344
|
+
<section class="smrt-record-screen__items" aria-label={t(M['ui.screens.items'])}>
|
|
345
|
+
{@render items()}
|
|
346
|
+
</section>
|
|
347
|
+
{/if}
|
|
348
|
+
{/snippet}
|
|
349
|
+
|
|
350
|
+
<div
|
|
351
|
+
class="smrt-record-screen"
|
|
352
|
+
class:smrt-record-screen--edit={editing}
|
|
353
|
+
data-mode={mode}
|
|
354
|
+
data-smrt-component="RecordScreen"
|
|
355
|
+
>
|
|
356
|
+
<div class="smrt-record-screen__main">
|
|
357
|
+
<RecordHeader
|
|
358
|
+
{definition}
|
|
359
|
+
model={header}
|
|
360
|
+
{record}
|
|
361
|
+
{relations}
|
|
362
|
+
{hrefFor}
|
|
363
|
+
{labels}
|
|
364
|
+
currency={recordCurrency}
|
|
365
|
+
{locale}
|
|
366
|
+
{backLabel}
|
|
367
|
+
{deleting}
|
|
368
|
+
{statusTones}
|
|
369
|
+
{onback}
|
|
370
|
+
onedit={editing ? undefined : onedit}
|
|
371
|
+
{ondelete}
|
|
372
|
+
{extraActions}
|
|
373
|
+
/>
|
|
374
|
+
<DetailScreen
|
|
375
|
+
{definition}
|
|
376
|
+
{policy}
|
|
377
|
+
{record}
|
|
378
|
+
{relations}
|
|
379
|
+
{fields}
|
|
380
|
+
currency={recordCurrency}
|
|
381
|
+
{locale}
|
|
382
|
+
{hrefFor}
|
|
383
|
+
{labels}
|
|
384
|
+
showHeader={false}
|
|
385
|
+
exclude={detailExclude}
|
|
386
|
+
hideEmpty
|
|
387
|
+
variant="card"
|
|
388
|
+
>
|
|
389
|
+
{@render children?.()}
|
|
390
|
+
</DetailScreen>
|
|
391
|
+
<section class="smrt-record-screen__related" aria-label={t(M['ui.screens.related'])}>
|
|
392
|
+
<OverviewGrid
|
|
393
|
+
{controller}
|
|
394
|
+
editing={layoutEditing}
|
|
395
|
+
presentation="plain"
|
|
396
|
+
headingLevel={2}
|
|
397
|
+
heading={widgetHeading}
|
|
398
|
+
label={t(M['ui.screens.related'])}
|
|
399
|
+
models={modelChoices}
|
|
400
|
+
/>
|
|
401
|
+
</section>
|
|
402
|
+
</div>
|
|
403
|
+
{#if editing && desktop}
|
|
404
|
+
<aside class="smrt-record-screen__aside" aria-label={editTitle}>
|
|
405
|
+
<h2 class="smrt-record-screen__aside-title">{editTitle}</h2>
|
|
406
|
+
{@render editForm()}
|
|
407
|
+
</aside>
|
|
408
|
+
{/if}
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
{#if !desktop}
|
|
412
|
+
<!-- A section, so the sheet's header is not a second banner landmark. -->
|
|
413
|
+
<section class="smrt-record-screen__sheet" data-smrt-component="RecordScreen">
|
|
414
|
+
<PhoneSheet open={editing} title={editTitle} onclose={cancel} variant="page">
|
|
415
|
+
{#if editing}
|
|
416
|
+
<div class="smrt-record-screen__sheet-body">{@render editForm()}</div>
|
|
417
|
+
{/if}
|
|
418
|
+
</PhoneSheet>
|
|
419
|
+
</section>
|
|
420
|
+
{/if}
|
|
421
|
+
|
|
422
|
+
<style>
|
|
423
|
+
@layer smrt.reset, smrt.structure, smrt.theme;
|
|
424
|
+
@layer smrt.theme {
|
|
425
|
+
.smrt-record-screen {
|
|
426
|
+
display: grid;
|
|
427
|
+
grid-template-columns: minmax(0, 1fr);
|
|
428
|
+
gap: var(--smrt-spacing-lg, 1.5rem);
|
|
429
|
+
align-items: start;
|
|
430
|
+
}
|
|
431
|
+
.smrt-record-screen__main {
|
|
432
|
+
display: flex;
|
|
433
|
+
flex-direction: column;
|
|
434
|
+
gap: var(--smrt-spacing-lg, 1.5rem);
|
|
435
|
+
min-inline-size: 0;
|
|
436
|
+
}
|
|
437
|
+
.smrt-record-screen__related {
|
|
438
|
+
min-inline-size: 0;
|
|
439
|
+
}
|
|
440
|
+
.smrt-record-screen__widget-heading {
|
|
441
|
+
display: flex;
|
|
442
|
+
flex-wrap: wrap;
|
|
443
|
+
align-items: baseline;
|
|
444
|
+
justify-content: space-between;
|
|
445
|
+
gap: var(--smrt-spacing-sm, 0.5rem);
|
|
446
|
+
}
|
|
447
|
+
.smrt-record-screen__count {
|
|
448
|
+
color: var(--smrt-color-on-surface-variant);
|
|
449
|
+
font-variant-numeric: tabular-nums;
|
|
450
|
+
}
|
|
451
|
+
.smrt-record-screen__view-all {
|
|
452
|
+
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
453
|
+
font-weight: var(--smrt-typography-weight-normal, 400);
|
|
454
|
+
}
|
|
455
|
+
.smrt-record-screen__aside {
|
|
456
|
+
display: flex;
|
|
457
|
+
flex-direction: column;
|
|
458
|
+
gap: var(--smrt-spacing-md, 1rem);
|
|
459
|
+
padding: var(--smrt-spacing-md, 1rem);
|
|
460
|
+
border: var(--smrt-hairline);
|
|
461
|
+
border-radius: var(--smrt-radius-lg);
|
|
462
|
+
background: var(--smrt-color-surface-container-low, var(--smrt-color-surface));
|
|
463
|
+
animation: smrt-record-screen-in var(--smrt-duration-medium1)
|
|
464
|
+
var(--smrt-easing-standard) both;
|
|
465
|
+
}
|
|
466
|
+
.smrt-record-screen__items {
|
|
467
|
+
min-inline-size: 0;
|
|
468
|
+
}
|
|
469
|
+
.smrt-record-screen__aside-title {
|
|
470
|
+
margin: 0;
|
|
471
|
+
font-size: var(--smrt-typography-title-medium-size, 1rem);
|
|
472
|
+
}
|
|
473
|
+
@media (min-width: 48rem) {
|
|
474
|
+
.smrt-record-screen--edit {
|
|
475
|
+
grid-template-columns: minmax(0, 1fr) minmax(20rem, 28rem);
|
|
476
|
+
}
|
|
477
|
+
.smrt-record-screen__aside {
|
|
478
|
+
position: sticky;
|
|
479
|
+
inset-block-start: var(--smrt-spacing-md, 1rem);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
.smrt-record-screen__sheet {
|
|
483
|
+
position: fixed;
|
|
484
|
+
inset: 0;
|
|
485
|
+
z-index: var(--smrt-z-index-modal);
|
|
486
|
+
pointer-events: none;
|
|
487
|
+
}
|
|
488
|
+
.smrt-record-screen__sheet > :global(*) {
|
|
489
|
+
pointer-events: auto;
|
|
490
|
+
}
|
|
491
|
+
/* The sheet body is edge to edge; the form gets the page gutter. */
|
|
492
|
+
.smrt-record-screen__sheet-body {
|
|
493
|
+
padding: var(--smrt-spacing-md, 1rem);
|
|
494
|
+
}
|
|
495
|
+
@keyframes smrt-record-screen-in {
|
|
496
|
+
from {
|
|
497
|
+
opacity: 0;
|
|
498
|
+
transform: translateX(var(--smrt-spacing-md, 1rem));
|
|
499
|
+
}
|
|
500
|
+
to {
|
|
501
|
+
opacity: 1;
|
|
502
|
+
transform: none;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
@media (prefers-reduced-motion: reduce) {
|
|
506
|
+
.smrt-record-screen__aside {
|
|
507
|
+
animation: none;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
</style>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RecordScreenProps } from './types.js';
|
|
2
|
+
declare const RecordScreen: import("svelte").Component<RecordScreenProps, {}, "">;
|
|
3
|
+
type RecordScreen = ReturnType<typeof RecordScreen>;
|
|
4
|
+
export default RecordScreen;
|
|
5
|
+
//# sourceMappingURL=RecordScreen.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RecordScreen.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/screens/RecordScreen.svelte.ts"],"names":[],"mappings":"AAwDA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAkVpD,QAAA,MAAM,YAAY,uDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table columns chosen by role (#3718 record page): the columns a related
|
|
3
|
+
* records table shows for a child model, and the columns of a line-items
|
|
4
|
+
* table. Pure and Node-tested; `deriveScreenRelations` attaches them to each
|
|
5
|
+
* `children` / `items` relation, and `widgets/record-table.ts` re-exports
|
|
6
|
+
* them for hosts that build their own.
|
|
7
|
+
*/
|
|
8
|
+
import type { RecordListColumn } from '../overview/widgets/data.js';
|
|
9
|
+
import type { ScreenFieldRoles } from './roles.js';
|
|
10
|
+
import type { ScreenCollectionDefinition } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* Columns a related-records table shows for a child model, chosen by role:
|
|
13
|
+
* identifier (else the title), status, the lead date, a party other than the
|
|
14
|
+
* parent link, and the amount (the record's total, else its first money
|
|
15
|
+
* field). `exclude` drops the FK back to the parent.
|
|
16
|
+
*/
|
|
17
|
+
export declare function relatedColumns(definition: ScreenCollectionDefinition, roles: ScreenFieldRoles, options?: {
|
|
18
|
+
exclude?: readonly string[];
|
|
19
|
+
max?: number;
|
|
20
|
+
}): RecordListColumn[];
|
|
21
|
+
/**
|
|
22
|
+
* Columns of a line-items table: the item (description / title / name),
|
|
23
|
+
* quantity, unit price and the line amount. `unitPrice` is the money field
|
|
24
|
+
* named like a unit price; the amount is the last other money field.
|
|
25
|
+
*/
|
|
26
|
+
export declare function lineItemColumns(definition: ScreenCollectionDefinition, roles: ScreenFieldRoles, options?: {
|
|
27
|
+
exclude?: readonly string[];
|
|
28
|
+
}): RecordListColumn[];
|
|
29
|
+
//# sourceMappingURL=columns.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"columns.d.ts","sourceRoot":"","sources":["../../../src/components/screens/columns.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAEpE,OAAO,KAAK,EAAa,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC9D,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AA4B7D;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,UAAU,EAAE,0BAA0B,EACtC,KAAK,EAAE,gBAAgB,EACvB,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1D,gBAAgB,EAAE,CAsCpB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,0BAA0B,EACtC,KAAK,EAAE,gBAAgB,EACvB,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAO,GAC5C,gBAAgB,EAAE,CAuCpB"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { humanizeFieldName } from './fields.js';
|
|
2
|
+
/** Roles that render one column each, in table order. */
|
|
3
|
+
const COLUMN_ROLE_ORDER = [
|
|
4
|
+
'identifier',
|
|
5
|
+
'status',
|
|
6
|
+
'date',
|
|
7
|
+
'party',
|
|
8
|
+
'total',
|
|
9
|
+
'money',
|
|
10
|
+
];
|
|
11
|
+
/** A records column's role: the first of its roles a table renders specially. */
|
|
12
|
+
function renderRole(roles) {
|
|
13
|
+
for (const role of [
|
|
14
|
+
'identifier',
|
|
15
|
+
'status',
|
|
16
|
+
'date',
|
|
17
|
+
'money',
|
|
18
|
+
'quantity',
|
|
19
|
+
'party',
|
|
20
|
+
'title',
|
|
21
|
+
]) {
|
|
22
|
+
if (roles.includes(role))
|
|
23
|
+
return role;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Columns a related-records table shows for a child model, chosen by role:
|
|
29
|
+
* identifier (else the title), status, the lead date, a party other than the
|
|
30
|
+
* parent link, and the amount (the record's total, else its first money
|
|
31
|
+
* field). `exclude` drops the FK back to the parent.
|
|
32
|
+
*/
|
|
33
|
+
export function relatedColumns(definition, roles, options = {}) {
|
|
34
|
+
const exclude = new Set(options.exclude ?? []);
|
|
35
|
+
const max = options.max ?? 4;
|
|
36
|
+
const names = Object.keys(roles).filter((name) => !exclude.has(name) && name in definition.fields);
|
|
37
|
+
const pick = (role) => names.find((name) => roles[name]?.includes(role));
|
|
38
|
+
const chosen = [];
|
|
39
|
+
const add = (name) => {
|
|
40
|
+
if (name && !chosen.includes(name))
|
|
41
|
+
chosen.push(name);
|
|
42
|
+
};
|
|
43
|
+
add(pick('identifier') ?? pick('title'));
|
|
44
|
+
for (const role of COLUMN_ROLE_ORDER) {
|
|
45
|
+
if (role === 'identifier')
|
|
46
|
+
continue;
|
|
47
|
+
if (role === 'money' && chosen.some((n) => roles[n]?.includes('money')))
|
|
48
|
+
continue;
|
|
49
|
+
if (role === 'date') {
|
|
50
|
+
// The lead date: a business date before the system timestamps.
|
|
51
|
+
add(names.find((n) => roles[n]?.includes('date') &&
|
|
52
|
+
n !== 'createdAt' &&
|
|
53
|
+
n !== 'updatedAt') ?? pick('date'));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
add(pick(role));
|
|
57
|
+
}
|
|
58
|
+
return chosen.slice(0, max).map((name) => ({
|
|
59
|
+
key: name,
|
|
60
|
+
label: humanizeColumnLabel(name, definition),
|
|
61
|
+
...(renderRole(roles[name] ?? [])
|
|
62
|
+
? { role: renderRole(roles[name] ?? []) }
|
|
63
|
+
: {}),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Columns of a line-items table: the item (description / title / name),
|
|
68
|
+
* quantity, unit price and the line amount. `unitPrice` is the money field
|
|
69
|
+
* named like a unit price; the amount is the last other money field.
|
|
70
|
+
*/
|
|
71
|
+
export function lineItemColumns(definition, roles, options = {}) {
|
|
72
|
+
const exclude = new Set(options.exclude ?? []);
|
|
73
|
+
const names = Object.keys(roles).filter((name) => !exclude.has(name) && name in definition.fields);
|
|
74
|
+
const textNames = names.filter((n) => definition.fields[n]?.type === 'text');
|
|
75
|
+
const item = textNames.find((n) => /^(?:description|title|name|label)$/.test(n)) ??
|
|
76
|
+
textNames.find((n) => roles[n]?.includes('title')) ??
|
|
77
|
+
textNames.find((n) => !roles[n]?.includes('identifier'));
|
|
78
|
+
const quantity = names.find((n) => roles[n]?.includes('quantity'));
|
|
79
|
+
const money = names.filter((n) => roles[n]?.includes('money'));
|
|
80
|
+
const unit = money.find((n) => /unit|price|rate/i.test(n));
|
|
81
|
+
const amount = money.find((n) => /^(?:amount|lineTotal|total|lineAmount)$/.test(n)) ??
|
|
82
|
+
[...money].reverse().find((n) => n !== unit);
|
|
83
|
+
const out = [];
|
|
84
|
+
if (item)
|
|
85
|
+
out.push({ key: item, label: humanizeColumnLabel(item, definition) });
|
|
86
|
+
if (quantity)
|
|
87
|
+
out.push({
|
|
88
|
+
key: quantity,
|
|
89
|
+
label: humanizeColumnLabel(quantity, definition),
|
|
90
|
+
role: 'quantity',
|
|
91
|
+
});
|
|
92
|
+
if (unit)
|
|
93
|
+
out.push({
|
|
94
|
+
key: unit,
|
|
95
|
+
label: humanizeColumnLabel(unit, definition),
|
|
96
|
+
role: 'money',
|
|
97
|
+
summarize: false,
|
|
98
|
+
});
|
|
99
|
+
if (amount && amount !== unit)
|
|
100
|
+
out.push({
|
|
101
|
+
key: amount,
|
|
102
|
+
label: humanizeColumnLabel(amount, definition),
|
|
103
|
+
role: 'money',
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
function humanizeColumnLabel(name, definition) {
|
|
108
|
+
const field = definition.fields[name];
|
|
109
|
+
if (field &&
|
|
110
|
+
(field.type === 'foreignKey' || field.type === 'crossPackageRef') &&
|
|
111
|
+
/.[a-z]Id$/.test(name)) {
|
|
112
|
+
return humanizeFieldName(name.slice(0, -2));
|
|
113
|
+
}
|
|
114
|
+
return humanizeFieldName(name);
|
|
115
|
+
}
|