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.
@@ -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
+ }