create-cs-object 0.1.0 → 0.1.2
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/README.md +6 -2
- package/dist/cli.js +29 -2
- package/package.json +5 -2
- package/template/AGENTS.md +6 -0
- package/template/README.md +16 -0
- package/template/authoring.md +179 -18
- package/template/components.json +25 -0
- package/template/gitignore +1 -0
- package/template/index.html +12 -0
- package/template/reports.json +8 -0
- package/template/requirements.txt +1 -1
- package/template/scripts/dev.ts +104 -33
- package/template/scripts/reports.ts +24 -0
- package/template/src/App.tsx +681 -0
- package/template/src/components/theme-provider.tsx +230 -0
- package/template/src/components/ui/badge.tsx +51 -0
- package/template/src/components/ui/button.tsx +55 -0
- package/template/src/components/ui/card.tsx +102 -0
- package/template/src/components/ui/dropdown-menu.tsx +274 -0
- package/template/src/components/ui/input.tsx +19 -0
- package/template/src/components/ui/label.tsx +17 -0
- package/template/src/components/ui/separator.tsx +22 -0
- package/template/src/components/ui/sheet.tsx +137 -0
- package/template/src/hooks/use-mobile.ts +19 -0
- package/template/src/index.css +131 -0
- package/template/src/lib/utils.ts +1 -0
- package/template/src/main.tsx +9 -0
- package/template/tsconfig.app.json +29 -0
- package/template/tsconfig.json +12 -0
- package/template/tsconfig.node.json +24 -0
- package/template/vite.config.ts +144 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CalculationDefinition,
|
|
3
|
+
CalculationDefinitionSchema,
|
|
4
|
+
} from '@cs-object/core';
|
|
5
|
+
import { type FormEvent, useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { Button } from '@/components/ui/button';
|
|
8
|
+
import {
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from '@/components/ui/dropdown-menu';
|
|
14
|
+
import { Input } from '@/components/ui/input';
|
|
15
|
+
import { Label } from '@/components/ui/label';
|
|
16
|
+
import {
|
|
17
|
+
Sheet,
|
|
18
|
+
SheetContent,
|
|
19
|
+
SheetHeader,
|
|
20
|
+
SheetTitle,
|
|
21
|
+
} from '@/components/ui/sheet';
|
|
22
|
+
|
|
23
|
+
const CatalogSchema = z.array(z.object({ id: z.string(), title: z.string() }));
|
|
24
|
+
type Report = z.infer<typeof CatalogSchema>[number];
|
|
25
|
+
|
|
26
|
+
const CheckSchema = z.object({ status: z.string() });
|
|
27
|
+
const RunSchema = z.object({
|
|
28
|
+
id: z.uuid(),
|
|
29
|
+
outputs: z.record(z.string(), z.number()),
|
|
30
|
+
checks: z.object({
|
|
31
|
+
sourceToDocumentConsistency: CheckSchema,
|
|
32
|
+
independentReferenceAgreement: CheckSchema,
|
|
33
|
+
rendering: CheckSchema,
|
|
34
|
+
visualInspection: CheckSchema,
|
|
35
|
+
}),
|
|
36
|
+
html: z.string(),
|
|
37
|
+
pdf: z.string(),
|
|
38
|
+
evidence: z.string(),
|
|
39
|
+
});
|
|
40
|
+
type Run = z.infer<typeof RunSchema>;
|
|
41
|
+
|
|
42
|
+
function apiPath(report: Report, route: string): string {
|
|
43
|
+
if (!route.startsWith('/api/'))
|
|
44
|
+
throw new Error('Unexpected calculation API path');
|
|
45
|
+
return `/api/reports/${encodeURIComponent(report.id)}${route.slice(4)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readJson(response: Response): Promise<unknown> {
|
|
49
|
+
const body: unknown = await response.json();
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const parsed = z
|
|
52
|
+
.object({
|
|
53
|
+
error: z.string(),
|
|
54
|
+
diagnostics: z.array(z.object({ message: z.string() })).optional(),
|
|
55
|
+
})
|
|
56
|
+
.safeParse(body);
|
|
57
|
+
throw new Error(
|
|
58
|
+
parsed.success
|
|
59
|
+
? [
|
|
60
|
+
parsed.data.error,
|
|
61
|
+
...(parsed.data.diagnostics ?? []).map(({ message }) => message),
|
|
62
|
+
].join('\n')
|
|
63
|
+
: `Request failed (${response.status})`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return body;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function numericText(value: number): string {
|
|
70
|
+
return Object.is(value, -0) ? '-0.0' : String(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function numericJson(value: number): string {
|
|
74
|
+
if (Object.is(value, -0)) return '-0.0';
|
|
75
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value))
|
|
76
|
+
return value.toExponential();
|
|
77
|
+
return String(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function requestBody(
|
|
81
|
+
definition: CalculationDefinition,
|
|
82
|
+
values: Record<string, string>,
|
|
83
|
+
): string {
|
|
84
|
+
const entries = definition.inputs.map((input) => {
|
|
85
|
+
const raw = values[input.name]?.trim() ?? '';
|
|
86
|
+
const number = Number(raw);
|
|
87
|
+
if (!raw || !Number.isFinite(number))
|
|
88
|
+
throw new Error(`Enter a finite value for ${input.name}`);
|
|
89
|
+
if (input.numericType === 'int' && !Number.isSafeInteger(number))
|
|
90
|
+
throw new Error(`${input.name} requires a safe integer`);
|
|
91
|
+
return `${JSON.stringify(input.name)}:${numericJson(number)}`;
|
|
92
|
+
});
|
|
93
|
+
return `{"inputs":{${entries.join(',')}}}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type ShellFragment = {
|
|
97
|
+
text: string;
|
|
98
|
+
kind: 'command' | 'option' | 'url' | 'string' | 'plain';
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const syntaxColor = {
|
|
102
|
+
command: 'font-semibold text-primary',
|
|
103
|
+
option: 'text-sky-700 dark:text-sky-300',
|
|
104
|
+
url: 'text-violet-700 dark:text-violet-300',
|
|
105
|
+
string: 'text-amber-700 dark:text-amber-300',
|
|
106
|
+
plain: 'text-foreground',
|
|
107
|
+
} satisfies Record<ShellFragment['kind'], string>;
|
|
108
|
+
|
|
109
|
+
function curlPreview(
|
|
110
|
+
report: Report | undefined,
|
|
111
|
+
definition: CalculationDefinition | undefined,
|
|
112
|
+
values: Record<string, string>,
|
|
113
|
+
):
|
|
114
|
+
| { kind: 'ready'; command: string; fragments: ShellFragment[] }
|
|
115
|
+
| { kind: 'unavailable'; message: string } {
|
|
116
|
+
if (!report || !definition)
|
|
117
|
+
return {
|
|
118
|
+
kind: 'unavailable',
|
|
119
|
+
message: 'Load a report to show the command.',
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
const body = requestBody(definition, values).replaceAll("'", "'\\''");
|
|
123
|
+
const fragments: ShellFragment[] = [
|
|
124
|
+
{ kind: 'command', text: 'curl' },
|
|
125
|
+
{ kind: 'plain', text: ' ' },
|
|
126
|
+
{ kind: 'option', text: '-sS --fail-with-body' },
|
|
127
|
+
{ kind: 'plain', text: ' \\\n ' },
|
|
128
|
+
{
|
|
129
|
+
kind: 'url',
|
|
130
|
+
text: `${location.origin}${apiPath(report, '/api/calculate')}`,
|
|
131
|
+
},
|
|
132
|
+
{ kind: 'plain', text: ' \\\n ' },
|
|
133
|
+
{ kind: 'option', text: '-H' },
|
|
134
|
+
{ kind: 'plain', text: ' ' },
|
|
135
|
+
{ kind: 'string', text: "'Content-Type: application/json'" },
|
|
136
|
+
{ kind: 'plain', text: ' \\\n ' },
|
|
137
|
+
{ kind: 'option', text: '-d' },
|
|
138
|
+
{ kind: 'plain', text: ' ' },
|
|
139
|
+
{ kind: 'string', text: `'${body}'` },
|
|
140
|
+
];
|
|
141
|
+
return {
|
|
142
|
+
kind: 'ready',
|
|
143
|
+
command: fragments.map(({ text }) => text).join(''),
|
|
144
|
+
fragments,
|
|
145
|
+
};
|
|
146
|
+
} catch {
|
|
147
|
+
return {
|
|
148
|
+
kind: 'unavailable',
|
|
149
|
+
message: 'Enter valid input values to show the command.',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const visibleChecks = [
|
|
155
|
+
['sourceToDocumentConsistency', 'Source-to-document'],
|
|
156
|
+
['independentReferenceAgreement', 'Independent reference'],
|
|
157
|
+
['rendering', 'PDF layout'],
|
|
158
|
+
['visualInspection', 'Human inspection'],
|
|
159
|
+
] as const satisfies ReadonlyArray<readonly [keyof Run['checks'], string]>;
|
|
160
|
+
|
|
161
|
+
function checkText(status: string | undefined): string {
|
|
162
|
+
if (status === undefined) return 'Not run';
|
|
163
|
+
if (status === 'passed') return 'Passed';
|
|
164
|
+
if (status === 'failed') return 'Failed';
|
|
165
|
+
if (status === 'pending' || status === 'not_applicable') return 'WIP';
|
|
166
|
+
return status;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const previewStyles = `
|
|
170
|
+
@media screen {
|
|
171
|
+
html, body { min-height: 100%; background: #fff; }
|
|
172
|
+
body.cso-standalone-report [data-formula-sheet-print-root] {
|
|
173
|
+
box-sizing: border-box;
|
|
174
|
+
width: 100%;
|
|
175
|
+
min-height: 141.4286vw;
|
|
176
|
+
margin: 0;
|
|
177
|
+
padding: 10mm;
|
|
178
|
+
background: #fff;
|
|
179
|
+
}
|
|
180
|
+
body.cso-standalone-report [data-formula-sheet] {
|
|
181
|
+
box-sizing: border-box;
|
|
182
|
+
width: 100%;
|
|
183
|
+
min-width: 0;
|
|
184
|
+
padding: 0;
|
|
185
|
+
margin: 0;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
function ReportPreview({ htmlUrl }: { htmlUrl?: string }) {
|
|
191
|
+
const [html, setHtml] = useState<string>();
|
|
192
|
+
const [error, setError] = useState<string>();
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (!htmlUrl) {
|
|
196
|
+
setHtml(undefined);
|
|
197
|
+
setError(undefined);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const controller = new AbortController();
|
|
201
|
+
setHtml(undefined);
|
|
202
|
+
setError(undefined);
|
|
203
|
+
void (async () => {
|
|
204
|
+
try {
|
|
205
|
+
const response = await fetch(htmlUrl, { signal: controller.signal });
|
|
206
|
+
if (!response.ok)
|
|
207
|
+
throw new Error(`Preview failed (${response.status})`);
|
|
208
|
+
const document = new DOMParser().parseFromString(
|
|
209
|
+
await response.text(),
|
|
210
|
+
'text/html',
|
|
211
|
+
);
|
|
212
|
+
const style = document.createElement('style');
|
|
213
|
+
style.textContent = previewStyles;
|
|
214
|
+
document.head.append(style);
|
|
215
|
+
if (!controller.signal.aborted)
|
|
216
|
+
setHtml(`<!doctype html>${document.documentElement.outerHTML}`);
|
|
217
|
+
} catch (cause) {
|
|
218
|
+
if (!controller.signal.aborted)
|
|
219
|
+
setError(cause instanceof Error ? cause.message : 'Preview failed');
|
|
220
|
+
}
|
|
221
|
+
})();
|
|
222
|
+
return () => controller.abort();
|
|
223
|
+
}, [htmlUrl]);
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<section
|
|
227
|
+
aria-label="Formula sheet preview"
|
|
228
|
+
className="flex min-h-[calc(100dvh-4rem)] min-w-0 justify-center overflow-x-auto bg-muted/40 p-4 sm:p-8"
|
|
229
|
+
>
|
|
230
|
+
{html ? (
|
|
231
|
+
<iframe
|
|
232
|
+
id="report"
|
|
233
|
+
title="Formula sheet"
|
|
234
|
+
sandbox=""
|
|
235
|
+
src={htmlUrl}
|
|
236
|
+
srcDoc={html}
|
|
237
|
+
className="block aspect-[210/297] h-auto w-full max-w-[794px] shrink-0 border-0 bg-white shadow-lg"
|
|
238
|
+
/>
|
|
239
|
+
) : (
|
|
240
|
+
<div className="flex min-h-[600px] items-center text-sm text-muted-foreground">
|
|
241
|
+
{error ??
|
|
242
|
+
(htmlUrl
|
|
243
|
+
? 'Loading formula sheet…'
|
|
244
|
+
: 'Calculate to preview the report.')}
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
</section>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export default function App() {
|
|
252
|
+
const [reports, setReports] = useState<Report[]>([]);
|
|
253
|
+
const [selected, setSelected] = useState<Report>();
|
|
254
|
+
const [definition, setDefinition] = useState<CalculationDefinition>();
|
|
255
|
+
const [values, setValues] = useState<Record<string, string>>({});
|
|
256
|
+
const [run, setRun] = useState<Run>();
|
|
257
|
+
const [status, setStatus] = useState('Loading calculation…');
|
|
258
|
+
const [problem, setProblem] = useState<string>();
|
|
259
|
+
const [busy, setBusy] = useState(false);
|
|
260
|
+
const [activeSheet, setActiveSheet] = useState<'inputs' | 'api' | null>(null);
|
|
261
|
+
const [copyFeedback, setCopyFeedback] = useState<{
|
|
262
|
+
kind: 'copied' | 'failed';
|
|
263
|
+
command: string;
|
|
264
|
+
}>();
|
|
265
|
+
const revision = useRef(0);
|
|
266
|
+
const curl = curlPreview(selected, definition, values);
|
|
267
|
+
|
|
268
|
+
async function copyCurl() {
|
|
269
|
+
if (curl.kind !== 'ready') return;
|
|
270
|
+
try {
|
|
271
|
+
await navigator.clipboard.writeText(curl.command);
|
|
272
|
+
setCopyFeedback({ kind: 'copied', command: curl.command });
|
|
273
|
+
} catch {
|
|
274
|
+
setCopyFeedback({ kind: 'failed', command: curl.command });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function calculate(
|
|
279
|
+
report: Report,
|
|
280
|
+
activeDefinition: CalculationDefinition,
|
|
281
|
+
activeValues: Record<string, string>,
|
|
282
|
+
) {
|
|
283
|
+
const started = ++revision.current;
|
|
284
|
+
setBusy(true);
|
|
285
|
+
setRun(undefined);
|
|
286
|
+
setStatus('Calculating…');
|
|
287
|
+
setProblem(undefined);
|
|
288
|
+
try {
|
|
289
|
+
const next = RunSchema.parse(
|
|
290
|
+
await readJson(
|
|
291
|
+
await fetch(apiPath(report, '/api/runs'), {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers: { 'Content-Type': 'application/json' },
|
|
294
|
+
body: requestBody(activeDefinition, activeValues),
|
|
295
|
+
}),
|
|
296
|
+
),
|
|
297
|
+
);
|
|
298
|
+
if (started !== revision.current) return;
|
|
299
|
+
setRun(next);
|
|
300
|
+
setStatus('Report ready. Review its assumptions and results.');
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (started === revision.current) {
|
|
303
|
+
const message =
|
|
304
|
+
error instanceof Error ? error.message : 'Calculation failed';
|
|
305
|
+
setStatus(message);
|
|
306
|
+
setProblem(message);
|
|
307
|
+
}
|
|
308
|
+
} finally {
|
|
309
|
+
if (started === revision.current) setBusy(false);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
let active = true;
|
|
315
|
+
void (async () => {
|
|
316
|
+
try {
|
|
317
|
+
const catalog = CatalogSchema.parse(
|
|
318
|
+
await readJson(await fetch('/api/reports')),
|
|
319
|
+
);
|
|
320
|
+
if (!active) return;
|
|
321
|
+
setReports(catalog);
|
|
322
|
+
setSelected(catalog[0]);
|
|
323
|
+
if (catalog.length === 0) {
|
|
324
|
+
setStatus('No runnable reports are configured.');
|
|
325
|
+
setProblem('No runnable reports are configured.');
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (active) {
|
|
329
|
+
const message =
|
|
330
|
+
error instanceof Error ? error.message : 'Cannot load report list';
|
|
331
|
+
setStatus(message);
|
|
332
|
+
setProblem(message);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
})();
|
|
336
|
+
return () => {
|
|
337
|
+
active = false;
|
|
338
|
+
};
|
|
339
|
+
}, []);
|
|
340
|
+
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (!selected) return;
|
|
343
|
+
let active = true;
|
|
344
|
+
revision.current += 1;
|
|
345
|
+
setDefinition(undefined);
|
|
346
|
+
setValues({});
|
|
347
|
+
setRun(undefined);
|
|
348
|
+
setBusy(false);
|
|
349
|
+
setStatus(`Loading ${selected.title}…`);
|
|
350
|
+
setProblem(undefined);
|
|
351
|
+
void (async () => {
|
|
352
|
+
try {
|
|
353
|
+
const parsed = CalculationDefinitionSchema.parse(
|
|
354
|
+
await readJson(await fetch(apiPath(selected, '/api/definition'))),
|
|
355
|
+
);
|
|
356
|
+
if (!active) return;
|
|
357
|
+
setDefinition(parsed);
|
|
358
|
+
const defaults = Object.fromEntries(
|
|
359
|
+
parsed.inputs.map((input) => [
|
|
360
|
+
input.name,
|
|
361
|
+
input.default === undefined ? '' : numericText(input.default),
|
|
362
|
+
]),
|
|
363
|
+
);
|
|
364
|
+
setValues(defaults);
|
|
365
|
+
setStatus('Enter inputs and calculate.');
|
|
366
|
+
if (parsed.inputs.every((input) => input.default !== undefined))
|
|
367
|
+
void calculate(selected, parsed, defaults);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (active) {
|
|
370
|
+
const message =
|
|
371
|
+
error instanceof Error ? error.message : 'Cannot load calculation';
|
|
372
|
+
setStatus(message);
|
|
373
|
+
setProblem(message);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
})();
|
|
377
|
+
return () => {
|
|
378
|
+
active = false;
|
|
379
|
+
revision.current += 1;
|
|
380
|
+
};
|
|
381
|
+
}, [selected]);
|
|
382
|
+
|
|
383
|
+
function onSubmit(event: FormEvent) {
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
if (definition && selected) void calculate(selected, definition, values);
|
|
386
|
+
}
|
|
387
|
+
function edit(name: string, value: string) {
|
|
388
|
+
revision.current += 1;
|
|
389
|
+
setValues((current) => ({ ...current, [name]: value }));
|
|
390
|
+
setRun(undefined);
|
|
391
|
+
setBusy(false);
|
|
392
|
+
setStatus('Inputs changed. Calculate again to update the report.');
|
|
393
|
+
setProblem(undefined);
|
|
394
|
+
}
|
|
395
|
+
async function downloadPdf() {
|
|
396
|
+
if (!run || !selected) return;
|
|
397
|
+
const current = run;
|
|
398
|
+
const report = selected;
|
|
399
|
+
const started = revision.current;
|
|
400
|
+
const refreshChecks = async () => {
|
|
401
|
+
const latest = await readJson(
|
|
402
|
+
await fetch(apiPath(report, `/api/runs/${current.id}`)),
|
|
403
|
+
);
|
|
404
|
+
const checks = RunSchema.omit({
|
|
405
|
+
html: true,
|
|
406
|
+
pdf: true,
|
|
407
|
+
evidence: true,
|
|
408
|
+
}).parse(latest).checks;
|
|
409
|
+
if (started === revision.current) setRun({ ...current, checks });
|
|
410
|
+
};
|
|
411
|
+
setBusy(true);
|
|
412
|
+
setStatus('Preparing PDF…');
|
|
413
|
+
setProblem(undefined);
|
|
414
|
+
try {
|
|
415
|
+
const response = await fetch(current.pdf);
|
|
416
|
+
if (!response.ok) await readJson(response);
|
|
417
|
+
const url = URL.createObjectURL(await response.blob());
|
|
418
|
+
if (started !== revision.current) {
|
|
419
|
+
URL.revokeObjectURL(url);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const link = document.createElement('a');
|
|
423
|
+
link.href = url;
|
|
424
|
+
link.download = 'calculation.pdf';
|
|
425
|
+
link.click();
|
|
426
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
427
|
+
await refreshChecks();
|
|
428
|
+
if (started !== revision.current) return;
|
|
429
|
+
setStatus('PDF ready. Human review remains pending.');
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (started === revision.current) {
|
|
432
|
+
try {
|
|
433
|
+
await refreshChecks();
|
|
434
|
+
} catch {
|
|
435
|
+
// Keep the original PDF error if the run is unavailable.
|
|
436
|
+
}
|
|
437
|
+
if (started === revision.current) {
|
|
438
|
+
const message = error instanceof Error ? error.message : 'PDF failed';
|
|
439
|
+
setStatus(message);
|
|
440
|
+
setProblem(message);
|
|
441
|
+
setActiveSheet('inputs');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
} finally {
|
|
445
|
+
if (started === revision.current) setBusy(false);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<main className="min-h-screen bg-background">
|
|
451
|
+
<header className="sticky top-0 z-60 flex min-h-16 min-w-0 flex-wrap items-center gap-x-3 gap-y-1 border-b bg-background px-4 py-2 sm:flex-nowrap sm:px-6 sm:py-0">
|
|
452
|
+
<span className="shrink-0 font-heading text-lg font-semibold">CSO</span>
|
|
453
|
+
<DropdownMenu>
|
|
454
|
+
<DropdownMenuTrigger
|
|
455
|
+
render={
|
|
456
|
+
<Button
|
|
457
|
+
variant="ghost"
|
|
458
|
+
aria-label="Choose report"
|
|
459
|
+
disabled={reports.length === 0}
|
|
460
|
+
className="min-w-0 max-w-[calc(100vw-6rem)] justify-between gap-2 sm:max-w-[min(42vw,24rem)]"
|
|
461
|
+
/>
|
|
462
|
+
}
|
|
463
|
+
>
|
|
464
|
+
<span className="truncate">
|
|
465
|
+
{selected?.title ?? 'Select report'}
|
|
466
|
+
</span>
|
|
467
|
+
<span aria-hidden="true" className="text-muted-foreground">
|
|
468
|
+
⌄
|
|
469
|
+
</span>
|
|
470
|
+
</DropdownMenuTrigger>
|
|
471
|
+
<DropdownMenuContent
|
|
472
|
+
align="start"
|
|
473
|
+
sideOffset={16}
|
|
474
|
+
className="w-max! min-w-64! max-w-[calc(100vw-2rem)] bg-popover!"
|
|
475
|
+
>
|
|
476
|
+
{reports.map((report) => (
|
|
477
|
+
<DropdownMenuItem
|
|
478
|
+
key={report.id}
|
|
479
|
+
onClick={() => {
|
|
480
|
+
setSelected(report);
|
|
481
|
+
setActiveSheet(null);
|
|
482
|
+
}}
|
|
483
|
+
>
|
|
484
|
+
{report.title}
|
|
485
|
+
</DropdownMenuItem>
|
|
486
|
+
))}
|
|
487
|
+
</DropdownMenuContent>
|
|
488
|
+
</DropdownMenu>
|
|
489
|
+
<div className="ml-auto flex w-full items-center justify-end gap-2 sm:w-auto sm:shrink-0">
|
|
490
|
+
<Button variant="ghost" onClick={() => setActiveSheet('api')}>
|
|
491
|
+
API
|
|
492
|
+
</Button>
|
|
493
|
+
<Button variant="outline" onClick={() => setActiveSheet('inputs')}>
|
|
494
|
+
Inputs
|
|
495
|
+
</Button>
|
|
496
|
+
<Button
|
|
497
|
+
id="pdf"
|
|
498
|
+
disabled={!run || busy}
|
|
499
|
+
onClick={() => void downloadPdf()}
|
|
500
|
+
>
|
|
501
|
+
{run?.checks.visualInspection.status === 'pending'
|
|
502
|
+
? 'PDF · review pending'
|
|
503
|
+
: 'PDF'}
|
|
504
|
+
</Button>
|
|
505
|
+
</div>
|
|
506
|
+
</header>
|
|
507
|
+
<p id="status" role="status" aria-live="polite" className="sr-only">
|
|
508
|
+
{status}
|
|
509
|
+
</p>
|
|
510
|
+
{problem && (
|
|
511
|
+
<p
|
|
512
|
+
id="problem"
|
|
513
|
+
role="alert"
|
|
514
|
+
className="mx-auto mt-4 max-w-3xl whitespace-pre-line rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
|
|
515
|
+
>
|
|
516
|
+
{problem}
|
|
517
|
+
</p>
|
|
518
|
+
)}
|
|
519
|
+
<dl
|
|
520
|
+
id="checks"
|
|
521
|
+
aria-label="Verification status"
|
|
522
|
+
className="flex flex-wrap gap-x-5 gap-y-1 border-b px-4 py-2 text-xs sm:px-6"
|
|
523
|
+
>
|
|
524
|
+
{visibleChecks.map(([key, label]) => {
|
|
525
|
+
const value = run?.checks[key].status;
|
|
526
|
+
return (
|
|
527
|
+
<div key={key} className="flex items-baseline gap-1">
|
|
528
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
529
|
+
<dd
|
|
530
|
+
className={
|
|
531
|
+
value === 'passed'
|
|
532
|
+
? 'font-medium text-primary'
|
|
533
|
+
: value === 'failed'
|
|
534
|
+
? 'font-medium text-destructive'
|
|
535
|
+
: 'text-muted-foreground'
|
|
536
|
+
}
|
|
537
|
+
>
|
|
538
|
+
{checkText(value)}
|
|
539
|
+
</dd>
|
|
540
|
+
</div>
|
|
541
|
+
);
|
|
542
|
+
})}
|
|
543
|
+
</dl>
|
|
544
|
+
<ReportPreview htmlUrl={run?.html} />
|
|
545
|
+
<Sheet
|
|
546
|
+
open={activeSheet !== null}
|
|
547
|
+
onOpenChange={(open) => {
|
|
548
|
+
if (!open) setActiveSheet(null);
|
|
549
|
+
}}
|
|
550
|
+
modal={false}
|
|
551
|
+
disablePointerDismissal
|
|
552
|
+
>
|
|
553
|
+
<SheetContent
|
|
554
|
+
side="right"
|
|
555
|
+
showOverlay={false}
|
|
556
|
+
className="top-24! h-[calc(100dvh-6rem)]! w-[min(92vw,24rem)] sm:top-16! sm:h-[calc(100dvh-4rem)]!"
|
|
557
|
+
>
|
|
558
|
+
<SheetHeader className="shrink-0 border-b pr-14">
|
|
559
|
+
<SheetTitle>
|
|
560
|
+
{activeSheet === 'api' ? 'API details' : 'Inputs'}
|
|
561
|
+
</SheetTitle>
|
|
562
|
+
</SheetHeader>
|
|
563
|
+
<div
|
|
564
|
+
id="calculation-sheet-scroll"
|
|
565
|
+
className="min-h-0 flex-1 space-y-5 overflow-y-auto px-4 py-5"
|
|
566
|
+
>
|
|
567
|
+
{activeSheet === 'api' ? (
|
|
568
|
+
<section aria-label="Terminal command" className="space-y-3">
|
|
569
|
+
<p className="text-xs text-muted-foreground">
|
|
570
|
+
Run this command in your terminal. The response contains only
|
|
571
|
+
the calculation outputs.
|
|
572
|
+
</p>
|
|
573
|
+
<div className="overflow-hidden rounded-md border bg-muted/30">
|
|
574
|
+
<div className="flex items-center justify-between border-b px-3 py-2">
|
|
575
|
+
<span className="font-mono text-xs text-muted-foreground">
|
|
576
|
+
bash
|
|
577
|
+
</span>
|
|
578
|
+
<Button
|
|
579
|
+
id="copy-curl"
|
|
580
|
+
type="button"
|
|
581
|
+
size="sm"
|
|
582
|
+
variant="ghost"
|
|
583
|
+
disabled={curl.kind !== 'ready'}
|
|
584
|
+
onClick={() => void copyCurl()}
|
|
585
|
+
>
|
|
586
|
+
{curl.kind === 'ready' &&
|
|
587
|
+
copyFeedback?.command === curl.command
|
|
588
|
+
? copyFeedback.kind === 'copied'
|
|
589
|
+
? 'Copied'
|
|
590
|
+
: 'Retry copy'
|
|
591
|
+
: 'Copy'}
|
|
592
|
+
</Button>
|
|
593
|
+
</div>
|
|
594
|
+
<pre
|
|
595
|
+
id="curl"
|
|
596
|
+
className="whitespace-pre-wrap break-all px-3 py-4 font-mono text-xs leading-6"
|
|
597
|
+
>
|
|
598
|
+
{curl.kind === 'ready'
|
|
599
|
+
? curl.fragments.map((fragment, index) => (
|
|
600
|
+
<span
|
|
601
|
+
key={`${fragment.kind}-${index}`}
|
|
602
|
+
data-syntax={fragment.kind}
|
|
603
|
+
className={syntaxColor[fragment.kind]}
|
|
604
|
+
>
|
|
605
|
+
{fragment.text}
|
|
606
|
+
</span>
|
|
607
|
+
))
|
|
608
|
+
: curl.message}
|
|
609
|
+
</pre>
|
|
610
|
+
</div>
|
|
611
|
+
{curl.kind === 'ready' &&
|
|
612
|
+
copyFeedback?.command === curl.command &&
|
|
613
|
+
copyFeedback.kind === 'failed' && (
|
|
614
|
+
<p role="alert" className="text-xs text-destructive">
|
|
615
|
+
Copy failed. Select the command to copy it manually.
|
|
616
|
+
</p>
|
|
617
|
+
)}
|
|
618
|
+
</section>
|
|
619
|
+
) : (
|
|
620
|
+
<>
|
|
621
|
+
<section
|
|
622
|
+
aria-labelledby="parameters-title"
|
|
623
|
+
className="space-y-4"
|
|
624
|
+
>
|
|
625
|
+
<h2
|
|
626
|
+
id="parameters-title"
|
|
627
|
+
className="font-heading text-sm font-medium"
|
|
628
|
+
>
|
|
629
|
+
Parameters
|
|
630
|
+
</h2>
|
|
631
|
+
<form id="inputs" onSubmit={onSubmit} className="space-y-4">
|
|
632
|
+
<div id="fields" className="space-y-4">
|
|
633
|
+
{definition?.inputs.map((input) => (
|
|
634
|
+
<div key={input.name} className="space-y-2">
|
|
635
|
+
<Label htmlFor={`input-${input.name}`}>
|
|
636
|
+
{input.description}{' '}
|
|
637
|
+
{input.unit && `(${input.unit})`}
|
|
638
|
+
</Label>
|
|
639
|
+
<Input
|
|
640
|
+
id={`input-${input.name}`}
|
|
641
|
+
name={input.name}
|
|
642
|
+
type="number"
|
|
643
|
+
step={input.numericType === 'int' ? '1' : 'any'}
|
|
644
|
+
required
|
|
645
|
+
value={values[input.name] ?? ''}
|
|
646
|
+
onChange={(event) =>
|
|
647
|
+
edit(input.name, event.target.value)
|
|
648
|
+
}
|
|
649
|
+
/>
|
|
650
|
+
</div>
|
|
651
|
+
))}
|
|
652
|
+
</div>
|
|
653
|
+
<Button
|
|
654
|
+
id="calculate"
|
|
655
|
+
type="submit"
|
|
656
|
+
className="w-full"
|
|
657
|
+
disabled={!definition || busy}
|
|
658
|
+
>
|
|
659
|
+
{busy ? 'Calculating…' : 'Calculate'}
|
|
660
|
+
</Button>
|
|
661
|
+
</form>
|
|
662
|
+
</section>
|
|
663
|
+
<a
|
|
664
|
+
id="evidence"
|
|
665
|
+
hidden={
|
|
666
|
+
!run || run.checks.rendering.status === 'not_applicable'
|
|
667
|
+
}
|
|
668
|
+
className="text-xs text-muted-foreground underline [hidden]:hidden"
|
|
669
|
+
href={run && selected ? run.evidence : undefined}
|
|
670
|
+
download
|
|
671
|
+
>
|
|
672
|
+
Download evidence
|
|
673
|
+
</a>
|
|
674
|
+
</>
|
|
675
|
+
)}
|
|
676
|
+
</div>
|
|
677
|
+
</SheetContent>
|
|
678
|
+
</Sheet>
|
|
679
|
+
</main>
|
|
680
|
+
);
|
|
681
|
+
}
|