@primocaredentgroup/dental-digital-intake-ui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2567 @@
1
+ // src/components/DigitalIntakeDashboard.tsx
2
+ import { useState } from "react";
3
+
4
+ // src/components/StatusBadge.tsx
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ var map = {
7
+ draft: "#94a3b8",
8
+ waiting_files: "#38bdf8",
9
+ uploaded: "#818cf8",
10
+ parsing: "#fbbf24",
11
+ needs_review: "#fb923c",
12
+ classified: "#34d399",
13
+ linked: "#22d3ee",
14
+ validated: "#4ade80",
15
+ ready_for_next_step: "#a78bfa",
16
+ archived: "#64748b",
17
+ failed: "#f87171"
18
+ };
19
+ function StatusBadge({ status }) {
20
+ const c = map[status] ?? "#94a3b8";
21
+ return /* @__PURE__ */ jsxs(
22
+ "span",
23
+ {
24
+ className: "badge",
25
+ style: {
26
+ background: `${c}22`,
27
+ color: "#f8fafc",
28
+ border: `1px solid ${c}55`
29
+ },
30
+ children: [
31
+ /* @__PURE__ */ jsx(
32
+ "span",
33
+ {
34
+ style: {
35
+ width: 7,
36
+ height: 7,
37
+ borderRadius: "50%",
38
+ background: c,
39
+ display: "inline-block"
40
+ }
41
+ }
42
+ ),
43
+ status.replace(/_/g, " ")
44
+ ]
45
+ }
46
+ );
47
+ }
48
+ function SmallBadge({
49
+ children,
50
+ variant
51
+ }) {
52
+ const cls = variant === "format" ? "badge-format" : "badge-role";
53
+ return /* @__PURE__ */ jsx("span", { className: `badge ${cls}`, children });
54
+ }
55
+
56
+ // src/lib/mockHostContext.ts
57
+ var hostAppLabel = (h) => ({
58
+ primoupcore: "PrimoUP Core",
59
+ primolabcore: "PrimoLab Core",
60
+ standalone: "Standalone"
61
+ })[h] ?? h;
62
+ var originLabel = (o) => ({
63
+ clinic_intraoral: "Clinica \xB7 intraorale",
64
+ lab_bench_scan: "Laboratorio \xB7 da banco",
65
+ unknown: "Origine non determinata"
66
+ })[o] ?? o;
67
+ var vendorLabel = (v) => ({
68
+ "3shape": "3Shape",
69
+ itero: "iTero",
70
+ carestream: "Carestream",
71
+ sirona: "Sirona / Elios",
72
+ exocad: "Exocad",
73
+ unknown: "Vendor sconosciuto"
74
+ })[v] ?? v;
75
+
76
+ // src/lib/validationUi.ts
77
+ function defaultProfileForOriginUi(origin) {
78
+ if (origin === "clinic_intraoral") return "clinic_intraoral_default";
79
+ if (origin === "lab_bench_scan") return "lab_bench_scan_default";
80
+ return "generic_default";
81
+ }
82
+ var validationProfileLabel = {
83
+ clinic_intraoral_default: "Clinica intraorale (default)",
84
+ lab_bench_scan_default: "Laboratorio / scansione da banco (default)",
85
+ generic_default: "Generico (default)"
86
+ };
87
+ var readinessLabelUi = {
88
+ ready_for_lab: "Ready for Lab",
89
+ ready_for_cad: "Ready for CAD",
90
+ ready_for_next_step: "Ready for Next Step"
91
+ };
92
+
93
+ // src/components/DigitalIntakeDashboard.tsx
94
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
95
+ var defaultDashboardFilters = () => ({
96
+ status: "",
97
+ origin: "",
98
+ vendor: "",
99
+ hostApp: "",
100
+ validationProfile: ""
101
+ });
102
+ function DigitalIntakeDashboard({
103
+ acquisitions,
104
+ filters,
105
+ onFiltersChange,
106
+ onOpen,
107
+ onCreateOpen,
108
+ onSeedDemo
109
+ }) {
110
+ const [seedBusy, setSeedBusy] = useState(false);
111
+ const set = (patch) => onFiltersChange({ ...filters, ...patch });
112
+ const kpis = {
113
+ total: acquisitions?.length ?? 0,
114
+ review: acquisitions?.filter((a) => a.status === "needs_review").length ?? 0,
115
+ readyLab: acquisitions?.filter(
116
+ (a) => a.status === "ready_for_next_step" && a.validationResult?.readinessLabel === "ready_for_lab"
117
+ ).length ?? 0,
118
+ readyCad: acquisitions?.filter(
119
+ (a) => a.status === "ready_for_next_step" && a.validationResult?.readinessLabel === "ready_for_cad"
120
+ ).length ?? 0,
121
+ validationFailed: acquisitions?.filter(
122
+ (a) => a.validationResult != null && a.validationResult.isValid === false
123
+ ).length ?? 0,
124
+ failed: acquisitions?.filter((a) => a.status === "failed").length ?? 0
125
+ };
126
+ return /* @__PURE__ */ jsxs2("div", { children: [
127
+ /* @__PURE__ */ jsxs2("div", { className: "kpi-grid", children: [
128
+ /* @__PURE__ */ jsxs2("div", { className: "kpi", children: [
129
+ /* @__PURE__ */ jsx2("div", { className: "kpi-value", children: kpis.total }),
130
+ /* @__PURE__ */ jsx2("div", { className: "kpi-label", children: "Acquisizioni in lista" })
131
+ ] }),
132
+ /* @__PURE__ */ jsxs2("div", { className: "kpi", children: [
133
+ /* @__PURE__ */ jsx2("div", { className: "kpi-value", children: kpis.review }),
134
+ /* @__PURE__ */ jsx2("div", { className: "kpi-label", children: "Needs review" })
135
+ ] }),
136
+ /* @__PURE__ */ jsxs2("div", { className: "kpi", children: [
137
+ /* @__PURE__ */ jsx2("div", { className: "kpi-value", children: kpis.readyLab }),
138
+ /* @__PURE__ */ jsx2("div", { className: "kpi-label", children: "Ready for Lab" })
139
+ ] }),
140
+ /* @__PURE__ */ jsxs2("div", { className: "kpi", children: [
141
+ /* @__PURE__ */ jsx2("div", { className: "kpi-value", children: kpis.readyCad }),
142
+ /* @__PURE__ */ jsx2("div", { className: "kpi-label", children: "Ready for CAD" })
143
+ ] }),
144
+ /* @__PURE__ */ jsxs2("div", { className: "kpi", children: [
145
+ /* @__PURE__ */ jsx2("div", { className: "kpi-value", children: kpis.validationFailed }),
146
+ /* @__PURE__ */ jsx2("div", { className: "kpi-label", children: "Validazione fallita" })
147
+ ] }),
148
+ /* @__PURE__ */ jsxs2("div", { className: "kpi", children: [
149
+ /* @__PURE__ */ jsx2("div", { className: "kpi-value", children: kpis.failed }),
150
+ /* @__PURE__ */ jsx2("div", { className: "kpi-label", children: "Failed" })
151
+ ] })
152
+ ] }),
153
+ /* @__PURE__ */ jsxs2("div", { className: "panel", style: { marginBottom: "1.25rem" }, children: [
154
+ /* @__PURE__ */ jsx2("h3", { style: { marginTop: 0 }, children: "Filtri" }),
155
+ /* @__PURE__ */ jsxs2("div", { className: "field-grid", children: [
156
+ /* @__PURE__ */ jsxs2("div", { className: "field", children: [
157
+ /* @__PURE__ */ jsx2("label", { children: "Stato" }),
158
+ /* @__PURE__ */ jsxs2(
159
+ "select",
160
+ {
161
+ value: filters.status,
162
+ onChange: (e) => set({ status: e.target.value }),
163
+ children: [
164
+ /* @__PURE__ */ jsx2("option", { value: "", children: "Tutti" }),
165
+ /* @__PURE__ */ jsx2("option", { value: "draft", children: "draft" }),
166
+ /* @__PURE__ */ jsx2("option", { value: "waiting_files", children: "waiting_files" }),
167
+ /* @__PURE__ */ jsx2("option", { value: "uploaded", children: "uploaded" }),
168
+ /* @__PURE__ */ jsx2("option", { value: "parsing", children: "parsing" }),
169
+ /* @__PURE__ */ jsx2("option", { value: "needs_review", children: "needs_review" }),
170
+ /* @__PURE__ */ jsx2("option", { value: "classified", children: "classified" }),
171
+ /* @__PURE__ */ jsx2("option", { value: "linked", children: "linked" }),
172
+ /* @__PURE__ */ jsx2("option", { value: "validated", children: "validated" }),
173
+ /* @__PURE__ */ jsx2("option", { value: "ready_for_next_step", children: "ready_for_next_step" }),
174
+ /* @__PURE__ */ jsx2("option", { value: "archived", children: "archived" }),
175
+ /* @__PURE__ */ jsx2("option", { value: "failed", children: "failed" })
176
+ ]
177
+ }
178
+ )
179
+ ] }),
180
+ /* @__PURE__ */ jsxs2("div", { className: "field", children: [
181
+ /* @__PURE__ */ jsx2("label", { children: "Origine" }),
182
+ /* @__PURE__ */ jsxs2(
183
+ "select",
184
+ {
185
+ value: filters.origin,
186
+ onChange: (e) => set({ origin: e.target.value }),
187
+ children: [
188
+ /* @__PURE__ */ jsx2("option", { value: "", children: "Tutte" }),
189
+ /* @__PURE__ */ jsx2("option", { value: "clinic_intraoral", children: "clinic_intraoral" }),
190
+ /* @__PURE__ */ jsx2("option", { value: "lab_bench_scan", children: "lab_bench_scan" }),
191
+ /* @__PURE__ */ jsx2("option", { value: "unknown", children: "unknown" })
192
+ ]
193
+ }
194
+ )
195
+ ] }),
196
+ /* @__PURE__ */ jsxs2("div", { className: "field", children: [
197
+ /* @__PURE__ */ jsx2("label", { children: "Vendor" }),
198
+ /* @__PURE__ */ jsxs2(
199
+ "select",
200
+ {
201
+ value: filters.vendor,
202
+ onChange: (e) => set({ vendor: e.target.value }),
203
+ children: [
204
+ /* @__PURE__ */ jsx2("option", { value: "", children: "Tutti" }),
205
+ /* @__PURE__ */ jsx2("option", { value: "3shape", children: "3shape" }),
206
+ /* @__PURE__ */ jsx2("option", { value: "itero", children: "itero" }),
207
+ /* @__PURE__ */ jsx2("option", { value: "carestream", children: "carestream" }),
208
+ /* @__PURE__ */ jsx2("option", { value: "sirona", children: "sirona" }),
209
+ /* @__PURE__ */ jsx2("option", { value: "exocad", children: "exocad" }),
210
+ /* @__PURE__ */ jsx2("option", { value: "unknown", children: "unknown" })
211
+ ]
212
+ }
213
+ )
214
+ ] }),
215
+ /* @__PURE__ */ jsxs2("div", { className: "field", children: [
216
+ /* @__PURE__ */ jsx2("label", { children: "Host app" }),
217
+ /* @__PURE__ */ jsxs2(
218
+ "select",
219
+ {
220
+ value: filters.hostApp,
221
+ onChange: (e) => set({ hostApp: e.target.value }),
222
+ children: [
223
+ /* @__PURE__ */ jsx2("option", { value: "", children: "Tutte" }),
224
+ /* @__PURE__ */ jsx2("option", { value: "primoupcore", children: "primoupcore" }),
225
+ /* @__PURE__ */ jsx2("option", { value: "primolabcore", children: "primolabcore" }),
226
+ /* @__PURE__ */ jsx2("option", { value: "standalone", children: "standalone" })
227
+ ]
228
+ }
229
+ )
230
+ ] }),
231
+ /* @__PURE__ */ jsxs2("div", { className: "field", children: [
232
+ /* @__PURE__ */ jsx2("label", { children: "Profilo validazione" }),
233
+ /* @__PURE__ */ jsxs2(
234
+ "select",
235
+ {
236
+ value: filters.validationProfile,
237
+ onChange: (e) => set({ validationProfile: e.target.value }),
238
+ children: [
239
+ /* @__PURE__ */ jsx2("option", { value: "", children: "Tutti" }),
240
+ /* @__PURE__ */ jsx2("option", { value: "clinic_intraoral_default", children: validationProfileLabel.clinic_intraoral_default }),
241
+ /* @__PURE__ */ jsx2("option", { value: "lab_bench_scan_default", children: validationProfileLabel.lab_bench_scan_default }),
242
+ /* @__PURE__ */ jsx2("option", { value: "generic_default", children: validationProfileLabel.generic_default })
243
+ ]
244
+ }
245
+ )
246
+ ] })
247
+ ] })
248
+ ] }),
249
+ /* @__PURE__ */ jsxs2("div", { className: "btn-row", style: { marginBottom: "1rem" }, children: [
250
+ onCreateOpen ? /* @__PURE__ */ jsx2(
251
+ "button",
252
+ {
253
+ type: "button",
254
+ className: "btn btn-primary",
255
+ onClick: () => onCreateOpen(),
256
+ children: "Nuova acquisizione"
257
+ }
258
+ ) : null,
259
+ onSeedDemo ? /* @__PURE__ */ jsx2(
260
+ "button",
261
+ {
262
+ type: "button",
263
+ className: "btn btn-ghost",
264
+ disabled: seedBusy,
265
+ onClick: () => {
266
+ setSeedBusy(true);
267
+ void onSeedDemo().catch(() => {
268
+ }).finally(() => setSeedBusy(false));
269
+ },
270
+ children: "Carica dati demo"
271
+ }
272
+ ) : null
273
+ ] }),
274
+ !acquisitions ? /* @__PURE__ */ jsx2("p", { className: "muted", children: "Caricamento\u2026" }) : /* @__PURE__ */ jsx2("div", { className: "acq-grid", children: acquisitions.map((a) => /* @__PURE__ */ jsxs2(
275
+ "button",
276
+ {
277
+ type: "button",
278
+ className: "acq-card",
279
+ onClick: () => onOpen(a._id),
280
+ children: [
281
+ /* @__PURE__ */ jsx2("div", { className: "btn-row", style: { marginBottom: "0.5rem" }, children: /* @__PURE__ */ jsx2(StatusBadge, { status: a.status }) }),
282
+ /* @__PURE__ */ jsx2("div", { className: "acq-card-title", children: a.title }),
283
+ /* @__PURE__ */ jsxs2("p", { className: "acq-card-sub", children: [
284
+ originLabel(a.origin),
285
+ " \xB7 ",
286
+ hostAppLabel(a.hostApp)
287
+ ] }),
288
+ /* @__PURE__ */ jsxs2("p", { className: "acq-card-sub", children: [
289
+ "Vendor: ",
290
+ vendorLabel(a.sourceVendor),
291
+ " (",
292
+ a.vendorConfidence.toFixed(2),
293
+ ")"
294
+ ] }),
295
+ /* @__PURE__ */ jsxs2("p", { className: "acq-card-sub", children: [
296
+ "Profilo validazione:",
297
+ " ",
298
+ /* @__PURE__ */ jsx2("code", { style: { fontSize: "0.8rem" }, children: a.validationProfile ?? "\u2014" })
299
+ ] })
300
+ ]
301
+ },
302
+ a._id
303
+ )) })
304
+ ] });
305
+ }
306
+
307
+ // src/components/DigitalAcquisitionDetail.tsx
308
+ import { useMemo, useState as useState5 } from "react";
309
+
310
+ // src/lib/meshFormats.ts
311
+ var MESH_VIEWER_FORMATS = ["stl", "ply", "obj"];
312
+ function isMeshViewerFormat(format) {
313
+ const f = format.toLowerCase();
314
+ return MESH_VIEWER_FORMATS.includes(f);
315
+ }
316
+ var MESH_VIEWER_LARGE_BYTES = 250 * 1024 * 1024;
317
+
318
+ // src/components/FileList.tsx
319
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
320
+ function FileList({
321
+ files,
322
+ onVisualizeMesh,
323
+ renderFileActions
324
+ }) {
325
+ if (!files?.length) return /* @__PURE__ */ jsx3("p", { className: "muted", children: "Nessun file caricato." });
326
+ return /* @__PURE__ */ jsx3("div", { children: files.map((f) => /* @__PURE__ */ jsxs3("div", { className: "file-row", children: [
327
+ /* @__PURE__ */ jsxs3("div", { className: "file-row-main", style: { flex: "1 1 200px" }, children: [
328
+ /* @__PURE__ */ jsx3("div", { style: { fontWeight: 600, wordBreak: "break-word" }, children: f.originalName }),
329
+ /* @__PURE__ */ jsxs3("div", { className: "muted file-row-path", style: { fontSize: "0.8rem" }, children: [
330
+ f.originalPath,
331
+ f.extractedFromZip && f.extractedPath ? /* @__PURE__ */ jsxs3("span", { children: [
332
+ " ",
333
+ "\xB7 da ZIP: ",
334
+ /* @__PURE__ */ jsx3("code", { children: f.extractedPath })
335
+ ] }) : null
336
+ ] }),
337
+ f.warnings?.length ? /* @__PURE__ */ jsx3("div", { className: "warning-box", style: { marginTop: "0.35rem" }, children: f.warnings.join(" \xB7 ") }) : null
338
+ ] }),
339
+ /* @__PURE__ */ jsx3(SmallBadge, { variant: "format", children: f.format }),
340
+ /* @__PURE__ */ jsx3(SmallBadge, { variant: "role", children: f.role }),
341
+ /* @__PURE__ */ jsxs3("span", { className: "muted", style: { fontSize: "0.8rem" }, children: [
342
+ (f.sizeBytes / 1024).toFixed(1),
343
+ " KB"
344
+ ] }),
345
+ /* @__PURE__ */ jsx3("span", { className: "muted", style: { fontSize: "0.75rem" }, children: f.classificationSource }),
346
+ renderFileActions ? renderFileActions(f) : null,
347
+ onVisualizeMesh && isMeshViewerFormat(f.format) && f.storageId ? /* @__PURE__ */ jsx3(
348
+ "button",
349
+ {
350
+ type: "button",
351
+ className: "btn btn-ghost",
352
+ style: { fontSize: "0.75rem", padding: "0.25rem 0.5rem" },
353
+ onClick: () => onVisualizeMesh(f),
354
+ children: "Visualizza"
355
+ }
356
+ ) : null
357
+ ] }, f._id)) });
358
+ }
359
+
360
+ // src/components/UploadPanel.tsx
361
+ import { useState as useState2 } from "react";
362
+ import { extractZipSafely, collectCadMetadataFromZipEntries } from "@primocaredentgroup/dental-digital-intake-shared";
363
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
364
+ function guessContentType(extension) {
365
+ const e = extension.toLowerCase();
366
+ if (e === "stl") return "model/stl";
367
+ if (e === "ply") return "application/octet-stream";
368
+ if (e === "obj") return "text/plain";
369
+ if (e === "jpg" || e === "jpeg") return "image/jpeg";
370
+ if (e === "png") return "image/png";
371
+ if (e === "pdf") return "application/pdf";
372
+ if (e === "json") return "application/json";
373
+ if (e === "xml") return "application/xml";
374
+ if (e === "txt") return "text/plain";
375
+ if (e === "dcm") return "application/dicom";
376
+ return void 0;
377
+ }
378
+ function UploadPanel({
379
+ upload
380
+ }) {
381
+ const [busy, setBusy] = useState2(false);
382
+ const [err, setErr] = useState2(null);
383
+ const [status, setStatus] = useState2(null);
384
+ const [dragOver, setDragOver] = useState2(false);
385
+ const uploadZipViaBrowser = async (file) => {
386
+ setErr(null);
387
+ setBusy(true);
388
+ setStatus(`Lettura ed estrazione ZIP in locale (${file.name})\u2026`);
389
+ try {
390
+ const buf = new Uint8Array(await file.arrayBuffer());
391
+ const extracted = extractZipSafely(buf);
392
+ if (!extracted.ok) {
393
+ setErr(extracted.fatal);
394
+ setStatus(null);
395
+ return;
396
+ }
397
+ const entries = extracted.entries;
398
+ setStatus(
399
+ `${entries.length} file da caricare (estrazione browser). Upload in corso\u2026`
400
+ );
401
+ const urls = await upload.generateBulkPackageUploadUrls(entries.length);
402
+ if (urls.length !== entries.length) {
403
+ throw new Error("Numero di URL di upload incoerente con i file estratti.");
404
+ }
405
+ const filesPayload = [];
406
+ for (let i = 0; i < entries.length; i++) {
407
+ const eEntry = entries[i];
408
+ setStatus(`Upload ${i + 1}/${entries.length}: ${eEntry.safePath}\u2026`);
409
+ const ct = guessContentType(eEntry.extension);
410
+ const init = {
411
+ method: "POST",
412
+ body: new Blob([eEntry.data])
413
+ };
414
+ if (ct) {
415
+ init.headers = { "Content-Type": ct };
416
+ }
417
+ const uploadRes = await fetch(urls[i], init);
418
+ if (!uploadRes.ok) {
419
+ throw new Error(
420
+ `Upload fallito per ${eEntry.safePath} (HTTP ${uploadRes.status}).`
421
+ );
422
+ }
423
+ const body = await uploadRes.json();
424
+ if (!body.storageId) {
425
+ throw new Error(`Risposta storage senza storageId per ${eEntry.safePath}.`);
426
+ }
427
+ const baseName = eEntry.safePath.split("/").pop() ?? eEntry.safePath;
428
+ filesPayload.push({
429
+ storageId: body.storageId,
430
+ originalName: baseName,
431
+ extractedPath: eEntry.safePath,
432
+ extension: eEntry.extension,
433
+ sizeBytes: eEntry.data.byteLength,
434
+ contentType: ct
435
+ });
436
+ }
437
+ setStatus("Registrazione file sul server\u2026");
438
+ const cadProjectMetadata = collectCadMetadataFromZipEntries(
439
+ entries.map((e) => ({ safePath: e.safePath, data: e.data }))
440
+ );
441
+ await upload.registerClientExtractedZipFiles({
442
+ originalZipFileName: file.name,
443
+ zipWarnings: extracted.warnings,
444
+ ...cadProjectMetadata ? { cadProjectMetadata } : {},
445
+ files: filesPayload
446
+ });
447
+ setStatus(
448
+ "ZIP processato nel browser e registrato: parsing avviato sul server."
449
+ );
450
+ } catch (e) {
451
+ setErr(e instanceof Error ? e.message : "Errore durante l'elaborazione ZIP.");
452
+ setStatus(null);
453
+ } finally {
454
+ setBusy(false);
455
+ }
456
+ };
457
+ const uploadSingleOrServerSmallZip = async (file) => {
458
+ setErr(null);
459
+ setBusy(true);
460
+ setStatus(`Caricamento su storage\u2026 (${file.name})`);
461
+ try {
462
+ const postUrl = await upload.generatePackageUploadUrl();
463
+ const headers = file.type ? { "Content-Type": file.type } : {};
464
+ const uploadRes = await fetch(postUrl, {
465
+ method: "POST",
466
+ headers,
467
+ body: file
468
+ });
469
+ if (!uploadRes.ok) {
470
+ throw new Error(
471
+ `Upload verso storage fallito (HTTP ${uploadRes.status}).`
472
+ );
473
+ }
474
+ const body = await uploadRes.json();
475
+ if (!body.storageId) {
476
+ throw new Error("Risposta storage senza storageId.");
477
+ }
478
+ setStatus("Registrazione pacchetto lato server\u2026");
479
+ const result = await upload.registerUploadedPackage({
480
+ storageId: body.storageId,
481
+ originalFileName: file.name,
482
+ declaredContentType: file.type || void 0
483
+ });
484
+ if (result.kind === "zip") {
485
+ setStatus(
486
+ "ZIP piccolo: estrazione sul backend in corso (aggiornamento automatico)."
487
+ );
488
+ } else if (result.kind === "zip_too_large") {
489
+ setErr(
490
+ "Questo ZIP supera il limite dell\u2019estrazione server: usa sempre la modalit\xE0 browser (trascina di nuovo il file)."
491
+ );
492
+ setStatus(null);
493
+ } else {
494
+ setStatus("File singolo registrato: parsing avviato sul server.");
495
+ }
496
+ } catch (e) {
497
+ setErr(e instanceof Error ? e.message : "Errore durante l'upload.");
498
+ setStatus(null);
499
+ } finally {
500
+ setBusy(false);
501
+ }
502
+ };
503
+ const uploadFile = async (file) => {
504
+ const lower = file.name.toLowerCase();
505
+ const isZip = lower.endsWith(".zip") || file.type !== "" && file.type.toLowerCase().includes("zip");
506
+ if (isZip) {
507
+ await uploadZipViaBrowser(file);
508
+ return;
509
+ }
510
+ await uploadSingleOrServerSmallZip(file);
511
+ };
512
+ const onInputChange = (e) => {
513
+ const file = e.target.files?.[0];
514
+ e.target.value = "";
515
+ if (file) void uploadFile(file);
516
+ };
517
+ const onDrop = (e) => {
518
+ e.preventDefault();
519
+ setDragOver(false);
520
+ const file = e.dataTransfer.files?.[0];
521
+ if (file) void uploadFile(file);
522
+ };
523
+ return /* @__PURE__ */ jsxs4("div", { children: [
524
+ /* @__PURE__ */ jsx4("h3", { style: { marginTop: 0 }, children: "Upload reale (Convex storage)" }),
525
+ /* @__PURE__ */ jsxs4("p", { className: "muted", style: { marginTop: 0 }, children: [
526
+ "I file ",
527
+ /* @__PURE__ */ jsx4("strong", { children: ".zip" }),
528
+ " sono estratti nel ",
529
+ /* @__PURE__ */ jsx4("strong", { children: "browser" }),
530
+ " (RAM del tuo PC), poi ogni file operativo viene caricato su Convex: cos\xEC non si applica il limite ~64 MB delle action sullo ZIP completo. File singoli (.stl, .ply, \u2026) continuano a passare dall\u2019upload classico."
531
+ ] }),
532
+ err ? /* @__PURE__ */ jsx4("div", { className: "err-banner", children: err }) : null,
533
+ status ? /* @__PURE__ */ jsx4("div", { className: "panel", style: { marginBottom: "0.75rem", padding: "0.65rem 1rem" }, children: status }) : null,
534
+ /* @__PURE__ */ jsxs4(
535
+ "div",
536
+ {
537
+ className: `upload-dropzone ${dragOver ? "upload-dropzone-active" : ""}`,
538
+ onDragOver: (e) => {
539
+ e.preventDefault();
540
+ setDragOver(true);
541
+ },
542
+ onDragLeave: () => setDragOver(false),
543
+ onDrop,
544
+ children: [
545
+ /* @__PURE__ */ jsx4("p", { style: { margin: "0 0 0.5rem" }, children: "Trascina qui un file oppure scegli dal disco." }),
546
+ /* @__PURE__ */ jsx4(
547
+ "input",
548
+ {
549
+ type: "file",
550
+ disabled: busy,
551
+ accept: ".zip,.stl,.ply,.obj,.dcm,.xml,.json,.png,.jpg,.jpeg,.pdf,.txt,application/zip",
552
+ onChange: onInputChange
553
+ }
554
+ )
555
+ ]
556
+ }
557
+ ),
558
+ /* @__PURE__ */ jsx4("p", { className: "muted", style: { fontSize: "0.85rem", marginBottom: 0 }, children: "ZIP \u2192 tipo rilevato dal nome / content-type; estrazione locale + pi\xF9 POST verso l\u2019host. I file singoli usano l\u2019upload classico lato server." })
559
+ ] });
560
+ }
561
+
562
+ // src/components/ReviewPanel.tsx
563
+ import { useState as useState3 } from "react";
564
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
565
+ var roles = [
566
+ "upper_arch",
567
+ "lower_arch",
568
+ "bite",
569
+ "scanbody",
570
+ "abutment",
571
+ "model",
572
+ "impression",
573
+ "dicom_volume",
574
+ "project_file",
575
+ "preview",
576
+ "document",
577
+ "unknown"
578
+ ];
579
+ function ReviewPanel({
580
+ acquisitionId,
581
+ files,
582
+ review,
583
+ onPreviewMesh
584
+ }) {
585
+ const [map2, setMap] = useState3({});
586
+ const [busy, setBusy] = useState3(false);
587
+ const [err, setErr] = useState3(null);
588
+ const onRole = (id, role) => {
589
+ setMap((m) => ({ ...m, [id]: role }));
590
+ };
591
+ const submit = async (rejected) => {
592
+ if (!files?.length) return;
593
+ setErr(null);
594
+ setBusy(true);
595
+ try {
596
+ await review.saveReview({
597
+ acquisitionId,
598
+ updates: files.map((f) => ({
599
+ fileId: f._id,
600
+ role: map2[f._id] ?? f.role
601
+ })),
602
+ markRejected: rejected
603
+ });
604
+ } catch (e) {
605
+ setErr(e instanceof Error ? e.message : "Errore review");
606
+ } finally {
607
+ setBusy(false);
608
+ }
609
+ };
610
+ if (!files?.length) return null;
611
+ return /* @__PURE__ */ jsxs5("div", { children: [
612
+ /* @__PURE__ */ jsx5("h3", { style: { marginTop: 0 }, children: "Review manuale" }),
613
+ /* @__PURE__ */ jsx5("p", { className: "muted", children: "Correggi il ruolo dei file: le modifiche sono applicate solo tramite mutation server-side." }),
614
+ err ? /* @__PURE__ */ jsx5("div", { className: "err-banner", children: err }) : null,
615
+ /* @__PURE__ */ jsx5("div", { className: "btn-row", style: { marginBottom: "0.75rem" }, children: /* @__PURE__ */ jsx5(
616
+ "button",
617
+ {
618
+ type: "button",
619
+ className: "btn btn-ghost",
620
+ disabled: busy,
621
+ onClick: () => void review.beginManualReview({ acquisitionId }),
622
+ children: 'Segna "in review"'
623
+ }
624
+ ) }),
625
+ files.map((f) => /* @__PURE__ */ jsxs5("div", { className: "field", style: { marginBottom: "0.65rem" }, children: [
626
+ /* @__PURE__ */ jsxs5(
627
+ "div",
628
+ {
629
+ className: "btn-row",
630
+ style: {
631
+ justifyContent: "space-between",
632
+ alignItems: "center",
633
+ marginBottom: "0.35rem"
634
+ },
635
+ children: [
636
+ /* @__PURE__ */ jsx5("label", { style: { margin: 0 }, children: f.originalName }),
637
+ onPreviewMesh && isMeshViewerFormat(f.format) && f.storageId ? /* @__PURE__ */ jsx5(
638
+ "button",
639
+ {
640
+ type: "button",
641
+ className: "btn btn-ghost",
642
+ style: { fontSize: "0.75rem", padding: "0.25rem 0.55rem" },
643
+ onClick: () => onPreviewMesh(f._id),
644
+ children: "Visualizza 3D"
645
+ }
646
+ ) : null
647
+ ]
648
+ }
649
+ ),
650
+ /* @__PURE__ */ jsx5(
651
+ "select",
652
+ {
653
+ value: map2[f._id] ?? f.role,
654
+ onChange: (e) => onRole(f._id, e.target.value),
655
+ children: roles.map((r) => /* @__PURE__ */ jsx5("option", { value: r, children: r }, r))
656
+ }
657
+ )
658
+ ] }, f._id)),
659
+ /* @__PURE__ */ jsxs5("div", { className: "btn-row", style: { marginTop: "1rem" }, children: [
660
+ /* @__PURE__ */ jsx5(
661
+ "button",
662
+ {
663
+ type: "button",
664
+ className: "btn btn-primary",
665
+ disabled: busy,
666
+ onClick: () => void submit(false),
667
+ children: "Salva review e classifica"
668
+ }
669
+ ),
670
+ /* @__PURE__ */ jsx5(
671
+ "button",
672
+ {
673
+ type: "button",
674
+ className: "btn btn-danger",
675
+ disabled: busy,
676
+ onClick: () => void submit(true),
677
+ children: "Respinti (solo review status)"
678
+ }
679
+ )
680
+ ] })
681
+ ] });
682
+ }
683
+
684
+ // src/components/ManifestViewer.tsx
685
+ import { jsx as jsx6 } from "react/jsx-runtime";
686
+ function ManifestViewer({ manifest }) {
687
+ if (manifest == null) {
688
+ return /* @__PURE__ */ jsx6("p", { className: "muted", children: "Manifest non ancora disponibile." });
689
+ }
690
+ return /* @__PURE__ */ jsx6("pre", { className: "manifest-pre", children: JSON.stringify(manifest, null, 2) });
691
+ }
692
+
693
+ // src/components/CadProjectMetadataPanel.tsx
694
+ import { Fragment, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
695
+ var CONFIDENCE_LABEL = {
696
+ high: "Alta",
697
+ medium: "Media",
698
+ low: "Bassa",
699
+ inferred: "Dedotta"
700
+ };
701
+ function ConfidenceBadge({
702
+ confidence
703
+ }) {
704
+ return /* @__PURE__ */ jsx7("span", { className: `confidence-badge confidence-${confidence}`, children: CONFIDENCE_LABEL[confidence] });
705
+ }
706
+ var TS_COMPONENT_LABEL = {
707
+ analog: "Analogo",
708
+ analog_socket: "Socket analogo",
709
+ scanbody: "Scan body",
710
+ "link/tibase_candidate": "Link / Ti-Base (candidato)",
711
+ screw_or_transmucosal_component_candidate: "Vite / transmucoso (candidato)",
712
+ manufacturer_component_candidate: "Componente produttore (candidato)",
713
+ unknown: "Non classificato"
714
+ };
715
+ function ImplantComponentCard({ comp }) {
716
+ const geom = comp.parsedGeometry;
717
+ return /* @__PURE__ */ jsxs6("div", { className: "implant-card", children: [
718
+ /* @__PURE__ */ jsxs6("div", { className: "implant-card-head", children: [
719
+ /* @__PURE__ */ jsx7("strong", { children: comp.manufacturer ?? "Produttore n/d" }),
720
+ /* @__PURE__ */ jsx7(ConfidenceBadge, { confidence: comp.confidence })
721
+ ] }),
722
+ /* @__PURE__ */ jsxs6("dl", { className: "implant-card-grid", children: [
723
+ comp.tooth ? /* @__PURE__ */ jsxs6(Fragment, { children: [
724
+ /* @__PURE__ */ jsx7("dt", { children: "Dente" }),
725
+ /* @__PURE__ */ jsx7("dd", { children: comp.tooth })
726
+ ] }) : null,
727
+ comp.platform ? /* @__PURE__ */ jsxs6(Fragment, { children: [
728
+ /* @__PURE__ */ jsx7("dt", { children: "Piattaforma" }),
729
+ /* @__PURE__ */ jsx7("dd", { children: comp.platform })
730
+ ] }) : null,
731
+ comp.connection ? /* @__PURE__ */ jsxs6(Fragment, { children: [
732
+ /* @__PURE__ */ jsx7("dt", { children: "Connessione" }),
733
+ /* @__PURE__ */ jsx7("dd", { children: comp.connection })
734
+ ] }) : null,
735
+ comp.component ? /* @__PURE__ */ jsxs6(Fragment, { children: [
736
+ /* @__PURE__ */ jsx7("dt", { children: "Componente" }),
737
+ /* @__PURE__ */ jsx7("dd", { children: comp.component })
738
+ ] }) : null,
739
+ comp.libraryName ? /* @__PURE__ */ jsxs6(Fragment, { children: [
740
+ /* @__PURE__ */ jsx7("dt", { children: "Libreria Exocad" }),
741
+ /* @__PURE__ */ jsx7("dd", { children: comp.libraryName })
742
+ ] }) : null,
743
+ comp.geometryFile ? /* @__PURE__ */ jsxs6(Fragment, { children: [
744
+ /* @__PURE__ */ jsx7("dt", { children: "File geometria" }),
745
+ /* @__PURE__ */ jsx7("dd", { children: /* @__PURE__ */ jsx7("code", { children: comp.geometryFile }) })
746
+ ] }) : null,
747
+ comp.libraryPath ? /* @__PURE__ */ jsxs6(Fragment, { children: [
748
+ /* @__PURE__ */ jsx7("dt", { children: "Path libreria" }),
749
+ /* @__PURE__ */ jsx7("dd", { children: /* @__PURE__ */ jsx7("code", { className: "implant-path", children: comp.libraryPath }) })
750
+ ] }) : null
751
+ ] }),
752
+ geom ? /* @__PURE__ */ jsxs6("div", { className: "implant-geom", children: [
753
+ /* @__PURE__ */ jsx7("span", { className: "implant-geom-title", children: "Interpretazione codice (dedotta, da confermare)" }),
754
+ /* @__PURE__ */ jsxs6("ul", { className: "implant-geom-list", children: [
755
+ geom.baseType ? /* @__PURE__ */ jsxs6("li", { children: [
756
+ "Tipo base: ",
757
+ /* @__PURE__ */ jsx7("strong", { children: geom.baseType.value }),
758
+ " ",
759
+ /* @__PURE__ */ jsx7(ConfidenceBadge, { confidence: geom.baseType.confidence })
760
+ ] }) : null,
761
+ geom.diameterOrPlatform ? /* @__PURE__ */ jsxs6("li", { children: [
762
+ "Diametro/piattaforma:",
763
+ " ",
764
+ /* @__PURE__ */ jsx7("strong", { children: geom.diameterOrPlatform.value }),
765
+ " ",
766
+ /* @__PURE__ */ jsx7(
767
+ ConfidenceBadge,
768
+ {
769
+ confidence: geom.diameterOrPlatform.confidence
770
+ }
771
+ )
772
+ ] }) : null,
773
+ geom.abutmentHeight ? /* @__PURE__ */ jsxs6("li", { children: [
774
+ "Altezza abutment: ",
775
+ /* @__PURE__ */ jsx7("strong", { children: geom.abutmentHeight.value }),
776
+ " ",
777
+ /* @__PURE__ */ jsx7(ConfidenceBadge, { confidence: geom.abutmentHeight.confidence })
778
+ ] }) : null
779
+ ] })
780
+ ] }) : null,
781
+ /* @__PURE__ */ jsxs6("p", { className: "implant-card-source muted", children: [
782
+ "Fonte: ",
783
+ comp.sourceFiles.join(", ")
784
+ ] })
785
+ ] });
786
+ }
787
+ function ExocadComponentsSection({ exocad }) {
788
+ const { implantComponents, case: caseInfo, restoration, model } = exocad;
789
+ return /* @__PURE__ */ jsxs6("div", { className: "exocad-section", children: [
790
+ /* @__PURE__ */ jsxs6("div", { className: "exocad-case", children: [
791
+ caseInfo.patientName ? /* @__PURE__ */ jsxs6("span", { children: [
792
+ /* @__PURE__ */ jsx7("strong", { children: "Paziente:" }),
793
+ " ",
794
+ caseInfo.patientName
795
+ ] }) : null,
796
+ caseInfo.practiceName ? /* @__PURE__ */ jsxs6("span", { children: [
797
+ /* @__PURE__ */ jsx7("strong", { children: "Clinica:" }),
798
+ " ",
799
+ caseInfo.practiceName,
800
+ caseInfo.practiceId ? ` (#${caseInfo.practiceId})` : ""
801
+ ] }) : null,
802
+ restoration.span ? /* @__PURE__ */ jsxs6("span", { children: [
803
+ /* @__PURE__ */ jsxs6("strong", { children: [
804
+ restoration.isBridge ? "Ponte" : "Span",
805
+ ":"
806
+ ] }),
807
+ " ",
808
+ restoration.span
809
+ ] }) : null,
810
+ caseInfo.exocadVersion ? /* @__PURE__ */ jsx7("span", { className: "muted", children: caseInfo.exocadVersion }) : null
811
+ ] }),
812
+ /* @__PURE__ */ jsxs6("div", { className: "implant-box", children: [
813
+ /* @__PURE__ */ jsxs6("div", { className: "implant-box-head", children: [
814
+ /* @__PURE__ */ jsx7("strong", { children: "Componentistica rilevata" }),
815
+ /* @__PURE__ */ jsx7(
816
+ "span",
817
+ {
818
+ className: `implant-flag ${exocad.isImplantCase ? "is-implant" : "no-implant"}`,
819
+ children: exocad.isImplantCase ? "Caso implantare" : "Nessun impianto rilevato"
820
+ }
821
+ )
822
+ ] }),
823
+ implantComponents.length === 0 ? /* @__PURE__ */ jsxs6("p", { className: "muted", style: { margin: 0 }, children: [
824
+ "Nessuna componentistica implantare trovata nei file",
825
+ /* @__PURE__ */ jsx7("code", { children: " .constructionInfo" }),
826
+ " / ",
827
+ /* @__PURE__ */ jsx7("code", { children: ".modelInfo" }),
828
+ ". Se il caso \xE8 su impianto, controlla che lo ZIP contenga questi file."
829
+ ] }) : /* @__PURE__ */ jsx7("div", { className: "implant-card-list", children: implantComponents.map((comp, i) => /* @__PURE__ */ jsx7(
830
+ ImplantComponentCard,
831
+ {
832
+ comp
833
+ },
834
+ `${comp.geometryCode ?? comp.component ?? "c"}-${i}`
835
+ )) }),
836
+ model.analogLibrary || model.modelWorkflow ? /* @__PURE__ */ jsxs6("p", { className: "muted implant-model", children: [
837
+ model.analogLibrary ? /* @__PURE__ */ jsxs6(Fragment, { children: [
838
+ "Libreria analoghi: ",
839
+ /* @__PURE__ */ jsx7("strong", { children: model.analogLibrary }),
840
+ ".",
841
+ " "
842
+ ] }) : null,
843
+ model.modelWorkflow ? /* @__PURE__ */ jsxs6(Fragment, { children: [
844
+ "Workflow modello: ",
845
+ /* @__PURE__ */ jsx7("strong", { children: model.modelWorkflow }),
846
+ "."
847
+ ] }) : null
848
+ ] }) : null,
849
+ exocad.warnings.length > 0 ? /* @__PURE__ */ jsx7("ul", { className: "implant-warnings", children: exocad.warnings.map((w) => /* @__PURE__ */ jsx7("li", { children: w }, w)) }) : null
850
+ ] })
851
+ ] });
852
+ }
853
+ function zipPathForSourceFile(sourceFile, files) {
854
+ const base = sourceFile.replace(/\\/g, "/").split("/").pop() ?? sourceFile;
855
+ const match = files.find(
856
+ (f) => f.originalName === sourceFile || f.originalName.endsWith(`/${base}`) || f.originalName.split("/").pop() === base
857
+ );
858
+ return match?.originalName;
859
+ }
860
+ function CadSourceFileBlock({
861
+ entry,
862
+ zipPath,
863
+ defaultOpen
864
+ }) {
865
+ const hasDetails = entry.projectName || entry.projectDirectory || entry.toothNumbers.length > 0 || entry.materials.length > 0 || entry.shades.length > 0 || entry.reconstructions.length > 0;
866
+ return /* @__PURE__ */ jsxs6("details", { className: "cad-source-file", open: defaultOpen, children: [
867
+ /* @__PURE__ */ jsxs6("summary", { children: [
868
+ /* @__PURE__ */ jsx7("code", { children: entry.sourceFile }),
869
+ entry.source === "exocad" ? /* @__PURE__ */ jsx7("span", { className: "cad-source-file-vendor", children: " \xB7 Exocad" }) : null
870
+ ] }),
871
+ zipPath && zipPath !== entry.sourceFile ? /* @__PURE__ */ jsxs6("p", { className: "cad-source-file-path", children: [
872
+ "Nel pacchetto ZIP: ",
873
+ /* @__PURE__ */ jsx7("code", { children: zipPath })
874
+ ] }) : null,
875
+ !hasDetails ? /* @__PURE__ */ jsx7("p", { className: "muted", style: { margin: "0.5rem 0 0" }, children: "Nessun campo strutturato estratto da questo file (formato XML non riconosciuto o file vuoto)." }) : /* @__PURE__ */ jsxs6("div", { className: "cad-source-file-body", children: [
876
+ entry.projectName ? /* @__PURE__ */ jsxs6("p", { children: [
877
+ /* @__PURE__ */ jsx7("strong", { children: "Progetto:" }),
878
+ " ",
879
+ entry.projectName
880
+ ] }) : null,
881
+ entry.projectDirectory ? /* @__PURE__ */ jsxs6("p", { children: [
882
+ /* @__PURE__ */ jsx7("strong", { children: "Directory:" }),
883
+ " ",
884
+ entry.projectDirectory
885
+ ] }) : null,
886
+ entry.toothNumbers.length > 0 ? /* @__PURE__ */ jsxs6("p", { children: [
887
+ /* @__PURE__ */ jsx7("strong", { children: "Denti (FDI):" }),
888
+ " ",
889
+ entry.toothNumbers.join(", ")
890
+ ] }) : null,
891
+ entry.materials.length > 0 ? /* @__PURE__ */ jsxs6("p", { children: [
892
+ /* @__PURE__ */ jsx7("strong", { children: "Materiali:" }),
893
+ " ",
894
+ entry.materials.join(", ")
895
+ ] }) : null,
896
+ entry.shades.length > 0 ? /* @__PURE__ */ jsxs6("p", { children: [
897
+ /* @__PURE__ */ jsx7("strong", { children: "Colori / shade:" }),
898
+ " ",
899
+ entry.shades.join(", ")
900
+ ] }) : null,
901
+ entry.reconstructions.length > 0 ? /* @__PURE__ */ jsxs6("div", { children: [
902
+ /* @__PURE__ */ jsx7("span", { className: "cad-recon-label", children: "Elementi" }),
903
+ /* @__PURE__ */ jsx7("ul", { className: "cad-recon-list", children: entry.reconstructions.map((r, i) => /* @__PURE__ */ jsxs6("li", { children: [
904
+ r.toothNumbers.length ? `Denti ${r.toothNumbers.join(", ")}` : "Elemento",
905
+ r.reconstructionType ? ` \xB7 ${r.reconstructionType}` : null,
906
+ r.material ? ` \xB7 ${r.material}` : null,
907
+ r.shade ? ` \xB7 ${r.shade}` : null,
908
+ r.meshFileName ? ` \xB7 ${r.meshFileName}` : null
909
+ ] }, `${entry.sourceFile}-${r.toothNumbers.join("-")}-${i}`)) })
910
+ ] }) : null
911
+ ] }),
912
+ entry.parseWarnings.length > 0 ? /* @__PURE__ */ jsx7("ul", { className: "cad-recon-list muted", style: { fontSize: "0.88rem" }, children: entry.parseWarnings.map((w) => /* @__PURE__ */ jsx7("li", { children: w }, w)) }) : null
913
+ ] });
914
+ }
915
+ function ThreeShapeSection({ ts }) {
916
+ const { case: caseInfo, restoration, implant } = ts;
917
+ return /* @__PURE__ */ jsxs6("div", { className: "exocad-section", children: [
918
+ /* @__PURE__ */ jsxs6("div", { className: "exocad-case", children: [
919
+ caseInfo.patientName ? /* @__PURE__ */ jsxs6("span", { children: [
920
+ /* @__PURE__ */ jsx7("strong", { children: "Paziente:" }),
921
+ " ",
922
+ caseInfo.patientName
923
+ ] }) : null,
924
+ caseInfo.orderId ? /* @__PURE__ */ jsxs6("span", { children: [
925
+ /* @__PURE__ */ jsx7("strong", { children: "Ordine:" }),
926
+ " ",
927
+ caseInfo.orderId
928
+ ] }) : null,
929
+ caseInfo.customer ? /* @__PURE__ */ jsxs6("span", { children: [
930
+ /* @__PURE__ */ jsx7("strong", { children: "Cliente:" }),
931
+ " ",
932
+ caseInfo.customer
933
+ ] }) : null,
934
+ restoration.teeth.length > 0 ? /* @__PURE__ */ jsxs6("span", { children: [
935
+ /* @__PURE__ */ jsx7("strong", { children: "Denti (FDI):" }),
936
+ " ",
937
+ restoration.teeth.join(", ")
938
+ ] }) : null,
939
+ restoration.type ? /* @__PURE__ */ jsxs6("span", { children: [
940
+ /* @__PURE__ */ jsx7("strong", { children: "Lavorazione:" }),
941
+ " ",
942
+ restoration.type
943
+ ] }) : null,
944
+ restoration.materialName ? /* @__PURE__ */ jsxs6("span", { children: [
945
+ /* @__PURE__ */ jsx7("strong", { children: "Materiale:" }),
946
+ " ",
947
+ restoration.materialName
948
+ ] }) : null,
949
+ /* @__PURE__ */ jsx7("span", { className: "muted", children: caseInfo.software })
950
+ ] }),
951
+ /* @__PURE__ */ jsxs6("div", { className: "implant-box", children: [
952
+ /* @__PURE__ */ jsxs6("div", { className: "implant-box-head", children: [
953
+ /* @__PURE__ */ jsx7("strong", { children: "Componentistica rilevata \xB7 3Shape" }),
954
+ /* @__PURE__ */ jsx7(
955
+ "span",
956
+ {
957
+ className: `implant-flag ${implant.isImplantCase ? "is-implant" : "no-implant"}`,
958
+ children: implant.isImplantCase ? "Caso implantare" : "Nessun impianto rilevato"
959
+ }
960
+ )
961
+ ] }),
962
+ implant.isImplantCase ? /* @__PURE__ */ jsxs6("dl", { className: "implant-card-grid", style: { marginBottom: "0.6rem" }, children: [
963
+ implant.manufacturer ? /* @__PURE__ */ jsxs6(Fragment, { children: [
964
+ /* @__PURE__ */ jsx7("dt", { children: "Produttore" }),
965
+ /* @__PURE__ */ jsx7("dd", { children: implant.manufacturer })
966
+ ] }) : null,
967
+ implant.connection ? /* @__PURE__ */ jsxs6(Fragment, { children: [
968
+ /* @__PURE__ */ jsx7("dt", { children: "Connessione" }),
969
+ /* @__PURE__ */ jsx7("dd", { children: implant.connection })
970
+ ] }) : null,
971
+ implant.abutmentKitId ? /* @__PURE__ */ jsxs6(Fragment, { children: [
972
+ /* @__PURE__ */ jsx7("dt", { children: "Abutment kit" }),
973
+ /* @__PURE__ */ jsx7("dd", { children: /* @__PURE__ */ jsx7("code", { children: implant.abutmentKitId }) })
974
+ ] }) : null,
975
+ implant.connectionId ? /* @__PURE__ */ jsxs6(Fragment, { children: [
976
+ /* @__PURE__ */ jsx7("dt", { children: "Connection ID" }),
977
+ /* @__PURE__ */ jsx7("dd", { children: /* @__PURE__ */ jsx7("code", { children: implant.connectionId }) })
978
+ ] }) : null,
979
+ implant.implantSystemId ? /* @__PURE__ */ jsxs6(Fragment, { children: [
980
+ /* @__PURE__ */ jsx7("dt", { children: "Implant system ID" }),
981
+ /* @__PURE__ */ jsx7("dd", { children: /* @__PURE__ */ jsx7("code", { children: implant.implantSystemId }) })
982
+ ] }) : null
983
+ ] }) : null,
984
+ implant.components.length > 0 ? /* @__PURE__ */ jsxs6("div", { className: "ts-components", children: [
985
+ /* @__PURE__ */ jsx7("span", { className: "implant-geom-title", children: "Componenti trovati" }),
986
+ /* @__PURE__ */ jsx7("ul", { className: "ts-component-list", children: implant.components.map((c) => /* @__PURE__ */ jsxs6("li", { children: [
987
+ /* @__PURE__ */ jsx7("code", { children: c.fileName }),
988
+ /* @__PURE__ */ jsx7("span", { className: "ts-component-type", children: TS_COMPONENT_LABEL[c.type] }),
989
+ /* @__PURE__ */ jsx7(ConfidenceBadge, { confidence: c.confidence })
990
+ ] }, c.sourcePath)) })
991
+ ] }) : implant.isImplantCase ? /* @__PURE__ */ jsxs6("p", { className: "muted", style: { margin: 0 }, children: [
992
+ "Caso implantare ma nessun componente trovato in",
993
+ /* @__PURE__ */ jsx7("code", { children: " External models/" }),
994
+ "."
995
+ ] }) : null,
996
+ ts.warnings.length > 0 ? /* @__PURE__ */ jsx7("ul", { className: "implant-warnings", children: ts.warnings.map((w) => /* @__PURE__ */ jsx7("li", { children: w }, w)) }) : null
997
+ ] })
998
+ ] });
999
+ }
1000
+ function CadProjectMetadataPanel({
1001
+ cadProject,
1002
+ manifestFiles = []
1003
+ }) {
1004
+ const perFile = cadProject.bySourceFile ?? [];
1005
+ const hasPerFileBreakdown = perFile.length > 0;
1006
+ return /* @__PURE__ */ jsxs6("div", { className: "panel", style: { marginBottom: "1rem" }, children: [
1007
+ /* @__PURE__ */ jsx7("h3", { style: { marginTop: 0 }, children: "Metadati progetto CAD" }),
1008
+ /* @__PURE__ */ jsxs6("div", { className: "cad-project-summary", children: [
1009
+ /* @__PURE__ */ jsxs6("p", { className: "muted", style: { marginTop: 0 }, children: [
1010
+ "Riepilogo da",
1011
+ " ",
1012
+ /* @__PURE__ */ jsx7("code", { children: cadProject.sourceFiles.join(", ") }),
1013
+ cadProject.source === "exocad" ? " \xB7 Exocad" : null
1014
+ ] }),
1015
+ cadProject.projectName ? /* @__PURE__ */ jsxs6("p", { style: { margin: "0.35rem 0" }, children: [
1016
+ /* @__PURE__ */ jsx7("strong", { children: "Progetto:" }),
1017
+ " ",
1018
+ cadProject.projectName
1019
+ ] }) : null,
1020
+ cadProject.toothNumbers.length > 0 ? /* @__PURE__ */ jsxs6("p", { style: { margin: "0.35rem 0" }, children: [
1021
+ /* @__PURE__ */ jsx7("strong", { children: "Denti (FDI):" }),
1022
+ " ",
1023
+ cadProject.toothNumbers.join(", ")
1024
+ ] }) : null,
1025
+ cadProject.materials.length > 0 ? /* @__PURE__ */ jsxs6("p", { style: { margin: "0.35rem 0" }, children: [
1026
+ /* @__PURE__ */ jsx7("strong", { children: "Materiali:" }),
1027
+ " ",
1028
+ cadProject.materials.join(", ")
1029
+ ] }) : null,
1030
+ cadProject.shades.length > 0 ? /* @__PURE__ */ jsxs6("p", { style: { margin: "0.35rem 0" }, children: [
1031
+ /* @__PURE__ */ jsx7("strong", { children: "Colori / shade:" }),
1032
+ " ",
1033
+ cadProject.shades.join(", ")
1034
+ ] }) : null
1035
+ ] }),
1036
+ cadProject.exocad ? /* @__PURE__ */ jsx7(ExocadComponentsSection, { exocad: cadProject.exocad }) : null,
1037
+ cadProject.threeShape ? /* @__PURE__ */ jsx7(ThreeShapeSection, { ts: cadProject.threeShape }) : null,
1038
+ /* @__PURE__ */ jsx7("strong", { className: "cad-source-files-title", children: "Dettaglio per file sorgente" }),
1039
+ !hasPerFileBreakdown ? /* @__PURE__ */ jsxs6("p", { className: "muted", style: { margin: 0, fontSize: "0.9em" }, children: [
1040
+ "File analizzati:",
1041
+ " ",
1042
+ /* @__PURE__ */ jsx7("code", { children: cadProject.sourceFiles.join(", ") }),
1043
+ ". Ri-carica lo ZIP per vedere i campi estratti separatamente da ciascun file."
1044
+ ] }) : perFile.map((entry, index) => /* @__PURE__ */ jsx7(
1045
+ CadSourceFileBlock,
1046
+ {
1047
+ entry,
1048
+ zipPath: zipPathForSourceFile(entry.sourceFile, manifestFiles),
1049
+ defaultOpen: index === 0
1050
+ },
1051
+ entry.sourceFile
1052
+ ))
1053
+ ] });
1054
+ }
1055
+
1056
+ // src/components/CaseSummaryCard.tsx
1057
+ import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
1058
+ function uniq(values) {
1059
+ return [...new Set(values.filter((v) => Boolean(v)))];
1060
+ }
1061
+ function buildSummary(cad) {
1062
+ const ex = cad.exocad;
1063
+ const ts = cad.threeShape;
1064
+ const vendor = ts ? "3Shape" : ex || cad.source === "exocad" ? "Exocad" : "\u2014";
1065
+ const software = ts ? uniq([ts.case.software, ts.case.softwareVersion]).join(" ") || null : ex?.case.exocadVersion ?? null;
1066
+ const workTypes = ts?.restoration.type ? [ts.restoration.type] : ex ? uniq(ex.restoration.items.map((i) => i.type)) : uniq(cad.reconstructions.map((r) => r.reconstructionType));
1067
+ const teeth = ts && ts.restoration.teeth.length > 0 ? ts.restoration.teeth.map(String) : ex && ex.restoration.teeth.length > 0 ? ex.restoration.teeth : cad.toothNumbers.map(String);
1068
+ const materials = ts?.restoration.materialName ? [ts.restoration.materialName] : cad.materials;
1069
+ const shades = ts?.restoration.shade ? [ts.restoration.shade] : cad.shades;
1070
+ const isImplant = ts?.implant.isImplantCase ?? ex?.isImplantCase ?? false;
1071
+ const manufacturer = ts?.implant.manufacturer ?? ex?.implantComponents.find((c) => c.manufacturer)?.manufacturer ?? null;
1072
+ const connection = ts?.implant.connection ?? ex?.implantComponents.find((c) => c.connection)?.connection ?? null;
1073
+ const components = ts ? ts.implant.components.map((c) => c.fileName) : ex ? ex.implantComponents.map(
1074
+ (c) => c.component ?? c.geometryFile ?? c.manufacturer ?? "componente"
1075
+ ) : [];
1076
+ return {
1077
+ patientName: ex?.case.patientName ?? ts?.case.patientName ?? null,
1078
+ software,
1079
+ scanSource: ts?.case.scanSource ?? null,
1080
+ workTypes,
1081
+ teeth,
1082
+ materials,
1083
+ shades,
1084
+ isImplant,
1085
+ manufacturer,
1086
+ connection,
1087
+ abutmentKit: ts?.implant.abutmentKitId ?? null,
1088
+ components,
1089
+ vendor
1090
+ };
1091
+ }
1092
+ function Row({
1093
+ label,
1094
+ children
1095
+ }) {
1096
+ return /* @__PURE__ */ jsxs7("div", { className: "summary-row", children: [
1097
+ /* @__PURE__ */ jsx8("span", { className: "summary-label", children: label }),
1098
+ /* @__PURE__ */ jsx8("span", { className: "summary-value", children })
1099
+ ] });
1100
+ }
1101
+ function CaseSummaryCard({
1102
+ cadProject,
1103
+ fallbackTitle
1104
+ }) {
1105
+ const s = buildSummary(cadProject);
1106
+ return /* @__PURE__ */ jsxs7("div", { className: "panel summary-card", style: { marginBottom: "1rem" }, children: [
1107
+ /* @__PURE__ */ jsxs7("div", { className: "summary-head", children: [
1108
+ /* @__PURE__ */ jsx8("h3", { style: { margin: 0 }, children: s.patientName ?? fallbackTitle ?? "Riepilogo caso" }),
1109
+ s.vendor !== "\u2014" ? /* @__PURE__ */ jsx8("span", { className: "vendor-pill vendor-detail", children: s.vendor }) : null
1110
+ ] }),
1111
+ /* @__PURE__ */ jsxs7("div", { className: "summary-list", children: [
1112
+ /* @__PURE__ */ jsx8(Row, { label: "Lavorazione", children: s.workTypes.length > 0 ? s.workTypes.join(", ") : "\u2014" }),
1113
+ /* @__PURE__ */ jsxs7(Row, { label: "Software / scanner", children: [
1114
+ s.software ?? "\u2014",
1115
+ s.scanSource ? ` \xB7 ${s.scanSource}` : ""
1116
+ ] }),
1117
+ /* @__PURE__ */ jsx8(Row, { label: "Denti (FDI)", children: s.teeth.length > 0 ? s.teeth.join(", ") : "\u2014" }),
1118
+ /* @__PURE__ */ jsx8(Row, { label: "Materiale", children: s.materials.length > 0 ? s.materials.join(", ") : "\u2014" }),
1119
+ /* @__PURE__ */ jsx8(Row, { label: "Shade / colore", children: s.shades.length > 0 ? s.shades.join(", ") : "\u2014" }),
1120
+ /* @__PURE__ */ jsx8(Row, { label: "Tipo caso", children: /* @__PURE__ */ jsx8(
1121
+ "span",
1122
+ {
1123
+ className: `case-flag ${s.isImplant ? "case-implant" : "case-noimplant"}`,
1124
+ children: s.isImplant ? "Implantare" : "Non implantare"
1125
+ }
1126
+ ) }),
1127
+ s.isImplant ? /* @__PURE__ */ jsxs7(Fragment2, { children: [
1128
+ /* @__PURE__ */ jsx8(Row, { label: "Produttore", children: s.manufacturer ?? "Non identificato" }),
1129
+ /* @__PURE__ */ jsx8(Row, { label: "Connessione", children: s.connection ?? "\u2014" }),
1130
+ s.abutmentKit ? /* @__PURE__ */ jsx8(Row, { label: "Abutment kit", children: /* @__PURE__ */ jsx8("code", { children: s.abutmentKit }) }) : null,
1131
+ s.components.length > 0 ? /* @__PURE__ */ jsx8(Row, { label: "Componenti", children: s.components.join(", ") }) : null
1132
+ ] }) : null
1133
+ ] })
1134
+ ] });
1135
+ }
1136
+
1137
+ // src/components/EventTimeline.tsx
1138
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
1139
+ function EventTimeline({
1140
+ events
1141
+ }) {
1142
+ if (!events?.length) {
1143
+ return /* @__PURE__ */ jsx9("p", { className: "muted", children: "Nessun evento registrato. Le azioni sul backend appariranno qui in ordine cronologico." });
1144
+ }
1145
+ return /* @__PURE__ */ jsx9("div", { className: "timeline", children: events.map((e) => /* @__PURE__ */ jsxs8("div", { className: "timeline-item", children: [
1146
+ /* @__PURE__ */ jsx9("div", { style: { fontSize: "0.72rem", color: "#8ea0bf" }, children: new Date(e.createdAt).toLocaleString("it-IT") }),
1147
+ /* @__PURE__ */ jsx9("div", { style: { fontWeight: 600, marginTop: "0.15rem" }, children: e.type }),
1148
+ /* @__PURE__ */ jsx9("div", { className: "muted", style: { marginTop: "0.2rem" }, children: e.message })
1149
+ ] }, e._id)) });
1150
+ }
1151
+
1152
+ // src/components/MeshViewer.tsx
1153
+ import { useEffect, useRef, useState as useState4 } from "react";
1154
+ import { BufferAttribute as BufferAttribute2, Mesh as Mesh3 } from "three";
1155
+
1156
+ // src/lib/meshLoaders.ts
1157
+ import {
1158
+ Box3,
1159
+ BufferGeometry,
1160
+ Group,
1161
+ Mesh,
1162
+ MeshStandardMaterial,
1163
+ Vector3,
1164
+ DoubleSide
1165
+ } from "three";
1166
+ import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
1167
+ import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
1168
+ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
1169
+ function neutralMaterial() {
1170
+ return new MeshStandardMaterial({
1171
+ color: 12109020,
1172
+ metalness: 0.06,
1173
+ roughness: 0.42,
1174
+ side: DoubleSide,
1175
+ flatShading: false
1176
+ });
1177
+ }
1178
+ function loadStlMesh(data) {
1179
+ const loader = new STLLoader();
1180
+ const geometry = loader.parse(data);
1181
+ geometry.computeVertexNormals();
1182
+ return new Mesh(geometry, neutralMaterial());
1183
+ }
1184
+ function loadPlyMesh(data) {
1185
+ const loader = new PLYLoader();
1186
+ const geometry = loader.parse(data);
1187
+ geometry.computeVertexNormals();
1188
+ return new Mesh(geometry, neutralMaterial());
1189
+ }
1190
+ function loadObjMesh(data) {
1191
+ const text = new TextDecoder("utf-8").decode(data);
1192
+ const loader = new OBJLoader();
1193
+ const group = loader.parse(text);
1194
+ group.traverse((child) => {
1195
+ if (child instanceof Mesh) {
1196
+ child.material = neutralMaterial();
1197
+ if (child.geometry instanceof BufferGeometry) {
1198
+ child.geometry.computeVertexNormals();
1199
+ }
1200
+ }
1201
+ });
1202
+ return group;
1203
+ }
1204
+ function loadMeshByFormat(format, data) {
1205
+ const f = format.toLowerCase();
1206
+ if (f === "stl") return loadStlMesh(data);
1207
+ if (f === "ply") return loadPlyMesh(data);
1208
+ if (f === "obj") return loadObjMesh(data);
1209
+ throw new Error(`Formato mesh non supportato nel viewer: ${format}`);
1210
+ }
1211
+ function wrapLoadedMeshRoot(loaded) {
1212
+ const root = new Group();
1213
+ root.add(loaded);
1214
+ return root;
1215
+ }
1216
+ function normalizeMeshRoot(root, targetSize = 2) {
1217
+ root.updateMatrixWorld(true);
1218
+ const box = new Box3().setFromObject(root);
1219
+ const center = new Vector3();
1220
+ const size = new Vector3();
1221
+ box.getCenter(center);
1222
+ box.getSize(size);
1223
+ root.position.sub(center);
1224
+ const maxDim = Math.max(size.x, size.y, size.z, 1e-6);
1225
+ const s = targetSize / maxDim;
1226
+ root.scale.setScalar(s);
1227
+ root.updateMatrixWorld(true);
1228
+ return new Box3().setFromObject(root);
1229
+ }
1230
+ function disposeObject3DTree(root) {
1231
+ if (!root) return;
1232
+ root.traverse((obj) => {
1233
+ if (obj instanceof Mesh) {
1234
+ obj.geometry?.dispose();
1235
+ const m = obj.material;
1236
+ if (Array.isArray(m)) {
1237
+ for (const x of m) x.dispose();
1238
+ } else {
1239
+ m?.dispose();
1240
+ }
1241
+ }
1242
+ });
1243
+ }
1244
+
1245
+ // src/dentalGeom/DentalGeomIntakeViewport.ts
1246
+ import * as THREE from "three";
1247
+ import { OrbitControls } from "three-stdlib";
1248
+
1249
+ // src/dentalGeom/constants.ts
1250
+ var CAMERA_CONFIG = {
1251
+ FRUSTUM_SIZE: 100,
1252
+ NEAR_PLANE: 0.1,
1253
+ FAR_PLANE: 1e3,
1254
+ DEFAULT_POSITION: { x: 20, y: 20, z: 20 },
1255
+ DEFAULT_UP: { x: 0, y: 0, z: -1 },
1256
+ DEFAULT_LOOK_AT: { x: 0, y: 0, z: -1 }
1257
+ };
1258
+ var ANIMATION_CONFIG = {
1259
+ DURATION_MS: 1e3,
1260
+ EASING_THRESHOLD: 0.5,
1261
+ EASING_POWER: 2
1262
+ };
1263
+ var CONTROLS_CONFIG = {
1264
+ ENABLE_DAMPING: true,
1265
+ DAMPING_FACTOR: 0.05,
1266
+ DEFAULT_TARGET: { x: 0, y: 0, z: 0 }
1267
+ };
1268
+ var LIGHTING_CONFIG = {
1269
+ AMBIENT: { COLOR: 16777215, INTENSITY: 0.4 },
1270
+ KEY_LIGHT: {
1271
+ COLOR: 16777215,
1272
+ INTENSITY: 1.2,
1273
+ POSITION: { x: 50, y: 100, z: 50 }
1274
+ },
1275
+ FILL_LIGHT: {
1276
+ COLOR: 16777215,
1277
+ INTENSITY: 0.6,
1278
+ POSITION: { x: -50, y: 50, z: -50 }
1279
+ },
1280
+ RIM_LIGHT: {
1281
+ COLOR: 16777215,
1282
+ INTENSITY: 0.7,
1283
+ POSITION: { x: 0, y: 50, z: -100 }
1284
+ },
1285
+ HEMISPHERE: {
1286
+ SKY_COLOR: 16777215,
1287
+ GROUND_COLOR: 4473924,
1288
+ INTENSITY: 0.5
1289
+ }
1290
+ };
1291
+ var SCENE_CONFIG = {
1292
+ BACKGROUND_COLOR: 4868730
1293
+ };
1294
+ var RENDERER_CONFIG = {
1295
+ ANTIALIAS: true,
1296
+ LOCAL_CLIPPING_ENABLED: true
1297
+ };
1298
+ var VIEW_DIRECTIONS = {
1299
+ TOP: { name: "top", up: { x: 0, y: 1, z: 0 } },
1300
+ BOTTOM: { name: "bottom", up: { x: 0, y: 1, z: 0 } },
1301
+ FRONT: { name: "front", up: { x: 0, y: 0, z: -1 } },
1302
+ BACK: { name: "back", up: { x: 0, y: 0, z: -1 } },
1303
+ LEFT: { name: "left", up: { x: 0, y: 0, z: -1 } },
1304
+ RIGHT: { name: "right", up: { x: 0, y: 0, z: -1 } }
1305
+ };
1306
+
1307
+ // src/dentalGeom/DentalGeomIntakeViewport.ts
1308
+ function setupLights(scene) {
1309
+ const L = LIGHTING_CONFIG;
1310
+ scene.add(new THREE.AmbientLight(L.AMBIENT.COLOR, L.AMBIENT.INTENSITY));
1311
+ const key = new THREE.DirectionalLight(L.KEY_LIGHT.COLOR, L.KEY_LIGHT.INTENSITY);
1312
+ key.position.set(
1313
+ L.KEY_LIGHT.POSITION.x,
1314
+ L.KEY_LIGHT.POSITION.y,
1315
+ L.KEY_LIGHT.POSITION.z
1316
+ );
1317
+ scene.add(key);
1318
+ const fill = new THREE.DirectionalLight(L.FILL_LIGHT.COLOR, L.FILL_LIGHT.INTENSITY);
1319
+ fill.position.set(
1320
+ L.FILL_LIGHT.POSITION.x,
1321
+ L.FILL_LIGHT.POSITION.y,
1322
+ L.FILL_LIGHT.POSITION.z
1323
+ );
1324
+ scene.add(fill);
1325
+ const rim = new THREE.DirectionalLight(L.RIM_LIGHT.COLOR, L.RIM_LIGHT.INTENSITY);
1326
+ rim.position.set(
1327
+ L.RIM_LIGHT.POSITION.x,
1328
+ L.RIM_LIGHT.POSITION.y,
1329
+ L.RIM_LIGHT.POSITION.z
1330
+ );
1331
+ scene.add(rim);
1332
+ scene.add(
1333
+ new THREE.HemisphereLight(
1334
+ L.HEMISPHERE.SKY_COLOR,
1335
+ L.HEMISPHERE.GROUND_COLOR,
1336
+ L.HEMISPHERE.INTENSITY
1337
+ )
1338
+ );
1339
+ }
1340
+ var DentalGeomIntakeViewport = class {
1341
+ scene;
1342
+ camera;
1343
+ renderer;
1344
+ controls;
1345
+ container;
1346
+ rootObject = null;
1347
+ animationHandle = null;
1348
+ resizeObserver = null;
1349
+ tick = () => {
1350
+ this.animationHandle = requestAnimationFrame(this.tick);
1351
+ this.controls.update();
1352
+ this.renderer.render(this.scene, this.camera);
1353
+ };
1354
+ constructor(container) {
1355
+ this.container = container;
1356
+ this.scene = new THREE.Scene();
1357
+ this.scene.background = new THREE.Color(SCENE_CONFIG.BACKGROUND_COLOR);
1358
+ const w = Math.max(1, container.clientWidth);
1359
+ const h = Math.max(1, container.clientHeight);
1360
+ this.camera = new THREE.OrthographicCamera(
1361
+ -1,
1362
+ 1,
1363
+ 1,
1364
+ -1,
1365
+ CAMERA_CONFIG.NEAR_PLANE,
1366
+ CAMERA_CONFIG.FAR_PLANE
1367
+ );
1368
+ this.setDefaultOrthographicFrustum();
1369
+ this.camera.position.set(
1370
+ CAMERA_CONFIG.DEFAULT_POSITION.x,
1371
+ CAMERA_CONFIG.DEFAULT_POSITION.y,
1372
+ CAMERA_CONFIG.DEFAULT_POSITION.z
1373
+ );
1374
+ this.camera.lookAt(
1375
+ CAMERA_CONFIG.DEFAULT_LOOK_AT.x,
1376
+ CAMERA_CONFIG.DEFAULT_LOOK_AT.y,
1377
+ CAMERA_CONFIG.DEFAULT_LOOK_AT.z
1378
+ );
1379
+ this.renderer = new THREE.WebGLRenderer({
1380
+ antialias: RENDERER_CONFIG.ANTIALIAS
1381
+ });
1382
+ this.renderer.setPixelRatio(Math.min(2, typeof window !== "undefined" ? window.devicePixelRatio : 1));
1383
+ this.renderer.setSize(w, h);
1384
+ this.renderer.localClippingEnabled = RENDERER_CONFIG.LOCAL_CLIPPING_ENABLED;
1385
+ container.appendChild(this.renderer.domElement);
1386
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
1387
+ this.controls.enableDamping = CONTROLS_CONFIG.ENABLE_DAMPING;
1388
+ this.controls.dampingFactor = CONTROLS_CONFIG.DAMPING_FACTOR;
1389
+ this.controls.target.set(
1390
+ CONTROLS_CONFIG.DEFAULT_TARGET.x,
1391
+ CONTROLS_CONFIG.DEFAULT_TARGET.y,
1392
+ CONTROLS_CONFIG.DEFAULT_TARGET.z
1393
+ );
1394
+ this.controls.update();
1395
+ setupLights(this.scene);
1396
+ this.resizeObserver = new ResizeObserver(() => this.handleResize());
1397
+ this.resizeObserver.observe(container);
1398
+ this.tick();
1399
+ }
1400
+ handleResize() {
1401
+ const w = Math.max(1, this.container.clientWidth);
1402
+ const h = Math.max(1, this.container.clientHeight);
1403
+ this.renderer.setSize(w, h);
1404
+ if (this.rootObject) {
1405
+ this.fitOrthographicFrustumToObject();
1406
+ } else {
1407
+ this.setDefaultOrthographicFrustum();
1408
+ }
1409
+ }
1410
+ /** Frustum fisso (compat DentalGeom) prima del primo modello caricato. */
1411
+ setDefaultOrthographicFrustum() {
1412
+ const w = Math.max(1, this.container.clientWidth);
1413
+ const h = Math.max(1, this.container.clientHeight);
1414
+ const aspect = w / h;
1415
+ const frustum = CAMERA_CONFIG.FRUSTUM_SIZE;
1416
+ this.camera.left = -frustum * aspect / 2;
1417
+ this.camera.right = frustum * aspect / 2;
1418
+ this.camera.top = frustum / 2;
1419
+ this.camera.bottom = -frustum / 2;
1420
+ this.camera.zoom = 1;
1421
+ this.camera.updateProjectionMatrix();
1422
+ }
1423
+ /**
1424
+ * Adatta left/right/top/bottom all’ingombro reale della mesh.
1425
+ * Prima il frustum ~100 unità con modello normalizzato ~2 unità → modello minuscolo.
1426
+ */
1427
+ fitOrthographicFrustumToObject() {
1428
+ if (!this.rootObject) return;
1429
+ const box = new THREE.Box3().setFromObject(this.rootObject);
1430
+ if (box.isEmpty()) return;
1431
+ const sphere = new THREE.Sphere();
1432
+ box.getBoundingSphere(sphere);
1433
+ const r = Math.max(sphere.radius, 1e-6) * 1.35;
1434
+ const w = Math.max(1, this.container.clientWidth);
1435
+ const h = Math.max(1, this.container.clientHeight);
1436
+ const viewAspect = w / h;
1437
+ let halfW;
1438
+ let halfH;
1439
+ if (viewAspect >= 1) {
1440
+ halfH = r;
1441
+ halfW = r * viewAspect;
1442
+ } else {
1443
+ halfW = r;
1444
+ halfH = r / viewAspect;
1445
+ }
1446
+ this.camera.left = -halfW;
1447
+ this.camera.right = halfW;
1448
+ this.camera.top = halfH;
1449
+ this.camera.bottom = -halfH;
1450
+ this.camera.zoom = 1;
1451
+ this.camera.updateProjectionMatrix();
1452
+ }
1453
+ /** Sostituisce il modello nella scena (rimuove il precedente). */
1454
+ setRootObject(root) {
1455
+ if (this.rootObject) {
1456
+ this.scene.remove(this.rootObject);
1457
+ this.rootObject = null;
1458
+ }
1459
+ this.rootObject = root;
1460
+ if (root) this.scene.add(root);
1461
+ }
1462
+ centerCameraOnObject() {
1463
+ if (!this.rootObject) return;
1464
+ const box = new THREE.Box3().setFromObject(this.rootObject);
1465
+ if (box.isEmpty()) return;
1466
+ const center = new THREE.Vector3();
1467
+ box.getCenter(center);
1468
+ const size = new THREE.Vector3();
1469
+ box.getSize(size);
1470
+ const maxDim = Math.max(size.x, size.y, size.z, 1e-6);
1471
+ const distance = Math.abs(maxDim);
1472
+ const frontViewUp = VIEW_DIRECTIONS.FRONT.up;
1473
+ this.camera.position.set(center.x, center.y + distance, center.z);
1474
+ this.camera.up.set(frontViewUp.x, frontViewUp.y, frontViewUp.z);
1475
+ this.camera.lookAt(center);
1476
+ this.controls.target.copy(center);
1477
+ this.fitOrthographicFrustumToObject();
1478
+ this.controls.update();
1479
+ }
1480
+ setCameraView(view) {
1481
+ if (!this.rootObject) return;
1482
+ const box = new THREE.Box3().setFromObject(this.rootObject);
1483
+ if (box.isEmpty()) return;
1484
+ const center = new THREE.Vector3();
1485
+ box.getCenter(center);
1486
+ const size = new THREE.Vector3();
1487
+ box.getSize(size);
1488
+ const distance = Math.max(size.x, size.y, size.z, 1e-6);
1489
+ const cfg = VIEW_DIRECTIONS[view];
1490
+ if (!cfg) return;
1491
+ const targetPosition = this.calcViewPos(view, center, distance);
1492
+ const targetUp = new THREE.Vector3(cfg.up.x, cfg.up.y, cfg.up.z);
1493
+ this.animateCameraTransition(targetPosition, targetUp, center);
1494
+ }
1495
+ calcViewPos(view, center, distance) {
1496
+ switch (view) {
1497
+ case "TOP":
1498
+ return new THREE.Vector3(center.x, center.y, center.z + distance);
1499
+ case "BOTTOM":
1500
+ return new THREE.Vector3(center.x, center.y, center.z - distance);
1501
+ case "FRONT":
1502
+ return new THREE.Vector3(center.x, center.y + distance, center.z);
1503
+ case "BACK":
1504
+ return new THREE.Vector3(center.x, center.y - distance, center.z);
1505
+ case "LEFT":
1506
+ return new THREE.Vector3(center.x + distance, center.y, center.z);
1507
+ case "RIGHT":
1508
+ return new THREE.Vector3(center.x - distance, center.y, center.z);
1509
+ default:
1510
+ return center.clone();
1511
+ }
1512
+ }
1513
+ animateCameraTransition(targetPosition, targetUp, targetCenter) {
1514
+ const startPosition = this.camera.position.clone();
1515
+ const startUp = this.camera.up.clone();
1516
+ const startTarget = this.controls.target.clone();
1517
+ const startTime = Date.now();
1518
+ const duration = ANIMATION_CONFIG.DURATION_MS;
1519
+ const animate = () => {
1520
+ const elapsed = Date.now() - startTime;
1521
+ const progress = Math.min(elapsed / duration, 1);
1522
+ const eased = progress < ANIMATION_CONFIG.EASING_THRESHOLD ? ANIMATION_CONFIG.EASING_POWER * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2;
1523
+ this.camera.position.lerpVectors(startPosition, targetPosition, eased);
1524
+ this.camera.up.lerpVectors(startUp, targetUp, eased);
1525
+ this.controls.target.lerpVectors(startTarget, targetCenter, eased);
1526
+ this.camera.lookAt(this.controls.target);
1527
+ this.controls.update();
1528
+ if (progress < 1) requestAnimationFrame(animate);
1529
+ };
1530
+ animate();
1531
+ }
1532
+ dispose() {
1533
+ if (this.animationHandle !== null) {
1534
+ cancelAnimationFrame(this.animationHandle);
1535
+ this.animationHandle = null;
1536
+ }
1537
+ this.resizeObserver?.disconnect();
1538
+ this.controls.dispose();
1539
+ this.setRootObject(null);
1540
+ this.renderer.dispose();
1541
+ if (this.renderer.domElement.parentNode) {
1542
+ this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
1543
+ }
1544
+ }
1545
+ };
1546
+
1547
+ // src/dentalGeom/mockSegmentation.ts
1548
+ import {
1549
+ Box3 as Box33,
1550
+ BufferAttribute,
1551
+ Color as Color2,
1552
+ Mesh as Mesh2,
1553
+ MeshStandardMaterial as MeshStandardMaterial2,
1554
+ Vector3 as Vector33
1555
+ } from "three";
1556
+ function applyMockToothBandColors(geometry) {
1557
+ const pos = geometry.getAttribute("position");
1558
+ if (!pos) return;
1559
+ const n = pos.count;
1560
+ const colors = new Float32Array(n * 3);
1561
+ const tmp = new Vector33();
1562
+ const box = new Box33();
1563
+ for (let i = 0; i < n; i++) {
1564
+ tmp.fromBufferAttribute(pos, i);
1565
+ box.expandByPoint(tmp);
1566
+ }
1567
+ const size = new Vector33();
1568
+ box.getSize(size);
1569
+ const minY = box.min.y;
1570
+ for (let i = 0; i < n; i++) {
1571
+ const y = pos.getY(i);
1572
+ const t = size.y > 1e-9 ? (y - minY) / size.y : 0;
1573
+ const segment = Math.min(14, Math.floor(t * 15));
1574
+ const hue = segment / 15 * 0.85;
1575
+ const c = new Color2().setHSL(hue, 0.62, 0.52);
1576
+ colors[i * 3] = c.r;
1577
+ colors[i * 3 + 1] = c.g;
1578
+ colors[i * 3 + 2] = c.b;
1579
+ }
1580
+ geometry.setAttribute("color", new BufferAttribute(colors, 3));
1581
+ const colAttr = geometry.getAttribute("color");
1582
+ if (colAttr) colAttr.needsUpdate = true;
1583
+ }
1584
+ function enableVertexColorsOnObjectTree(root) {
1585
+ root.traverse((o) => {
1586
+ if (o instanceof Mesh2 && o.material) {
1587
+ const mat = o.material;
1588
+ if (Array.isArray(mat)) {
1589
+ for (const m of mat) {
1590
+ if (m instanceof MeshStandardMaterial2) {
1591
+ m.vertexColors = true;
1592
+ m.needsUpdate = true;
1593
+ }
1594
+ }
1595
+ } else if (mat instanceof MeshStandardMaterial2) {
1596
+ mat.vertexColors = true;
1597
+ mat.needsUpdate = true;
1598
+ }
1599
+ }
1600
+ });
1601
+ }
1602
+
1603
+ // src/components/MeshViewer.tsx
1604
+ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
1605
+ var VIEW_BUTTONS = [
1606
+ { key: "TOP", label: "Sopra" },
1607
+ { key: "BOTTOM", label: "Sotto" },
1608
+ { key: "FRONT", label: "Fronte" },
1609
+ { key: "BACK", label: "Retro" },
1610
+ { key: "LEFT", label: "Sin" },
1611
+ { key: "RIGHT", label: "Des" }
1612
+ ];
1613
+ function MeshViewer({
1614
+ meshDownloadUrl,
1615
+ selectedFile,
1616
+ segmentationBridge
1617
+ }) {
1618
+ const containerRef = useRef(null);
1619
+ const viewportRef = useRef(null);
1620
+ const backupRootRef = useRef(null);
1621
+ const sceneRootLiveRef = useRef(null);
1622
+ const [phase, setPhase] = useState4("idle");
1623
+ const [errorMsg, setErrorMsg] = useState4(null);
1624
+ const [confirmedLarge, setConfirmedLarge] = useState4(false);
1625
+ const [sceneRoot, setSceneRoot] = useState4(null);
1626
+ const [segmented, setSegmented] = useState4(false);
1627
+ const [segBusy, setSegBusy] = useState4(false);
1628
+ useEffect(() => {
1629
+ sceneRootLiveRef.current = sceneRoot;
1630
+ }, [sceneRoot]);
1631
+ useEffect(() => {
1632
+ return () => {
1633
+ if (sceneRootLiveRef.current) {
1634
+ disposeObject3DTree(sceneRootLiveRef.current);
1635
+ sceneRootLiveRef.current = null;
1636
+ }
1637
+ if (backupRootRef.current) {
1638
+ disposeObject3DTree(backupRootRef.current);
1639
+ backupRootRef.current = null;
1640
+ }
1641
+ };
1642
+ }, []);
1643
+ useEffect(() => {
1644
+ if (!selectedFile) {
1645
+ if (viewportRef.current) {
1646
+ viewportRef.current.dispose();
1647
+ viewportRef.current = null;
1648
+ }
1649
+ return;
1650
+ }
1651
+ const el = containerRef.current;
1652
+ if (!el) return;
1653
+ const vp = new DentalGeomIntakeViewport(el);
1654
+ viewportRef.current = vp;
1655
+ return () => {
1656
+ vp.dispose();
1657
+ viewportRef.current = null;
1658
+ };
1659
+ }, [selectedFile?._id]);
1660
+ useEffect(() => {
1661
+ setConfirmedLarge(false);
1662
+ setSegmented(false);
1663
+ setErrorMsg(null);
1664
+ setSceneRoot((prev) => {
1665
+ if (prev) disposeObject3DTree(prev);
1666
+ return null;
1667
+ });
1668
+ if (backupRootRef.current) {
1669
+ disposeObject3DTree(backupRootRef.current);
1670
+ backupRootRef.current = null;
1671
+ }
1672
+ if (!selectedFile) {
1673
+ setPhase("idle");
1674
+ return;
1675
+ }
1676
+ if (!isMeshViewerFormat(selectedFile.format)) {
1677
+ setPhase("error");
1678
+ setErrorMsg(
1679
+ `Formato \xAB${selectedFile.format}\xBB non supportato nel viewer (solo STL, PLY, OBJ).`
1680
+ );
1681
+ return;
1682
+ }
1683
+ if (!selectedFile.storageId) {
1684
+ setPhase("error");
1685
+ setErrorMsg(
1686
+ "Nessun file su storage per questa mesh (es. record solo mock senza upload reale)."
1687
+ );
1688
+ return;
1689
+ }
1690
+ if (selectedFile.sizeBytes > MESH_VIEWER_LARGE_BYTES) {
1691
+ setPhase("confirm_large");
1692
+ } else {
1693
+ setPhase("loading");
1694
+ }
1695
+ }, [selectedFile?._id]);
1696
+ useEffect(() => {
1697
+ if (!selectedFile || !isMeshViewerFormat(selectedFile.format)) return;
1698
+ if (!selectedFile.storageId) return;
1699
+ if (selectedFile.sizeBytes > MESH_VIEWER_LARGE_BYTES && !confirmedLarge) {
1700
+ return;
1701
+ }
1702
+ if (meshDownloadUrl === void 0) {
1703
+ setPhase("loading");
1704
+ return;
1705
+ }
1706
+ if (meshDownloadUrl === null) {
1707
+ setPhase("error");
1708
+ setErrorMsg(
1709
+ "URL di download non disponibile. L\u2019acquisizione potrebbe essere archiviata oppure il file non ha storage."
1710
+ );
1711
+ return;
1712
+ }
1713
+ let cancelled = false;
1714
+ (async () => {
1715
+ try {
1716
+ setPhase("loading");
1717
+ setErrorMsg(null);
1718
+ const res = await fetch(meshDownloadUrl);
1719
+ if (!res.ok) {
1720
+ throw new Error(`Download fallito (HTTP ${res.status}).`);
1721
+ }
1722
+ const buf = await res.arrayBuffer();
1723
+ if (cancelled) return;
1724
+ const loaded = loadMeshByFormat(selectedFile.format, buf);
1725
+ const wrapped = wrapLoadedMeshRoot(loaded);
1726
+ if (backupRootRef.current) {
1727
+ disposeObject3DTree(backupRootRef.current);
1728
+ }
1729
+ backupRootRef.current = wrapped.clone(true);
1730
+ const normalized = wrapped.clone(true);
1731
+ const fmt = selectedFile.format.toLowerCase();
1732
+ if (fmt === "stl") {
1733
+ normalized.rotation.x = Math.PI;
1734
+ }
1735
+ normalizeMeshRoot(normalized, 2);
1736
+ if (cancelled) {
1737
+ disposeObject3DTree(normalized);
1738
+ return;
1739
+ }
1740
+ setSceneRoot((prev) => {
1741
+ if (prev) disposeObject3DTree(prev);
1742
+ return normalized;
1743
+ });
1744
+ setPhase("ready");
1745
+ } catch (e) {
1746
+ if (cancelled) return;
1747
+ setPhase("error");
1748
+ setErrorMsg(
1749
+ e instanceof Error ? e.message : "Errore durante il caricamento della mesh."
1750
+ );
1751
+ }
1752
+ })();
1753
+ return () => {
1754
+ cancelled = true;
1755
+ };
1756
+ }, [
1757
+ selectedFile?._id,
1758
+ selectedFile?.format,
1759
+ selectedFile?.storageId,
1760
+ selectedFile?.sizeBytes,
1761
+ meshDownloadUrl,
1762
+ confirmedLarge
1763
+ ]);
1764
+ useEffect(() => {
1765
+ const vp = viewportRef.current;
1766
+ if (!vp || phase !== "ready" || !sceneRoot) {
1767
+ vp?.setRootObject(null);
1768
+ return;
1769
+ }
1770
+ vp.setRootObject(sceneRoot);
1771
+ vp.centerCameraOnObject();
1772
+ }, [phase, sceneRoot]);
1773
+ const handleRecentrar = () => {
1774
+ const raw = backupRootRef.current;
1775
+ if (!raw || !selectedFile) return;
1776
+ setSegmented(false);
1777
+ setSceneRoot((prev) => {
1778
+ if (prev) disposeObject3DTree(prev);
1779
+ const next = raw.clone(true);
1780
+ if (selectedFile.format.toLowerCase() === "stl") {
1781
+ next.rotation.x = Math.PI;
1782
+ }
1783
+ normalizeMeshRoot(next, 2);
1784
+ return next;
1785
+ });
1786
+ };
1787
+ const handleView = (v) => {
1788
+ viewportRef.current?.setCameraView(v);
1789
+ };
1790
+ const runSegmentation = async () => {
1791
+ const sf = selectedFile;
1792
+ if (!sf || !backupRootRef.current) return;
1793
+ if (segmentationBridge?.segmentMesh && (!meshDownloadUrl || meshDownloadUrl === null)) {
1794
+ setErrorMsg("URL mesh mancante per segmentazione remota.");
1795
+ return;
1796
+ }
1797
+ setSegBusy(true);
1798
+ setErrorMsg(null);
1799
+ try {
1800
+ let rgb = null;
1801
+ if (segmentationBridge?.segmentMesh && meshDownloadUrl) {
1802
+ const res = await fetch(meshDownloadUrl);
1803
+ if (!res.ok) throw new Error(`Segmentazione: download HTTP ${res.status}`);
1804
+ const buf = await res.arrayBuffer();
1805
+ rgb = await segmentationBridge.segmentMesh({
1806
+ arrayBuffer: buf,
1807
+ format: sf.format,
1808
+ fileName: sf.originalName
1809
+ });
1810
+ }
1811
+ const next = backupRootRef.current.clone(true);
1812
+ if (sf.format.toLowerCase() === "stl") {
1813
+ next.rotation.x = Math.PI;
1814
+ }
1815
+ normalizeMeshRoot(next, 2);
1816
+ let remaining = rgb && rgb.length > 0 ? rgb : null;
1817
+ next.traverse((o) => {
1818
+ if (o instanceof Mesh3 && o.geometry) {
1819
+ const geo = o.geometry;
1820
+ const pos = geo.getAttribute("position");
1821
+ const need = pos ? pos.count * 3 : 0;
1822
+ if (remaining !== null && need > 0 && remaining.length >= need) {
1823
+ const chunk = remaining.subarray(0, need);
1824
+ const copy = new Float32Array(chunk);
1825
+ geo.setAttribute("color", new BufferAttribute2(copy, 3));
1826
+ remaining = remaining.subarray(need);
1827
+ } else {
1828
+ applyMockToothBandColors(geo);
1829
+ }
1830
+ }
1831
+ });
1832
+ enableVertexColorsOnObjectTree(next);
1833
+ setSceneRoot((prev) => {
1834
+ if (prev) disposeObject3DTree(prev);
1835
+ return next;
1836
+ });
1837
+ setSegmented(true);
1838
+ } catch (e) {
1839
+ setErrorMsg(
1840
+ e instanceof Error ? e.message : "Segmentazione non riuscita."
1841
+ );
1842
+ } finally {
1843
+ setSegBusy(false);
1844
+ }
1845
+ };
1846
+ if (!selectedFile) {
1847
+ return /* @__PURE__ */ jsxs9("div", { className: "preview-3d preview-3d-empty", children: [
1848
+ /* @__PURE__ */ jsx10("strong", { children: "Anteprima 3D" }),
1849
+ /* @__PURE__ */ jsx10("span", { children: "Seleziona una mesh (STL, PLY, OBJ) dalla lista o dal pannello review." })
1850
+ ] });
1851
+ }
1852
+ return /* @__PURE__ */ jsxs9("div", { className: "mesh-viewer-wrap", children: [
1853
+ /* @__PURE__ */ jsxs9("div", { className: "mesh-viewer-toolbar", children: [
1854
+ /* @__PURE__ */ jsxs9("span", { className: "muted", style: { fontSize: "0.8rem", marginRight: "0.5rem" }, children: [
1855
+ "Viewer",
1856
+ " ",
1857
+ /* @__PURE__ */ jsx10(
1858
+ "a",
1859
+ {
1860
+ href: "https://github.com/Ellipsis1/DentalGeom",
1861
+ target: "_blank",
1862
+ rel: "noopener noreferrer",
1863
+ style: { color: "#93c5fd" },
1864
+ children: "DentalGeom"
1865
+ }
1866
+ ),
1867
+ "-style"
1868
+ ] }),
1869
+ VIEW_BUTTONS.map(({ key, label }) => /* @__PURE__ */ jsx10(
1870
+ "button",
1871
+ {
1872
+ type: "button",
1873
+ className: "btn btn-ghost",
1874
+ disabled: !sceneRoot,
1875
+ onClick: () => handleView(key),
1876
+ children: label
1877
+ },
1878
+ key
1879
+ )),
1880
+ /* @__PURE__ */ jsx10(
1881
+ "button",
1882
+ {
1883
+ type: "button",
1884
+ className: "btn btn-ghost",
1885
+ disabled: !sceneRoot,
1886
+ onClick: () => viewportRef.current?.centerCameraOnObject(),
1887
+ children: "Centra camera"
1888
+ }
1889
+ ),
1890
+ /* @__PURE__ */ jsx10(
1891
+ "button",
1892
+ {
1893
+ type: "button",
1894
+ className: "btn btn-ghost",
1895
+ disabled: !sceneRoot,
1896
+ onClick: handleRecentrar,
1897
+ children: "Reset modello"
1898
+ }
1899
+ ),
1900
+ /* @__PURE__ */ jsx10(
1901
+ "button",
1902
+ {
1903
+ type: "button",
1904
+ className: "btn btn-primary",
1905
+ disabled: !sceneRoot || segBusy || phase !== "ready",
1906
+ title: segmentationBridge?.segmentMesh ? "Segmentazione tramite bridge host (es. MeshSegNet worker)" : "Demo locale (bande color)\u2014non \xE8 MeshSegNet clinico",
1907
+ onClick: () => void runSegmentation(),
1908
+ children: segBusy ? "Segmentazione\u2026" : segmented ? "Ri-segmenta" : "Segmenta denti"
1909
+ }
1910
+ )
1911
+ ] }),
1912
+ /* @__PURE__ */ jsxs9("p", { className: "muted", style: { fontSize: "0.76rem", margin: "0.25rem 0" }, children: [
1913
+ "MeshSegNet (",
1914
+ /* @__PURE__ */ jsx10(
1915
+ "a",
1916
+ {
1917
+ href: "https://github.com/Tai-Hsien/MeshSegNet",
1918
+ target: "_blank",
1919
+ rel: "noopener noreferrer",
1920
+ children: "Tai-Hsien/MeshSegNet"
1921
+ }
1922
+ ),
1923
+ ") \xE8 PyTorch: esporre un servizio esterno e passare",
1924
+ " ",
1925
+ /* @__PURE__ */ jsx10("code", { children: "segmentationBridge" }),
1926
+ " dall\u2019host, oppure usare la demo colori locale."
1927
+ ] }),
1928
+ phase === "confirm_large" ? /* @__PURE__ */ jsxs9("div", { className: "warning-box", style: { marginBottom: "0.65rem" }, children: [
1929
+ "File di ",
1930
+ (selectedFile.sizeBytes / (1024 * 1024)).toFixed(1),
1931
+ " MB: il caricamento pu\xF2 essere lento o instabile nel browser. Continuare?",
1932
+ /* @__PURE__ */ jsx10("div", { className: "btn-row", style: { marginTop: "0.5rem" }, children: /* @__PURE__ */ jsx10(
1933
+ "button",
1934
+ {
1935
+ type: "button",
1936
+ className: "btn btn-primary",
1937
+ onClick: () => setConfirmedLarge(true),
1938
+ children: "Carica nel viewer"
1939
+ }
1940
+ ) })
1941
+ ] }) : null,
1942
+ phase === "loading" ? /* @__PURE__ */ jsx10("p", { className: "muted", style: { margin: "0.35rem 0 0.5rem" }, children: "Caricamento mesh\u2026" }) : null,
1943
+ errorMsg ? /* @__PURE__ */ jsx10("div", { className: "err-banner", children: errorMsg }) : null,
1944
+ /* @__PURE__ */ jsx10(
1945
+ "div",
1946
+ {
1947
+ ref: containerRef,
1948
+ className: "mesh-viewer-canvas dentalgeom-viewport",
1949
+ style: { minHeight: 420, width: "100%", borderRadius: 12 }
1950
+ }
1951
+ )
1952
+ ] });
1953
+ }
1954
+
1955
+ // src/components/MeshFileSelector.tsx
1956
+ import { jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
1957
+ function MeshFileSelector({
1958
+ files,
1959
+ selectedId,
1960
+ onSelect
1961
+ }) {
1962
+ const meshes = files?.filter((f) => isMeshViewerFormat(f.format)) ?? [];
1963
+ if (meshes.length === 0) {
1964
+ return /* @__PURE__ */ jsx11("p", { className: "muted", style: { marginBottom: "0.75rem" }, children: "Nessuna mesh STL/PLY/OBJ in questa acquisizione. Carica file reali o usa il seed demo con geometrie mock." });
1965
+ }
1966
+ return /* @__PURE__ */ jsxs10("div", { className: "mesh-selector", style: { marginBottom: "0.85rem" }, children: [
1967
+ /* @__PURE__ */ jsxs10("div", { className: "muted", style: { marginBottom: "0.45rem", fontSize: "0.85rem" }, children: [
1968
+ "Mesh disponibili (",
1969
+ meshes.length,
1970
+ ")"
1971
+ ] }),
1972
+ /* @__PURE__ */ jsx11("div", { className: "mesh-selector-list", children: meshes.map((f) => {
1973
+ const active = f._id === selectedId;
1974
+ const large = f.sizeBytes > MESH_VIEWER_LARGE_BYTES;
1975
+ return /* @__PURE__ */ jsxs10(
1976
+ "button",
1977
+ {
1978
+ type: "button",
1979
+ className: `mesh-selector-item ${active ? "mesh-selector-item-active" : ""}`,
1980
+ onClick: () => onSelect(f),
1981
+ children: [
1982
+ /* @__PURE__ */ jsx11("div", { style: { fontWeight: 600 }, children: f.originalName }),
1983
+ /* @__PURE__ */ jsxs10("div", { className: "btn-row", style: { marginTop: "0.35rem", gap: "0.35rem" }, children: [
1984
+ /* @__PURE__ */ jsx11(SmallBadge, { variant: "role", children: f.role }),
1985
+ /* @__PURE__ */ jsx11(SmallBadge, { variant: "format", children: f.format }),
1986
+ /* @__PURE__ */ jsxs10("span", { className: "muted", style: { fontSize: "0.75rem" }, children: [
1987
+ (f.sizeBytes / 1024).toFixed(0),
1988
+ " KB",
1989
+ large ? " \xB7 grande" : ""
1990
+ ] })
1991
+ ] })
1992
+ ]
1993
+ },
1994
+ f._id
1995
+ );
1996
+ }) })
1997
+ ] });
1998
+ }
1999
+
2000
+ // src/components/DigitalAcquisitionDetail.tsx
2001
+ import { Fragment as Fragment3, jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
2002
+ function readinessText(k) {
2003
+ if (!k) return "\u2014";
2004
+ return readinessLabelUi[k];
2005
+ }
2006
+ function DigitalAcquisitionDetail({
2007
+ acquisitionId,
2008
+ onBack,
2009
+ doc,
2010
+ files,
2011
+ events,
2012
+ manifest,
2013
+ packageDownloadUrl,
2014
+ meshDownloadUrl,
2015
+ meshSegmentationBridge,
2016
+ pipeline,
2017
+ upload,
2018
+ review,
2019
+ renderFileListActions,
2020
+ previewMeshId,
2021
+ onPreviewMeshIdChange
2022
+ }) {
2023
+ const [patientId, setPatientId] = useState5("");
2024
+ const [prescriptionId, setPrescriptionId] = useState5("");
2025
+ const [labWorkOrderId, setLabWorkOrderId] = useState5("");
2026
+ const [customerId, setCustomerId] = useState5("");
2027
+ const [externalCaseId, setExternalCaseId] = useState5("");
2028
+ const [err, setErr] = useState5(null);
2029
+ const [busy, setBusy] = useState5(false);
2030
+ const viewableMeshCount = useMemo(
2031
+ () => (files ?? []).filter((f) => isMeshViewerFormat(f.format)).length,
2032
+ [files]
2033
+ );
2034
+ const proprietary3dFiles = useMemo(
2035
+ () => (files ?? []).filter((f) => {
2036
+ const fmt = f.format.toLowerCase();
2037
+ const ext = f.extension.toLowerCase();
2038
+ return ["dcm", "3ml"].includes(fmt) || ["dcm", "3ml"].includes(ext);
2039
+ }),
2040
+ [files]
2041
+ );
2042
+ const selectedMeshFile = useMemo(() => {
2043
+ if (!previewMeshId || !files?.length) return null;
2044
+ const f = files.find((x) => x._id === previewMeshId);
2045
+ if (!f) return null;
2046
+ return {
2047
+ _id: f._id,
2048
+ originalName: f.originalName,
2049
+ format: f.format,
2050
+ sizeBytes: f.sizeBytes,
2051
+ role: f.role,
2052
+ storageId: f.storageId
2053
+ };
2054
+ }, [previewMeshId, files]);
2055
+ const run = async (fn) => {
2056
+ setErr(null);
2057
+ setBusy(true);
2058
+ try {
2059
+ await fn();
2060
+ } catch (e) {
2061
+ setErr(e instanceof Error ? e.message : "Errore");
2062
+ } finally {
2063
+ setBusy(false);
2064
+ }
2065
+ };
2066
+ if (!doc) {
2067
+ return /* @__PURE__ */ jsx12("div", { className: "panel", children: /* @__PURE__ */ jsx12("p", { className: "muted", children: "Caricamento dettaglio\u2026" }) });
2068
+ }
2069
+ const extraction = doc.extractionStatus;
2070
+ const extractionError = doc.extractionError;
2071
+ const validationProfile = doc.validationProfile;
2072
+ const validationResult = doc.validationResult;
2073
+ const originalPackageName = doc.originalPackageName;
2074
+ const originalPackageSizeBytes = doc.originalPackageSizeBytes;
2075
+ const hasOriginalPackage = Boolean(doc.originalPackageStorageId);
2076
+ const terminal = doc.status === "archived" || doc.status === "failed";
2077
+ const canMutate = !terminal;
2078
+ const canBeginUpload = canMutate && doc.status === "draft";
2079
+ const canMarkUploaded = canMutate && doc.status === "waiting_files";
2080
+ const extractionBlocksParse = extraction === "extracting" || extraction === "failed";
2081
+ const canParse = canMutate && doc.status === "uploaded" && !extractionBlocksParse;
2082
+ const canValidate = canMutate && Boolean(manifest?.readiness.canValidate);
2083
+ const canMarkReady = canMutate && Boolean(manifest?.validation.canMarkReady);
2084
+ const canArchive = canMutate && doc.status === "ready_for_next_step";
2085
+ const canFail = canMutate && doc.status !== "failed" && doc.status !== "archived";
2086
+ const markReadyLabel = validationResult?.readinessLabel === "ready_for_lab" ? "5 \xB7 Ready for Lab" : validationResult?.readinessLabel === "ready_for_cad" ? "5 \xB7 Ready for CAD" : "5 \xB7 Ready for Next Step";
2087
+ return /* @__PURE__ */ jsxs11("div", { children: [
2088
+ /* @__PURE__ */ jsx12("div", { className: "btn-row", style: { marginBottom: "1rem" }, children: /* @__PURE__ */ jsx12("button", { type: "button", className: "btn btn-ghost", onClick: onBack, children: "\u2190 Dashboard" }) }),
2089
+ /* @__PURE__ */ jsxs11("div", { className: "panel", style: { marginBottom: "1rem" }, children: [
2090
+ /* @__PURE__ */ jsx12("div", { className: "btn-row", style: { marginBottom: "0.75rem" }, children: /* @__PURE__ */ jsx12(StatusBadge, { status: doc.status }) }),
2091
+ /* @__PURE__ */ jsx12("h2", { style: { margin: "0 0 0.5rem" }, children: doc.title }),
2092
+ /* @__PURE__ */ jsxs11("p", { className: "muted", style: { margin: 0 }, children: [
2093
+ originLabel(doc.origin),
2094
+ " \xB7 ",
2095
+ hostAppLabel(doc.hostApp),
2096
+ " \xB7 Review:",
2097
+ " ",
2098
+ doc.reviewStatus,
2099
+ " \xB7 Vendor ",
2100
+ vendorLabel(doc.sourceVendor),
2101
+ " (",
2102
+ doc.vendorConfidence.toFixed(2),
2103
+ ")"
2104
+ ] }),
2105
+ doc.warnings?.length ? /* @__PURE__ */ jsxs11("div", { className: "warning-box", children: [
2106
+ /* @__PURE__ */ jsx12("strong", { children: "Warning" }),
2107
+ /* @__PURE__ */ jsx12("div", { style: { marginTop: "0.35rem" }, children: doc.warnings.join(" \xB7 ") })
2108
+ ] }) : null,
2109
+ doc.sourceAdapterType ?? doc.sourceProviderId ?? doc.externalCaseId ?? doc.externalPatientId ?? (doc.externalReferences && typeof doc.externalReferences === "object" && Object.keys(doc.externalReferences).length > 0) ? /* @__PURE__ */ jsxs11(
2110
+ "div",
2111
+ {
2112
+ style: {
2113
+ marginTop: "0.75rem",
2114
+ padding: "0.65rem 0.85rem",
2115
+ borderRadius: 12,
2116
+ background: "rgba(59, 130, 246, 0.06)",
2117
+ border: "1px solid rgba(59, 130, 246, 0.2)",
2118
+ fontSize: "0.82rem"
2119
+ },
2120
+ children: [
2121
+ /* @__PURE__ */ jsx12("strong", { style: { color: "#93c5fd" }, children: "Riferimenti esterni / integrazioni" }),
2122
+ /* @__PURE__ */ jsxs11("div", { className: "muted", style: { marginTop: "0.35rem" }, children: [
2123
+ doc.sourceAdapterType ? /* @__PURE__ */ jsxs11(Fragment3, { children: [
2124
+ "Adapter:",
2125
+ " ",
2126
+ /* @__PURE__ */ jsx12("code", { children: doc.sourceAdapterType }),
2127
+ /* @__PURE__ */ jsx12("br", {})
2128
+ ] }) : null,
2129
+ doc.sourceProviderId ? /* @__PURE__ */ jsxs11(Fragment3, { children: [
2130
+ "Provider id:",
2131
+ " ",
2132
+ /* @__PURE__ */ jsx12("code", { style: { wordBreak: "break-all" }, children: doc.sourceProviderId }),
2133
+ /* @__PURE__ */ jsx12("br", {})
2134
+ ] }) : null,
2135
+ doc.externalCaseId ? /* @__PURE__ */ jsxs11(Fragment3, { children: [
2136
+ "External case id:",
2137
+ " ",
2138
+ /* @__PURE__ */ jsx12("code", { children: doc.externalCaseId }),
2139
+ /* @__PURE__ */ jsx12("br", {})
2140
+ ] }) : null,
2141
+ doc.externalPatientId ? /* @__PURE__ */ jsxs11(Fragment3, { children: [
2142
+ "External patient id: ",
2143
+ /* @__PURE__ */ jsx12("code", { children: doc.externalPatientId }),
2144
+ /* @__PURE__ */ jsx12("br", {})
2145
+ ] }) : null,
2146
+ doc.linkedEntity.externalCaseId && !doc.externalCaseId ? /* @__PURE__ */ jsxs11(Fragment3, { children: [
2147
+ "Linked external case id:",
2148
+ " ",
2149
+ /* @__PURE__ */ jsx12("code", { children: doc.linkedEntity.externalCaseId }),
2150
+ /* @__PURE__ */ jsx12("br", {})
2151
+ ] }) : null
2152
+ ] }),
2153
+ doc.externalReferences && typeof doc.externalReferences === "object" && Object.keys(doc.externalReferences).length > 0 ? /* @__PURE__ */ jsxs11("div", { style: { marginTop: "0.5rem" }, children: [
2154
+ /* @__PURE__ */ jsx12("div", { className: "muted", style: { marginBottom: "0.25rem" }, children: /* @__PURE__ */ jsx12("code", { style: { fontSize: "0.78rem" }, children: "externalReferences" }) }),
2155
+ /* @__PURE__ */ jsx12(
2156
+ "pre",
2157
+ {
2158
+ className: "muted",
2159
+ style: {
2160
+ margin: 0,
2161
+ maxHeight: 220,
2162
+ overflow: "auto",
2163
+ fontSize: "0.75rem",
2164
+ lineHeight: 1.35,
2165
+ whiteSpace: "pre-wrap",
2166
+ wordBreak: "break-word"
2167
+ },
2168
+ children: JSON.stringify(doc.externalReferences, null, 2)
2169
+ }
2170
+ )
2171
+ ] }) : null
2172
+ ]
2173
+ }
2174
+ ) : null,
2175
+ terminal ? /* @__PURE__ */ jsx12("p", { className: "muted", style: { marginTop: "0.75rem", marginBottom: 0 }, children: "Stato terminale: modifiche e upload disabilitati. Solo consultazione manifest e timeline." }) : null
2176
+ ] }),
2177
+ /* @__PURE__ */ jsxs11("div", { className: "panel", style: { marginBottom: "1rem" }, children: [
2178
+ /* @__PURE__ */ jsx12("h3", { style: { marginTop: 0 }, children: "Validazione (engine lato server)" }),
2179
+ /* @__PURE__ */ jsxs11("p", { className: "muted", style: { marginTop: 0 }, children: [
2180
+ "Profilo:",
2181
+ " ",
2182
+ /* @__PURE__ */ jsx12("strong", { children: validationProfile ? validationProfileLabel[validationProfile] : "\u2014" }),
2183
+ " ",
2184
+ /* @__PURE__ */ jsxs11("code", { style: { fontSize: "0.8rem" }, children: [
2185
+ "(",
2186
+ validationProfile ?? "n/d",
2187
+ ")"
2188
+ ] })
2189
+ ] }),
2190
+ /* @__PURE__ */ jsxs11("p", { className: "muted", children: [
2191
+ "Readiness se validazione OK:",
2192
+ " ",
2193
+ /* @__PURE__ */ jsx12("strong", { children: manifest?.validation.readinessLabel ? readinessText(manifest.validation.readinessLabel) : "\u2014" })
2194
+ ] }),
2195
+ validationResult ? /* @__PURE__ */ jsxs11(Fragment3, { children: [
2196
+ /* @__PURE__ */ jsxs11("p", { className: "muted", style: { marginBottom: "0.35rem" }, children: [
2197
+ "Ultimo esito:",
2198
+ " ",
2199
+ /* @__PURE__ */ jsx12("strong", { children: validationResult.isValid ? "valido" : "non valido" }),
2200
+ " ",
2201
+ "\xB7 ",
2202
+ new Date(validationResult.checkedAt).toLocaleString()
2203
+ ] }),
2204
+ validationResult.blockingIssues.length ? /* @__PURE__ */ jsxs11("div", { className: "err-banner", children: [
2205
+ /* @__PURE__ */ jsx12("strong", { children: "Blocking (ultimo run)" }),
2206
+ /* @__PURE__ */ jsx12("ul", { style: { margin: "0.35rem 0 0", paddingLeft: "1.1rem" }, children: validationResult.blockingIssues.map((x) => /* @__PURE__ */ jsx12("li", { children: x }, x)) })
2207
+ ] }) : null,
2208
+ validationResult.warnings.length ? /* @__PURE__ */ jsxs11("div", { className: "warning-box", style: { marginTop: "0.5rem" }, children: [
2209
+ /* @__PURE__ */ jsx12("strong", { children: "Warning validazione" }),
2210
+ /* @__PURE__ */ jsx12("ul", { style: { margin: "0.35rem 0 0", paddingLeft: "1.1rem" }, children: validationResult.warnings.map((x) => /* @__PURE__ */ jsx12("li", { children: x }, x)) })
2211
+ ] }) : null
2212
+ ] }) : /* @__PURE__ */ jsx12("p", { className: "muted", children: "Nessuna validazione formale registrata." }),
2213
+ manifest && (manifest.validation.blockingIssues.length > 0 || manifest.validation.warnings.length > 0) ? /* @__PURE__ */ jsxs11("div", { style: { marginTop: "0.65rem" }, children: [
2214
+ /* @__PURE__ */ jsx12("strong", { className: "muted", children: "Snapshot engine (manifest)" }),
2215
+ manifest.validation.blockingIssues.length ? /* @__PURE__ */ jsx12(
2216
+ "ul",
2217
+ {
2218
+ className: "muted",
2219
+ style: { margin: "0.35rem 0 0", paddingLeft: "1.1rem" },
2220
+ children: manifest.validation.blockingIssues.map((x) => /* @__PURE__ */ jsx12("li", { children: x }, x))
2221
+ }
2222
+ ) : null,
2223
+ manifest.validation.warnings.length ? /* @__PURE__ */ jsx12(
2224
+ "ul",
2225
+ {
2226
+ className: "muted",
2227
+ style: { margin: "0.35rem 0 0", paddingLeft: "1.1rem" },
2228
+ children: manifest.validation.warnings.map((x) => /* @__PURE__ */ jsx12("li", { children: x }, x))
2229
+ }
2230
+ ) : null
2231
+ ] }) : null
2232
+ ] }),
2233
+ hasOriginalPackage ? /* @__PURE__ */ jsxs11("div", { className: "panel", style: { marginBottom: "1rem" }, children: [
2234
+ /* @__PURE__ */ jsx12("h3", { style: { marginTop: 0 }, children: "Pacchetto caricato" }),
2235
+ /* @__PURE__ */ jsxs11("p", { className: "muted", style: { marginTop: 0 }, children: [
2236
+ /* @__PURE__ */ jsx12("strong", { children: originalPackageName ?? "\u2014" }),
2237
+ originalPackageSizeBytes != null ? ` \xB7 ${(originalPackageSizeBytes / 1024).toFixed(1)} KB` : null
2238
+ ] }),
2239
+ /* @__PURE__ */ jsxs11("p", { className: "muted", style: { margin: "0.35rem 0" }, children: [
2240
+ "Estrazione: ",
2241
+ /* @__PURE__ */ jsx12("code", { children: extraction ?? "n/d" }),
2242
+ extraction === "extracting" ? " \u2014 attendi il completamento sull\u2019action Convex." : null
2243
+ ] }),
2244
+ extractionError ? /* @__PURE__ */ jsx12("div", { className: "err-banner", style: { marginTop: "0.5rem" }, children: extractionError }) : null,
2245
+ packageDownloadUrl ? /* @__PURE__ */ jsx12(
2246
+ "a",
2247
+ {
2248
+ href: packageDownloadUrl,
2249
+ target: "_blank",
2250
+ rel: "noopener noreferrer",
2251
+ className: "btn btn-ghost",
2252
+ style: { display: "inline-block", marginTop: "0.5rem" },
2253
+ children: "Scarica pacchetto originale"
2254
+ }
2255
+ ) : packageDownloadUrl === void 0 && hasOriginalPackage ? /* @__PURE__ */ jsx12("p", { className: "muted", style: { marginBottom: 0 }, children: "Recupero URL download\u2026" }) : null
2256
+ ] }) : null,
2257
+ manifest?.cadProject ? /* @__PURE__ */ jsx12(
2258
+ CaseSummaryCard,
2259
+ {
2260
+ cadProject: manifest.cadProject,
2261
+ fallbackTitle: doc.title
2262
+ }
2263
+ ) : null,
2264
+ manifest?.cadProject ? /* @__PURE__ */ jsx12(
2265
+ CadProjectMetadataPanel,
2266
+ {
2267
+ cadProject: manifest.cadProject,
2268
+ manifestFiles: manifest.files
2269
+ }
2270
+ ) : null,
2271
+ err ? /* @__PURE__ */ jsx12("div", { className: "err-banner", children: err }) : null,
2272
+ /* @__PURE__ */ jsxs11("div", { className: "panel", style: { marginBottom: "1rem" }, children: [
2273
+ /* @__PURE__ */ jsx12("h3", { style: { marginTop: 0 }, children: "Azioni pipeline (enforce server-side)" }),
2274
+ /* @__PURE__ */ jsxs11("p", { className: "muted", style: { marginTop: 0 }, children: [
2275
+ "Stato attuale: ",
2276
+ /* @__PURE__ */ jsx12("strong", { children: doc.status }),
2277
+ ". I pulsanti disabilitati non sono ammessi dal backend in questa fase."
2278
+ ] }),
2279
+ /* @__PURE__ */ jsxs11("div", { className: "tool-grid", children: [
2280
+ /* @__PURE__ */ jsx12(
2281
+ "button",
2282
+ {
2283
+ type: "button",
2284
+ className: "btn btn-primary",
2285
+ disabled: busy || !canBeginUpload,
2286
+ title: canBeginUpload ? "Avvia sessione upload" : "Solo da draft e se non terminale",
2287
+ onClick: () => void run(() => pipeline.beginUpload()),
2288
+ children: "1 \xB7 Inizia upload"
2289
+ }
2290
+ ),
2291
+ /* @__PURE__ */ jsx12(
2292
+ "button",
2293
+ {
2294
+ type: "button",
2295
+ className: "btn btn-primary",
2296
+ disabled: busy || !canMarkUploaded,
2297
+ title: canMarkUploaded ? "Segna upload completato" : "Serve waiting_files e almeno un file",
2298
+ onClick: () => void run(() => pipeline.markUploaded()),
2299
+ children: "2 \xB7 Upload completato"
2300
+ }
2301
+ ),
2302
+ /* @__PURE__ */ jsx12(
2303
+ "button",
2304
+ {
2305
+ type: "button",
2306
+ className: "btn btn-primary",
2307
+ disabled: busy || !canParse,
2308
+ title: canParse ? "Lancia parsing server" : extraction === "extracting" ? "Estrazione ZIP in corso" : extraction === "failed" ? "Estrazione fallita" : "Serve stato uploaded",
2309
+ onClick: () => void run(() => pipeline.parse()),
2310
+ children: "3 \xB7 Parsing"
2311
+ }
2312
+ ),
2313
+ /* @__PURE__ */ jsx12(
2314
+ "button",
2315
+ {
2316
+ type: "button",
2317
+ className: "btn btn-primary",
2318
+ disabled: busy || !canValidate,
2319
+ title: canValidate ? "Validazione server-only" : manifest?.readiness.missingRequirements?.length ? manifest.readiness.missingRequirements.join(" \xB7 ") : "Stato non ammesso per validazione",
2320
+ onClick: () => void run(() => pipeline.validate()),
2321
+ children: "4 \xB7 Valida acquisizione"
2322
+ }
2323
+ ),
2324
+ /* @__PURE__ */ jsx12(
2325
+ "button",
2326
+ {
2327
+ type: "button",
2328
+ className: "btn btn-primary",
2329
+ disabled: busy || !canMarkReady,
2330
+ title: canMarkReady ? manifest?.validation.nextStepSemanticLabel ? readinessText(
2331
+ manifest.validation.nextStepSemanticLabel
2332
+ ) : "Ready" : "Serve validated + validationResult valido dal server",
2333
+ onClick: () => void run(() => pipeline.markReady()),
2334
+ children: markReadyLabel
2335
+ }
2336
+ ),
2337
+ /* @__PURE__ */ jsx12(
2338
+ "button",
2339
+ {
2340
+ type: "button",
2341
+ className: "btn btn-ghost",
2342
+ disabled: busy || !canArchive,
2343
+ title: canArchive ? "Archivia" : "Serve ready_for_next_step",
2344
+ onClick: () => void run(() => pipeline.archive()),
2345
+ children: "Archivia"
2346
+ }
2347
+ ),
2348
+ /* @__PURE__ */ jsx12(
2349
+ "button",
2350
+ {
2351
+ type: "button",
2352
+ className: "btn btn-danger",
2353
+ disabled: busy || !canFail,
2354
+ title: canFail ? "Imposta stato failed" : "Non disponibile (terminale)",
2355
+ onClick: () => void run(
2356
+ () => pipeline.fail("Segnalazione errore da UI example")
2357
+ ),
2358
+ children: "Segna failed"
2359
+ }
2360
+ )
2361
+ ] })
2362
+ ] }),
2363
+ /* @__PURE__ */ jsxs11("div", { className: "detail-layout", children: [
2364
+ /* @__PURE__ */ jsxs11("div", { children: [
2365
+ (doc.status === "draft" || doc.status === "waiting_files") && canMutate && /* @__PURE__ */ jsx12("div", { className: "panel", style: { marginBottom: "1rem" }, children: /* @__PURE__ */ jsx12(UploadPanel, { upload }) }),
2366
+ /* @__PURE__ */ jsxs11("div", { className: "panel", style: { marginBottom: "1rem" }, children: [
2367
+ /* @__PURE__ */ jsx12("h3", { style: { marginTop: 0 }, children: "File" }),
2368
+ !files?.length ? /* @__PURE__ */ jsx12("p", { className: "muted", children: "Nessun file associato. Usa l'upload reale quando la pipeline lo consente, oppure i mock dalla dashboard se l'MVP lo prevede." }) : /* @__PURE__ */ jsx12(
2369
+ FileList,
2370
+ {
2371
+ files,
2372
+ renderFileActions: renderFileListActions,
2373
+ onVisualizeMesh: (f) => onPreviewMeshIdChange(f._id)
2374
+ }
2375
+ )
2376
+ ] }),
2377
+ doc.status === "needs_review" && canMutate && /* @__PURE__ */ jsx12("div", { className: "panel", style: { marginBottom: "1rem" }, children: /* @__PURE__ */ jsx12(
2378
+ ReviewPanel,
2379
+ {
2380
+ acquisitionId,
2381
+ review,
2382
+ files: files?.map((f) => ({
2383
+ _id: f._id,
2384
+ originalName: f.originalName,
2385
+ role: f.role,
2386
+ format: f.format,
2387
+ storageId: f.storageId
2388
+ })),
2389
+ onPreviewMesh: (id) => onPreviewMeshIdChange(id)
2390
+ },
2391
+ files?.map(
2392
+ (f) => `${f._id}:${f.role}`
2393
+ ).join("|") ?? "none"
2394
+ ) }),
2395
+ /* @__PURE__ */ jsxs11("div", { className: "panel", style: { marginBottom: "1rem" }, children: [
2396
+ /* @__PURE__ */ jsx12("h3", { style: { marginTop: 0 }, children: "Collegamento entit\xE0" }),
2397
+ /* @__PURE__ */ jsx12("p", { className: "muted", children: "Requisiti MVP: clinica richiede patientId + prescriptionId; lab richiede labWorkOrderId. Il server decide se passare a linked." }),
2398
+ /* @__PURE__ */ jsxs11("div", { className: "field-grid", children: [
2399
+ /* @__PURE__ */ jsxs11("div", { className: "field", children: [
2400
+ /* @__PURE__ */ jsx12("label", { children: "patientId" }),
2401
+ /* @__PURE__ */ jsx12(
2402
+ "input",
2403
+ {
2404
+ value: patientId,
2405
+ onChange: (e) => setPatientId(e.target.value),
2406
+ placeholder: doc.linkedEntity.patientId ?? ""
2407
+ }
2408
+ )
2409
+ ] }),
2410
+ /* @__PURE__ */ jsxs11("div", { className: "field", children: [
2411
+ /* @__PURE__ */ jsx12("label", { children: "prescriptionId" }),
2412
+ /* @__PURE__ */ jsx12(
2413
+ "input",
2414
+ {
2415
+ value: prescriptionId,
2416
+ onChange: (e) => setPrescriptionId(e.target.value),
2417
+ placeholder: doc.linkedEntity.prescriptionId ?? ""
2418
+ }
2419
+ )
2420
+ ] }),
2421
+ /* @__PURE__ */ jsxs11("div", { className: "field", children: [
2422
+ /* @__PURE__ */ jsx12("label", { children: "labWorkOrderId" }),
2423
+ /* @__PURE__ */ jsx12(
2424
+ "input",
2425
+ {
2426
+ value: labWorkOrderId,
2427
+ onChange: (e) => setLabWorkOrderId(e.target.value),
2428
+ placeholder: doc.linkedEntity.labWorkOrderId ?? ""
2429
+ }
2430
+ )
2431
+ ] }),
2432
+ /* @__PURE__ */ jsxs11("div", { className: "field", children: [
2433
+ /* @__PURE__ */ jsx12("label", { children: "customerId" }),
2434
+ /* @__PURE__ */ jsx12(
2435
+ "input",
2436
+ {
2437
+ value: customerId,
2438
+ onChange: (e) => setCustomerId(e.target.value),
2439
+ placeholder: doc.linkedEntity.customerId ?? ""
2440
+ }
2441
+ )
2442
+ ] }),
2443
+ /* @__PURE__ */ jsxs11("div", { className: "field", children: [
2444
+ /* @__PURE__ */ jsx12("label", { children: "externalCaseId" }),
2445
+ /* @__PURE__ */ jsx12(
2446
+ "input",
2447
+ {
2448
+ value: externalCaseId,
2449
+ onChange: (e) => setExternalCaseId(e.target.value),
2450
+ placeholder: doc.linkedEntity.externalCaseId ?? ""
2451
+ }
2452
+ )
2453
+ ] })
2454
+ ] }),
2455
+ /* @__PURE__ */ jsx12(
2456
+ "button",
2457
+ {
2458
+ type: "button",
2459
+ className: "btn btn-primary",
2460
+ style: { marginTop: "0.75rem" },
2461
+ disabled: busy || !canMutate,
2462
+ onClick: () => void run(
2463
+ () => pipeline.updateLinkedEntity({
2464
+ patientId: patientId || void 0,
2465
+ prescriptionId: prescriptionId || void 0,
2466
+ labWorkOrderId: labWorkOrderId || void 0,
2467
+ customerId: customerId || void 0,
2468
+ externalCaseId: externalCaseId || void 0
2469
+ })
2470
+ ),
2471
+ children: "Aggiorna collegamenti"
2472
+ }
2473
+ )
2474
+ ] }),
2475
+ /* @__PURE__ */ jsxs11("div", { className: "panel", children: [
2476
+ /* @__PURE__ */ jsx12("h3", { style: { marginTop: 0 }, children: "Manifest normalizzato (solo backend)" }),
2477
+ /* @__PURE__ */ jsx12("p", { className: "muted", style: { marginTop: 0 }, children: "Generato dalle query Convex del componente, non costruito in UI." }),
2478
+ /* @__PURE__ */ jsx12(ManifestViewer, { manifest })
2479
+ ] })
2480
+ ] }),
2481
+ /* @__PURE__ */ jsxs11("div", { children: [
2482
+ /* @__PURE__ */ jsxs11("div", { className: "panel", style: { marginBottom: "1rem" }, children: [
2483
+ /* @__PURE__ */ jsx12("h3", { style: { marginTop: 0 }, children: "Anteprima 3D" }),
2484
+ /* @__PURE__ */ jsxs11("p", { className: "muted", style: { marginTop: 0 }, children: [
2485
+ "Viewer Three.js orthodontic-style (ispirato a",
2486
+ " ",
2487
+ /* @__PURE__ */ jsx12(
2488
+ "a",
2489
+ {
2490
+ href: "https://github.com/Ellipsis1/DentalGeom",
2491
+ target: "_blank",
2492
+ rel: "noopener noreferrer",
2493
+ children: "DentalGeom"
2494
+ }
2495
+ ),
2496
+ "); i dati restano su storage Convex via URL firmato."
2497
+ ] }),
2498
+ viewableMeshCount === 0 ? /* @__PURE__ */ jsxs11("div", { className: "warning-box", style: { marginBottom: "0.75rem" }, children: [
2499
+ /* @__PURE__ */ jsx12("strong", { children: "Nessuna mesh visualizzabile (STL / PLY / OBJ)" }),
2500
+ /* @__PURE__ */ jsx12("div", { style: { marginTop: "0.35rem" }, children: proprietary3dFiles.length > 0 ? /* @__PURE__ */ jsxs11(Fragment3, { children: [
2501
+ "Questo pacchetto contiene ",
2502
+ proprietary3dFiles.length,
2503
+ " file 3D in ",
2504
+ /* @__PURE__ */ jsx12("strong", { children: "formati proprietari 3Shape" }),
2505
+ " (",
2506
+ /* @__PURE__ */ jsx12("code", { children: ".dcm" }),
2507
+ " \u2014 non DICOM standard \u2014 e",
2508
+ " ",
2509
+ /* @__PURE__ */ jsx12("code", { children: ".3ml" }),
2510
+ ", spesso cifrati) che il viewer browser non pu\xF2 aprire. Per la preview 3D serve un export",
2511
+ " ",
2512
+ /* @__PURE__ */ jsx12("strong", { children: "STL/PLY/OBJ" }),
2513
+ " dal software CAD."
2514
+ ] }) : /* @__PURE__ */ jsxs11(Fragment3, { children: [
2515
+ "Carica o estrai file ",
2516
+ /* @__PURE__ */ jsx12("strong", { children: "STL, PLY o OBJ" }),
2517
+ " per abilitare l'anteprima 3D."
2518
+ ] }) })
2519
+ ] }) : null,
2520
+ /* @__PURE__ */ jsx12(
2521
+ MeshFileSelector,
2522
+ {
2523
+ files: files ?? void 0,
2524
+ selectedId: previewMeshId,
2525
+ onSelect: (f) => onPreviewMeshIdChange(f._id)
2526
+ }
2527
+ ),
2528
+ /* @__PURE__ */ jsx12(
2529
+ MeshViewer,
2530
+ {
2531
+ meshDownloadUrl,
2532
+ selectedFile: selectedMeshFile,
2533
+ segmentationBridge: meshSegmentationBridge
2534
+ }
2535
+ )
2536
+ ] }),
2537
+ /* @__PURE__ */ jsxs11("div", { className: "panel", children: [
2538
+ /* @__PURE__ */ jsx12("h3", { style: { marginTop: 0 }, children: "Timeline eventi" }),
2539
+ /* @__PURE__ */ jsx12(EventTimeline, { events: events ?? void 0 })
2540
+ ] })
2541
+ ] })
2542
+ ] })
2543
+ ] });
2544
+ }
2545
+ export {
2546
+ CadProjectMetadataPanel,
2547
+ CaseSummaryCard,
2548
+ DigitalAcquisitionDetail,
2549
+ DigitalIntakeDashboard,
2550
+ EventTimeline,
2551
+ FileList,
2552
+ ManifestViewer,
2553
+ MeshFileSelector,
2554
+ MeshViewer,
2555
+ ReviewPanel,
2556
+ SmallBadge,
2557
+ StatusBadge,
2558
+ UploadPanel,
2559
+ defaultDashboardFilters,
2560
+ defaultProfileForOriginUi,
2561
+ hostAppLabel,
2562
+ originLabel,
2563
+ readinessLabelUi,
2564
+ validationProfileLabel,
2565
+ vendorLabel
2566
+ };
2567
+ //# sourceMappingURL=index.js.map