@happyvertical/smrt-template-sveltekit 0.55.24 → 0.56.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/AGENTS.md +10 -1
- package/mcp-apps-template/package.dependencies.json +2 -2
- package/package.json +21 -20
- package/template/gitignore.template +1 -0
- package/template/package.json +13 -12
- package/template/smrt.config.ts +1 -0
- package/template/src/lib/components/MaterializedReportDrilldown.svelte +135 -0
- package/template/src/lib/materialized-report-drilldown.ts +55 -0
- package/template/src/lib/server/materialized-reports.ts +12 -0
- package/template/src/routes/api/_reports/runtime/confirm/+server.ts +23 -0
- package/template/src/routes/reports/materialized/[id]/+page.server.ts +46 -0
- package/template/src/routes/reports/materialized/[id]/+page.svelte +97 -0
- package/template/src/routes/reports/materialized/[id]/drilldown/+server.ts +24 -0
- package/template/vite.config.ts +4 -1
- package/template.config.js +12 -11
package/AGENTS.md
CHANGED
|
@@ -67,7 +67,7 @@ It is the ground-up alternative to `smrt-saas-starter`.
|
|
|
67
67
|
CLI's or core's transitive dependencies to the app root — its strict layout
|
|
68
68
|
does not (#2297).
|
|
69
69
|
- `vite.config.ts` is `[sveltekit(), smrt()]`. The preset consumes exactly the
|
|
70
|
-
packages in `smrt.config.ts` `consumer.packages` (profiles, tenancy, users),
|
|
70
|
+
packages in `smrt.config.ts` `consumer.packages` (profiles, tenancy, users, reports),
|
|
71
71
|
scans `src/lib/objects`, and generates Vite virtual definitions, SvelteKit
|
|
72
72
|
routes, runtime registration, and knowledge artifacts. With a
|
|
73
73
|
`smrt.cookbook.json` it also hosts the cookbook's package models over REST
|
|
@@ -218,3 +218,12 @@ presented as WebMCP execution.
|
|
|
218
218
|
|
|
219
219
|
See [the M5 reference fixture gate](../../docs/content/m5-reference-fixture.md)
|
|
220
220
|
for what the milestone proves, what it does not, and the recovery commands.
|
|
221
|
+
|
|
222
|
+
Runtime report confirmation mounts the reports-owned Fetch handler at
|
|
223
|
+
`/api/_reports/runtime/confirm`. It passes verified locals and the request's
|
|
224
|
+
collection database; smrt-app-runtime has no reports dependency. The template
|
|
225
|
+
consumes the reports manifest for migrations and explicitly hosts only
|
|
226
|
+
`@happyvertical/smrt-reports:RuntimeReport` through the Vite preset’s `expose` option. Apps enabling
|
|
227
|
+
`reports.runtime` configure `packages.reports.runtime.sources`; the default
|
|
228
|
+
empty allow-list exposes no report sources. The authored route is preserved by
|
|
229
|
+
the template's `.gitignore` exception.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-template-sveltekit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.1",
|
|
4
4
|
"description": "Minimal SvelteKit project template with s-m-r-t framework integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -32,26 +32,26 @@
|
|
|
32
32
|
"directory": "packages/template-sveltekit"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@happyvertical/smrt-core": "0.
|
|
35
|
+
"@happyvertical/smrt-core": "0.56.1"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@happyvertical/smrt-app-cli": "0.
|
|
39
|
-
"@happyvertical/smrt-app-mcp": "0.
|
|
40
|
-
"@happyvertical/smrt-app-runtime": "0.
|
|
41
|
-
"@happyvertical/smrt-assets": "0.
|
|
42
|
-
"@happyvertical/smrt-chat": "0.
|
|
43
|
-
"@happyvertical/smrt-cli": "0.
|
|
44
|
-
"@happyvertical/smrt-config": "0.
|
|
45
|
-
"@happyvertical/smrt-jobs": "0.
|
|
46
|
-
"@happyvertical/smrt-mcp-openai": "0.
|
|
47
|
-
"@happyvertical/smrt-playbooks": "0.
|
|
48
|
-
"@happyvertical/smrt-profiles": "0.
|
|
49
|
-
"@happyvertical/smrt-scanner": "0.
|
|
50
|
-
"@happyvertical/smrt-svelte": "0.
|
|
51
|
-
"@happyvertical/smrt-tenancy": "0.
|
|
52
|
-
"@happyvertical/smrt-ui": "0.
|
|
53
|
-
"@happyvertical/smrt-users": "0.
|
|
54
|
-
"@happyvertical/smrt-web": "0.
|
|
38
|
+
"@happyvertical/smrt-app-cli": "0.56.1",
|
|
39
|
+
"@happyvertical/smrt-app-mcp": "0.56.1",
|
|
40
|
+
"@happyvertical/smrt-app-runtime": "0.56.1",
|
|
41
|
+
"@happyvertical/smrt-assets": "0.56.1",
|
|
42
|
+
"@happyvertical/smrt-chat": "0.56.1",
|
|
43
|
+
"@happyvertical/smrt-cli": "0.56.1",
|
|
44
|
+
"@happyvertical/smrt-config": "0.56.1",
|
|
45
|
+
"@happyvertical/smrt-jobs": "0.56.1",
|
|
46
|
+
"@happyvertical/smrt-mcp-openai": "0.56.1",
|
|
47
|
+
"@happyvertical/smrt-playbooks": "0.56.1",
|
|
48
|
+
"@happyvertical/smrt-profiles": "0.56.1",
|
|
49
|
+
"@happyvertical/smrt-scanner": "0.56.1",
|
|
50
|
+
"@happyvertical/smrt-svelte": "0.56.1",
|
|
51
|
+
"@happyvertical/smrt-tenancy": "0.56.1",
|
|
52
|
+
"@happyvertical/smrt-ui": "0.56.1",
|
|
53
|
+
"@happyvertical/smrt-users": "0.56.1",
|
|
54
|
+
"@happyvertical/smrt-web": "0.56.1",
|
|
55
55
|
"@happyvertical/sql": "^0.89.12",
|
|
56
56
|
"@modelcontextprotocol/client": "2.0.0",
|
|
57
57
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
"svelte-check": "^4.7.6",
|
|
65
65
|
"typescript": "6.0.3",
|
|
66
66
|
"vite": "8.1.4",
|
|
67
|
-
"vitest": "4.1.10"
|
|
67
|
+
"vitest": "4.1.10",
|
|
68
|
+
"@happyvertical/smrt-reports": "0.56.1"
|
|
68
69
|
},
|
|
69
70
|
"publishConfig": {
|
|
70
71
|
"registry": "https://registry.npmjs.org",
|
|
@@ -25,6 +25,7 @@ src/routes/api/**/+server.ts
|
|
|
25
25
|
# The runtime readiness endpoint is authored infrastructure, not generated API
|
|
26
26
|
# output, and must remain in the installable scaffold.
|
|
27
27
|
!src/routes/api/_runtime/health/+server.ts
|
|
28
|
+
!src/routes/api/_reports/runtime/confirm/+server.ts
|
|
28
29
|
src/lib/server/smrt-register.ts
|
|
29
30
|
src/lib/types/smrt-generated/
|
|
30
31
|
src/types/smrt-generated/
|
package/template/package.json
CHANGED
|
@@ -39,20 +39,21 @@
|
|
|
39
39
|
"svelte-check": "^4.7.6",
|
|
40
40
|
"typescript": "6.0.3",
|
|
41
41
|
"vite": "8.3.0",
|
|
42
|
-
"@happyvertical/smrt-cli": "^0.
|
|
42
|
+
"@happyvertical/smrt-cli": "^0.56.1"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@happyvertical/smrt-app-runtime": "^0.
|
|
46
|
-
"@happyvertical/smrt-config": "^0.
|
|
47
|
-
"@happyvertical/smrt-core": "^0.
|
|
48
|
-
"@happyvertical/smrt-jobs": "^0.
|
|
49
|
-
"@happyvertical/smrt-profiles": "^0.
|
|
50
|
-
"@happyvertical/smrt-svelte": "^0.
|
|
51
|
-
"@happyvertical/smrt-tenancy": "^0.
|
|
52
|
-
"@happyvertical/smrt-ui": "^0.
|
|
53
|
-
"@happyvertical/smrt-users": "^0.
|
|
54
|
-
"@happyvertical/smrt-web": "^0.
|
|
45
|
+
"@happyvertical/smrt-app-runtime": "^0.56.1",
|
|
46
|
+
"@happyvertical/smrt-config": "^0.56.1",
|
|
47
|
+
"@happyvertical/smrt-core": "^0.56.1",
|
|
48
|
+
"@happyvertical/smrt-jobs": "^0.56.1",
|
|
49
|
+
"@happyvertical/smrt-profiles": "^0.56.1",
|
|
50
|
+
"@happyvertical/smrt-svelte": "^0.56.1",
|
|
51
|
+
"@happyvertical/smrt-tenancy": "^0.56.1",
|
|
52
|
+
"@happyvertical/smrt-ui": "^0.56.1",
|
|
53
|
+
"@happyvertical/smrt-users": "^0.56.1",
|
|
54
|
+
"@happyvertical/smrt-web": "^0.56.1",
|
|
55
55
|
"@happyvertical/sql": "^0.89.12",
|
|
56
|
-
"@modelcontextprotocol/server": "^2.0.0"
|
|
56
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
57
|
+
"@happyvertical/smrt-reports": "^0.56.1"
|
|
57
58
|
}
|
|
58
59
|
}
|
package/template/smrt.config.ts
CHANGED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { MaterializedReportDrilldownPresentation } from '@happyvertical/smrt-reports';
|
|
3
|
+
import { formatWidgetValue } from '@happyvertical/smrt-ui/charts/format';
|
|
4
|
+
import { DataTable, type DataTableColumn } from '@happyvertical/smrt-ui/data';
|
|
5
|
+
import { Alert } from '@happyvertical/smrt-ui/feedback';
|
|
6
|
+
import { Badge, Card } from '@happyvertical/smrt-ui/ui';
|
|
7
|
+
import { parseMaterializedReportDrilldown } from '$lib/materialized-report-drilldown';
|
|
8
|
+
|
|
9
|
+
interface ContributorRow {
|
|
10
|
+
id: string;
|
|
11
|
+
source: string;
|
|
12
|
+
identity: string;
|
|
13
|
+
date: string;
|
|
14
|
+
party: string;
|
|
15
|
+
value: string;
|
|
16
|
+
taxable: string;
|
|
17
|
+
tax: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
details: unknown;
|
|
22
|
+
locale?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let { details, locale = 'en' }: Props = $props();
|
|
26
|
+
let presentation = $derived(parseMaterializedReportDrilldown(details));
|
|
27
|
+
|
|
28
|
+
const columns: DataTableColumn<ContributorRow>[] = [
|
|
29
|
+
{ id: 'source', label: 'Source', accessor: 'source', responsive: { keepVisible: true } },
|
|
30
|
+
{ id: 'identity', label: 'Document or source', accessor: 'identity', responsive: { keepVisible: true } },
|
|
31
|
+
{ id: 'date', label: 'Date', accessor: 'date' },
|
|
32
|
+
{ id: 'party', label: 'Party', accessor: 'party' },
|
|
33
|
+
{ id: 'value', label: 'Selected measure', accessor: 'value', align: 'right' },
|
|
34
|
+
{ id: 'taxable', label: 'Taxable', accessor: 'taxable', align: 'right' },
|
|
35
|
+
{ id: 'tax', label: 'Tax', accessor: 'tax', align: 'right' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function currency(value: number | null | undefined, code: string): string {
|
|
39
|
+
return value === null || value === undefined
|
|
40
|
+
? '–'
|
|
41
|
+
: formatWidgetValue(value, { format: 'money', currency: code, locale });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function calendarDate(value: string | null | undefined): string {
|
|
45
|
+
return value?.slice(0, 10) || '–';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rows(value: MaterializedReportDrilldownPresentation): ContributorRow[] {
|
|
49
|
+
return value.contributors.map((contributor) => ({
|
|
50
|
+
id: contributor.id,
|
|
51
|
+
source: contributor.source,
|
|
52
|
+
identity: contributor.identity,
|
|
53
|
+
date: calendarDate(contributor.date),
|
|
54
|
+
party: contributor.party || '–',
|
|
55
|
+
value: currency(contributor.values.valueMinor, value.currency),
|
|
56
|
+
taxable: currency(contributor.values.taxableMinor, value.currency),
|
|
57
|
+
tax: currency(contributor.values.taxMinor, value.currency),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<section class="drilldown" aria-label="Report drilldown" data-smrt-component="MaterializedReportDrilldown">
|
|
63
|
+
{#if presentation}
|
|
64
|
+
<Card>
|
|
65
|
+
<div class="heading">
|
|
66
|
+
<div>
|
|
67
|
+
<h2>Report drilldown</h2>
|
|
68
|
+
<p>Selected measure: <strong>{presentation.selectedMeasure.label}</strong></p>
|
|
69
|
+
</div>
|
|
70
|
+
<Badge variant={presentation.complete ? 'success' : 'warning'}>
|
|
71
|
+
{presentation.complete ? 'Complete' : 'Needs attention'}
|
|
72
|
+
</Badge>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{#each presentation.warnings as warning (warning.code)}
|
|
76
|
+
<Alert variant={warning.kind === 'drift' ? 'warning' : 'info'} title={warning.kind === 'drift' ? 'Source data changed' : 'Detail is incomplete'}>
|
|
77
|
+
{warning.message}
|
|
78
|
+
</Alert>
|
|
79
|
+
{/each}
|
|
80
|
+
|
|
81
|
+
{#if !presentation.complete && presentation.warnings.length === 0}
|
|
82
|
+
<Alert variant="warning" title="Detail is incomplete">
|
|
83
|
+
This report cannot establish the selected amount exactly.
|
|
84
|
+
</Alert>
|
|
85
|
+
{/if}
|
|
86
|
+
|
|
87
|
+
<DataTable
|
|
88
|
+
data={rows(presentation)}
|
|
89
|
+
{columns}
|
|
90
|
+
rowKey="id"
|
|
91
|
+
caption="Report contributors"
|
|
92
|
+
responsiveMode="hide-columns"
|
|
93
|
+
/>
|
|
94
|
+
</Card>
|
|
95
|
+
{:else}
|
|
96
|
+
<Alert variant="error" title="Report drilldown unavailable">
|
|
97
|
+
This report returned a detail that this application cannot display safely.
|
|
98
|
+
</Alert>
|
|
99
|
+
{/if}
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<style>
|
|
103
|
+
@layer smrt.theme {
|
|
104
|
+
.drilldown {
|
|
105
|
+
width: min(100%, 72rem);
|
|
106
|
+
margin-inline: auto;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.heading {
|
|
110
|
+
display: flex;
|
|
111
|
+
align-items: start;
|
|
112
|
+
justify-content: space-between;
|
|
113
|
+
gap: var(--smrt-spacing-4);
|
|
114
|
+
margin-bottom: var(--smrt-spacing-5);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
h2,
|
|
118
|
+
p {
|
|
119
|
+
margin: 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
h2 {
|
|
123
|
+
font: var(--smrt-typography-title-medium-font);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
p {
|
|
127
|
+
margin-top: var(--smrt-spacing-1);
|
|
128
|
+
color: var(--smrt-color-on-surface-variant);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
:global(.smrt-alert__root) {
|
|
132
|
+
margin-block: var(--smrt-spacing-3);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { MaterializedReportDrilldownPresentation } from '@happyvertical/smrt-reports';
|
|
2
|
+
|
|
3
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
4
|
+
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
|
|
6
|
+
const nonemptyText = (value: unknown): value is string =>
|
|
7
|
+
typeof value === 'string' && value.trim().length > 0;
|
|
8
|
+
|
|
9
|
+
const optionalText = (value: unknown): value is string | null | undefined =>
|
|
10
|
+
value === undefined || value === null || typeof value === 'string';
|
|
11
|
+
|
|
12
|
+
const minor = (value: unknown): value is number | null | undefined =>
|
|
13
|
+
value === undefined || value === null || Number.isSafeInteger(value);
|
|
14
|
+
|
|
15
|
+
/** Validate the versioned host payload before it reaches the generic UI. */
|
|
16
|
+
export function parseMaterializedReportDrilldown(
|
|
17
|
+
value: unknown,
|
|
18
|
+
): MaterializedReportDrilldownPresentation | undefined {
|
|
19
|
+
if (!isRecord(value) || value.version !== 1 || !isRecord(value.selectedMeasure))
|
|
20
|
+
return undefined;
|
|
21
|
+
if (
|
|
22
|
+
!nonemptyText(value.selectedMeasure.id) ||
|
|
23
|
+
!nonemptyText(value.selectedMeasure.label) ||
|
|
24
|
+
!nonemptyText(value.currency) ||
|
|
25
|
+
!Array.isArray(value.contributors) ||
|
|
26
|
+
!Array.isArray(value.warnings) ||
|
|
27
|
+
typeof value.complete !== 'boolean'
|
|
28
|
+
)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (!/^[A-Za-z]{3}$/.test(value.currency)) return undefined;
|
|
31
|
+
for (const contributor of value.contributors) {
|
|
32
|
+
if (!isRecord(contributor) || !isRecord(contributor.values)) return undefined;
|
|
33
|
+
if (
|
|
34
|
+
!nonemptyText(contributor.id) ||
|
|
35
|
+
!nonemptyText(contributor.source) ||
|
|
36
|
+
!nonemptyText(contributor.identity) ||
|
|
37
|
+
!optionalText(contributor.date) ||
|
|
38
|
+
!optionalText(contributor.party) ||
|
|
39
|
+
!minor(contributor.values.valueMinor) ||
|
|
40
|
+
!minor(contributor.values.taxableMinor) ||
|
|
41
|
+
!minor(contributor.values.taxMinor)
|
|
42
|
+
)
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
for (const warning of value.warnings) {
|
|
46
|
+
if (
|
|
47
|
+
!isRecord(warning) ||
|
|
48
|
+
!nonemptyText(warning.code) ||
|
|
49
|
+
!nonemptyText(warning.message) ||
|
|
50
|
+
(warning.kind !== 'incomplete' && warning.kind !== 'drift')
|
|
51
|
+
)
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return value as unknown as MaterializedReportDrilldownPresentation;
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MaterializedReportHost } from '@happyvertical/smrt-reports';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server-only materialized-report registrations for this application.
|
|
5
|
+
*
|
|
6
|
+
* Add fixed, application-owned hosts here. A commerce app uses
|
|
7
|
+
* `createTaxSummaryMaterializedReportHost(async () =>
|
|
8
|
+
* (await runtime.getCollection('TaxSummaryReport')).db)`.
|
|
9
|
+
* Browser requests select only one of
|
|
10
|
+
* these ids; they never supply a tenant, report constructor, or database.
|
|
11
|
+
*/
|
|
12
|
+
export const materializedReportHosts: readonly MaterializedReportHost[] = [];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SmrtRuntimeLocals } from '@happyvertical/smrt-app-runtime/sveltekit';
|
|
2
|
+
import { createRuntimeReportConfirmHandler } from '@happyvertical/smrt-reports';
|
|
3
|
+
import { runtime } from '$lib/server/smrt';
|
|
4
|
+
|
|
5
|
+
const confirm = createRuntimeReportConfirmHandler();
|
|
6
|
+
|
|
7
|
+
/** Approve a report as the verified session user, on the request's database. */
|
|
8
|
+
export const POST = async ({
|
|
9
|
+
request,
|
|
10
|
+
locals,
|
|
11
|
+
}: {
|
|
12
|
+
request: Request;
|
|
13
|
+
locals: SmrtRuntimeLocals;
|
|
14
|
+
}): Promise<Response> => {
|
|
15
|
+
if (!locals.user?.id || !locals.tenantId) return confirm(request, null);
|
|
16
|
+
const collection = await runtime.getCollection('RuntimeReport');
|
|
17
|
+
return confirm(request, {
|
|
18
|
+
db: collection.db,
|
|
19
|
+
userId: locals.user.id,
|
|
20
|
+
tenantId: locals.tenantId,
|
|
21
|
+
permissions: locals.permissions ?? [],
|
|
22
|
+
});
|
|
23
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { error } from '@sveltejs/kit';
|
|
2
|
+
import { assertMaterializedReportRead, findMaterializedReportHost } from '@happyvertical/smrt-reports';
|
|
3
|
+
import { materializedReportHosts } from '$lib/server/materialized-reports';
|
|
4
|
+
import type { PageServerLoad } from './$types';
|
|
5
|
+
|
|
6
|
+
function pageValue(value: string | null, fallback: number, maximum: number): number {
|
|
7
|
+
if (value === null) return fallback;
|
|
8
|
+
if (!/^\d+$/.test(value)) error(400, 'Invalid report page');
|
|
9
|
+
const parsed = Number(value);
|
|
10
|
+
if (!Number.isSafeInteger(parsed) || parsed > maximum) error(400, 'Invalid report page');
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const load: PageServerLoad = async ({ locals, params, url }) => {
|
|
15
|
+
if (!locals.user?.id || !locals.tenantId) error(401, 'Sign in required');
|
|
16
|
+
const host = findMaterializedReportHost(materializedReportHosts, params.id);
|
|
17
|
+
if (!host) error(404, 'Report not found');
|
|
18
|
+
const context = {
|
|
19
|
+
userId: locals.user.id,
|
|
20
|
+
tenantId: locals.tenantId,
|
|
21
|
+
permissions: locals.permissions ?? [],
|
|
22
|
+
};
|
|
23
|
+
try { assertMaterializedReportRead(host, context); } catch { error(403, 'Report unavailable'); }
|
|
24
|
+
const descriptor = await host.descriptor(context);
|
|
25
|
+
const offset = pageValue(url.searchParams.get('offset'), 0, 100_000);
|
|
26
|
+
const limit = pageValue(url.searchParams.get('limit'), 50, 100);
|
|
27
|
+
if (limit < 1) error(400, 'Invalid report page');
|
|
28
|
+
const result = await host.query(context, {
|
|
29
|
+
version: 1, requestId: `materialized:${host.id}`, mode: 'rows',
|
|
30
|
+
projection: [descriptor.identityField, ...descriptor.dataTable.columns.map((column) => column.id)],
|
|
31
|
+
page: { kind: 'offset', offset, limit },
|
|
32
|
+
});
|
|
33
|
+
if (result.page?.kind !== 'offset' || result.total.kind === 'unavailable')
|
|
34
|
+
error(500, 'Report page unavailable');
|
|
35
|
+
return {
|
|
36
|
+
hostId: host.id,
|
|
37
|
+
drilldownColumnIds: host.drilldownColumnIds,
|
|
38
|
+
page: {
|
|
39
|
+
descriptor,
|
|
40
|
+
rows: result.rows,
|
|
41
|
+
totalRows: result.total.value,
|
|
42
|
+
offset: result.page.offset,
|
|
43
|
+
limit: result.page.limit,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from '$app/navigation';
|
|
3
|
+
import { onMount } from 'svelte';
|
|
4
|
+
import { MaterializedReportTable } from '@happyvertical/smrt-reports/svelte';
|
|
5
|
+
import MaterializedReportDrilldown from '$lib/components/MaterializedReportDrilldown.svelte';
|
|
6
|
+
import { Alert } from '@happyvertical/smrt-ui/feedback';
|
|
7
|
+
import type { PageProps } from './$types';
|
|
8
|
+
|
|
9
|
+
let { data }: PageProps = $props();
|
|
10
|
+
let details = $state<unknown>(undefined);
|
|
11
|
+
let drilldownError = $state<string | null>(null);
|
|
12
|
+
let loading = $state(false);
|
|
13
|
+
let hydrated = $state(false);
|
|
14
|
+
let requestSequence = 0;
|
|
15
|
+
|
|
16
|
+
onMount(() => {
|
|
17
|
+
hydrated = true;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function drilldown(cell: { id: string; columnId: string }): Promise<void> {
|
|
21
|
+
const request = ++requestSequence;
|
|
22
|
+
loading = true;
|
|
23
|
+
drilldownError = null;
|
|
24
|
+
details = undefined;
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(`/reports/materialized/${encodeURIComponent(data.hostId)}/drilldown`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
body: JSON.stringify(cell),
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok) throw new Error('Report drilldown unavailable');
|
|
32
|
+
const responseDetails = await response.json();
|
|
33
|
+
if (request !== requestSequence) return;
|
|
34
|
+
details = responseDetails;
|
|
35
|
+
} catch {
|
|
36
|
+
if (request !== requestSequence) return;
|
|
37
|
+
drilldownError = 'Report drilldown unavailable';
|
|
38
|
+
} finally {
|
|
39
|
+
if (request === requestSequence) loading = false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function changePage(page: number): Promise<void> {
|
|
44
|
+
if (!Number.isSafeInteger(page) || page < 1) return;
|
|
45
|
+
requestSequence += 1;
|
|
46
|
+
details = undefined;
|
|
47
|
+
drilldownError = null;
|
|
48
|
+
loading = false;
|
|
49
|
+
const offset = (page - 1) * data.page.limit;
|
|
50
|
+
if (!Number.isSafeInteger(offset)) return;
|
|
51
|
+
const url = new URL(window.location.href);
|
|
52
|
+
url.searchParams.set('offset', String(offset));
|
|
53
|
+
url.searchParams.set('limit', String(data.page.limit));
|
|
54
|
+
await goto(`${url.pathname}?${url.searchParams.toString()}`, {
|
|
55
|
+
keepFocus: true,
|
|
56
|
+
noScroll: true,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<svelte:head><title>{data.page.descriptor.reportClassName}</title></svelte:head>
|
|
62
|
+
|
|
63
|
+
<main class="page" data-materialized-report-hydrated={hydrated ? 'true' : 'false'}>
|
|
64
|
+
<MaterializedReportTable
|
|
65
|
+
page={{
|
|
66
|
+
...data.page,
|
|
67
|
+
drilldownColumnIds: data.drilldownColumnIds,
|
|
68
|
+
}}
|
|
69
|
+
onDrilldown={drilldown}
|
|
70
|
+
onPageChange={changePage}
|
|
71
|
+
caption={data.page.descriptor.reportClassName}
|
|
72
|
+
/>
|
|
73
|
+
|
|
74
|
+
{#if loading}
|
|
75
|
+
<Alert variant="info" title="Loading report details">
|
|
76
|
+
Loading the contributors for the selected amount.
|
|
77
|
+
</Alert>
|
|
78
|
+
{:else if drilldownError}
|
|
79
|
+
<Alert variant="error" title="Report drilldown unavailable">
|
|
80
|
+
{drilldownError}
|
|
81
|
+
</Alert>
|
|
82
|
+
{:else if details}
|
|
83
|
+
<MaterializedReportDrilldown {details} />
|
|
84
|
+
{/if}
|
|
85
|
+
</main>
|
|
86
|
+
|
|
87
|
+
<style>
|
|
88
|
+
@layer smrt.theme {
|
|
89
|
+
.page {
|
|
90
|
+
display: grid;
|
|
91
|
+
gap: var(--smrt-spacing-6);
|
|
92
|
+
width: min(100%, 76rem);
|
|
93
|
+
margin-inline: auto;
|
|
94
|
+
padding: var(--smrt-spacing-6) var(--smrt-spacing-4);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { json } from '@sveltejs/kit';
|
|
2
|
+
import { assertMaterializedReportRead, findMaterializedReportHost } from '@happyvertical/smrt-reports';
|
|
3
|
+
import { materializedReportHosts } from '$lib/server/materialized-reports';
|
|
4
|
+
import type { RequestHandler } from './$types';
|
|
5
|
+
|
|
6
|
+
export const POST: RequestHandler = async ({ request, locals, params }) => {
|
|
7
|
+
if (!locals.user?.id || !locals.tenantId)
|
|
8
|
+
return json({ error: 'Sign in required' }, { status: 401 });
|
|
9
|
+
const host = findMaterializedReportHost(materializedReportHosts, params.id);
|
|
10
|
+
if (!host) return json({ error: 'Report not found' }, { status: 404 });
|
|
11
|
+
const context = { userId: locals.user.id, tenantId: locals.tenantId, permissions: locals.permissions ?? [] };
|
|
12
|
+
try { assertMaterializedReportRead(host, context); } catch { return json({ error: 'Report unavailable' }, { status: 403 }); }
|
|
13
|
+
let body: unknown;
|
|
14
|
+
try { body = await request.json(); } catch { return json({ error: 'Invalid request' }, { status: 400 }); }
|
|
15
|
+
if (!body || typeof body !== 'object') return json({ error: 'Invalid request' }, { status: 400 });
|
|
16
|
+
const { id, columnId } = body as Record<string, unknown>;
|
|
17
|
+
if (typeof id !== 'string' || !id || typeof columnId !== 'string' || !columnId)
|
|
18
|
+
return json({ error: 'Invalid request' }, { status: 400 });
|
|
19
|
+
try {
|
|
20
|
+
return json(await host.drilldown(context, { id, columnId }));
|
|
21
|
+
} catch {
|
|
22
|
+
return json({ error: 'Report drilldown unavailable' }, { status: 404 });
|
|
23
|
+
}
|
|
24
|
+
};
|
package/template/vite.config.ts
CHANGED
|
@@ -6,5 +6,8 @@ import { defineConfig } from 'vite';
|
|
|
6
6
|
// the packages listed in `smrt.config.ts` (`consumer.packages`), and generates
|
|
7
7
|
// this app's object registration, types, and API routes.
|
|
8
8
|
export default defineConfig({
|
|
9
|
-
plugins: [
|
|
9
|
+
plugins: [
|
|
10
|
+
sveltekit(),
|
|
11
|
+
smrt({ expose: ['@happyvertical/smrt-reports:RuntimeReport'] }),
|
|
12
|
+
],
|
|
10
13
|
});
|
package/template.config.js
CHANGED
|
@@ -19,22 +19,23 @@ export default {
|
|
|
19
19
|
|
|
20
20
|
// Dependencies to add to generated project
|
|
21
21
|
dependencies: {
|
|
22
|
-
'@happyvertical/smrt-app-runtime': '^0.
|
|
23
|
-
'@happyvertical/smrt-config': '^0.
|
|
24
|
-
'@happyvertical/smrt-core': '^0.
|
|
25
|
-
'@happyvertical/smrt-jobs': '^0.
|
|
26
|
-
'@happyvertical/smrt-profiles': '^0.
|
|
27
|
-
'@happyvertical/smrt-
|
|
28
|
-
'@happyvertical/smrt-
|
|
29
|
-
'@happyvertical/smrt-
|
|
30
|
-
'@happyvertical/smrt-
|
|
31
|
-
'@happyvertical/smrt-
|
|
22
|
+
'@happyvertical/smrt-app-runtime': '^0.56.1',
|
|
23
|
+
'@happyvertical/smrt-config': '^0.56.1',
|
|
24
|
+
'@happyvertical/smrt-core': '^0.56.1',
|
|
25
|
+
'@happyvertical/smrt-jobs': '^0.56.1',
|
|
26
|
+
'@happyvertical/smrt-profiles': '^0.56.1',
|
|
27
|
+
'@happyvertical/smrt-reports': '^0.56.1',
|
|
28
|
+
'@happyvertical/smrt-svelte': '^0.56.1',
|
|
29
|
+
'@happyvertical/smrt-tenancy': '^0.56.1',
|
|
30
|
+
'@happyvertical/smrt-ui': '^0.56.1',
|
|
31
|
+
'@happyvertical/smrt-users': '^0.56.1',
|
|
32
|
+
'@happyvertical/smrt-web': '^0.56.1',
|
|
32
33
|
'@happyvertical/sql': '^0.89.12',
|
|
33
34
|
'@modelcontextprotocol/server': '^2.0.0',
|
|
34
35
|
},
|
|
35
36
|
|
|
36
37
|
devDependencies: {
|
|
37
|
-
'@happyvertical/smrt-cli': '^0.
|
|
38
|
+
'@happyvertical/smrt-cli': '^0.56.1',
|
|
38
39
|
'@sveltejs/adapter-node': '^5.5.7',
|
|
39
40
|
'@sveltejs/kit': '2.70.3',
|
|
40
41
|
'@sveltejs/vite-plugin-svelte': '^7.3.0',
|