feat(voice): adapt voice entry to the stacked-jobs prosthesis model

Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.

Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.

Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().

Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.

Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.

KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
  type-row lock is missing, so unticking it saves prosthesis lab rows on a
  non-prosthesis detail

Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.

Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 12:23:58 +08:00
parent 77e2ed4b42
commit 15ddb9aac2
29 changed files with 3630 additions and 524 deletions

View File

@@ -71,7 +71,11 @@ export const LAB_WORKFLOW_STEPS = [
] as const;
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch';
export type ProsthesisStackGroup =
| 'restoration'
| 'implant'
| 'post_core'
| 'arch';
export type ProsthesisTypeSeed = {
code: string;
@@ -87,7 +91,13 @@ export type ProsthesisTypeSeed = {
};
const SCAN_DESIGN_MODEL = ['intraoral_scan', 'design', 'model_create'] as const;
const INDIRECT_FULL = [...SCAN_DESIGN_MODEL, 'milling_wet', 'sinter', 'stain', 'glaze'] as const;
const INDIRECT_FULL = [
...SCAN_DESIGN_MODEL,
'milling_wet',
'sinter',
'stain',
'glaze',
] as const;
const INDIRECT_LAYERED = [
...SCAN_DESIGN_MODEL,
'milling_wet',
@@ -115,9 +125,24 @@ const KEEP_LAYERED = [
'glaze',
'polish_prep',
] as const;
const DENTURE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
const APPLIANCE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
const POST_CORE = ['intraoral_scan', 'design', 'milling_wet', 'polish_prep'] as const;
const DENTURE = [
'intraoral_scan',
'design',
'printer_resin',
'polish_prep',
] as const;
const APPLIANCE = [
'intraoral_scan',
'design',
'printer_resin',
'polish_prep',
] as const;
const POST_CORE = [
'intraoral_scan',
'design',
'milling_wet',
'polish_prep',
] as const;
function crown(
code: string,
@@ -165,7 +190,8 @@ function indirect(
subcategory: indication,
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
manufacturingSteps:
technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
};
}
@@ -200,10 +226,24 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'stain',
'glaze',
]),
crown('full_metal_crown', 5, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep']),
crown('temporary_resin_crown', 6, ['intraoral_scan', 'design', 'milling_wet', 'polish_prep']),
crown('full_metal_crown', 5, [
...SCAN_DESIGN_MODEL,
'milling_wet',
'polish_prep',
]),
crown('temporary_resin_crown', 6, [
'intraoral_scan',
'design',
'milling_wet',
'polish_prep',
]),
crown('pmma', 7, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
crown('peek_crown', 8, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
crown('peek_crown', 8, [
'intraoral_scan',
'design',
'milling_dry',
'polish_prep',
]),
crown('press_ceramic', 9, [
'intraoral_scan',
'design',
@@ -243,7 +283,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'model_create',
'polish_prep',
]),
implant('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
implant('ti_base_abutment', 41, [
'intraoral_scan',
'model_create',
'polish_prep',
]),
implant('multi_unit_abutment', 42, [
'intraoral_scan',
'choosing_abutment',
@@ -255,7 +299,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'milling_wet',
'polish_prep',
]),
implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
implant('zirconia_abutment', 44, [
...SCAN_DESIGN_MODEL,
'milling_dry',
'sinter',
]),
implant(
'screw_retained',
45,
@@ -450,7 +498,11 @@ export {
const TREATMENT_LABELS: Record<string, Record<string, string>> = {
restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' },
specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' },
specialized_restoration: {
en: 'Specialized Restoration',
fa: 'ترمیم تخصصی',
nl: 'Gespecialiseerde Restauratie',
},
radiography: { en: 'Radiography', fa: 'رادیوگرافی', nl: 'Röntgen' },
endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' },
surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' },
@@ -460,8 +512,16 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' },
clinic_visit: { en: 'Clinic Visit', fa: 'ویزیت درمانگاه', nl: 'Kliniekbezoek' },
continue_treatment: { en: 'Continue Treatment', fa: 'ادامه درمان', nl: 'Behandeling Voortzetten' },
clinic_visit: {
en: 'Clinic Visit',
fa: 'ویزیت درمانگاه',
nl: 'Kliniekbezoek',
},
continue_treatment: {
en: 'Continue Treatment',
fa: 'ادامه درمان',
nl: 'Behandeling Voortzetten',
},
consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' },
@@ -471,50 +531,182 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
export const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' },
glass_ceramic_crown: { en: 'Glass Ceramic Crown', fa: 'روکش سرامیک شیشه‌ای', nl: 'Glaskeramische Kroon' },
full_metal_crown: { en: 'Full Metal Crown', fa: 'روکش تمام فلز', nl: 'Volledige Metalen Kroon' },
temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' },
monolithic_zirconia: {
en: 'Monolithic Zirconia',
fa: 'زیرکونیا مونولیتیک',
nl: 'Monolithisch Zirconia',
},
glass_ceramic_crown: {
en: 'Glass Ceramic Crown',
fa: 'روکش سرامیک شیشه‌ای',
nl: 'Glaskeramische Kroon',
},
full_metal_crown: {
en: 'Full Metal Crown',
fa: 'روکش تمام فلز',
nl: 'Volledige Metalen Kroon',
},
temporary_resin_crown: {
en: 'Temporary Resin Crown',
fa: 'روکش موقت رزینی',
nl: 'Tijdelijke Harskroon',
},
pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' },
press_ceramic: { en: 'Pressed Ceramic / IPS e.max', fa: 'سرامیک پرس / IPS e.max', nl: 'Perskeramiek / IPS e.max' },
veneer_full_contour: { en: 'Veneer · Full contour', fa: 'ونیر · تمام‌کانتور', nl: 'Veneer · Full contour' },
veneer_layered: { en: 'Veneer · Layered ceramic', fa: 'ونیر · سرامیک لایه‌ای', nl: 'Veneer · Gelaagd keramiek' },
inlay_full_contour: { en: 'Inlay · Full contour', fa: 'اینلی · تمام‌کانتور', nl: 'Inlay · Full contour' },
inlay_layered: { en: 'Inlay · Layered ceramic', fa: 'اینلی · سرامیک لایه‌ای', nl: 'Inlay · Gelaagd keramiek' },
onlay_full_contour: { en: 'Onlay · Full contour', fa: 'آنلی · تمام‌کانتور', nl: 'Onlay · Full contour' },
onlay_layered: { en: 'Onlay · Layered ceramic', fa: 'آنلی · سرامیک لایه‌ای', nl: 'Onlay · Gelaagd keramiek' },
overlay_full_contour: { en: 'Overlay · Full contour', fa: 'اورلی · تمام‌کانتور', nl: 'Overlay · Full contour' },
overlay_layered: { en: 'Overlay · Layered ceramic', fa: 'اورلی · سرامیک لایه‌ای', nl: 'Overlay · Gelaagd keramiek' },
cast_post_core: { en: 'Cast Post & Core', fa: 'پست و کور ریختگی', nl: 'Gegoten Stiftopbouw' },
fiber_post_core: { en: 'Fiber Post & Core', fa: 'پست و کور فایبر', nl: 'Fiber Stiftopbouw' },
customized_abutment: { en: 'Custom Abutment (Titanium)', fa: 'اباتمنت سفارشی (تیتانیوم)', nl: 'Aangepast abutment (titanium)' },
prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' },
ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' },
multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' },
zirconia_abutment: { en: 'Custom Abutment (Zirconia)', fa: 'اباتمنت سفارشی (زیرکونیا)', nl: 'Aangepast abutment (zirconia)' },
press_ceramic: {
en: 'Pressed Ceramic / IPS e.max',
fa: 'سرامیک پرس / IPS e.max',
nl: 'Perskeramiek / IPS e.max',
},
veneer_full_contour: {
en: 'Veneer · Full contour',
fa: 'ونیر · تمام‌کانتور',
nl: 'Veneer · Full contour',
},
veneer_layered: {
en: 'Veneer · Layered ceramic',
fa: 'ونیر · سرامیک لایه‌ای',
nl: 'Veneer · Gelaagd keramiek',
},
inlay_full_contour: {
en: 'Inlay · Full contour',
fa: 'اینلی · تمام‌کانتور',
nl: 'Inlay · Full contour',
},
inlay_layered: {
en: 'Inlay · Layered ceramic',
fa: 'اینلی · سرامیک لایه‌ای',
nl: 'Inlay · Gelaagd keramiek',
},
onlay_full_contour: {
en: 'Onlay · Full contour',
fa: 'آنلی · تمام‌کانتور',
nl: 'Onlay · Full contour',
},
onlay_layered: {
en: 'Onlay · Layered ceramic',
fa: 'آنلی · سرامیک لایه‌ای',
nl: 'Onlay · Gelaagd keramiek',
},
overlay_full_contour: {
en: 'Overlay · Full contour',
fa: 'اورلی · تمام‌کانتور',
nl: 'Overlay · Full contour',
},
overlay_layered: {
en: 'Overlay · Layered ceramic',
fa: 'اورلی · سرامیک لایه‌ای',
nl: 'Overlay · Gelaagd keramiek',
},
cast_post_core: {
en: 'Cast Post & Core',
fa: 'پست و کور ریختگی',
nl: 'Gegoten Stiftopbouw',
},
fiber_post_core: {
en: 'Fiber Post & Core',
fa: 'پست و کور فایبر',
nl: 'Fiber Stiftopbouw',
},
customized_abutment: {
en: 'Custom Abutment (Titanium)',
fa: 'اباتمنت سفارشی (تیتانیوم)',
nl: 'Aangepast abutment (titanium)',
},
prefabricated_abutment: {
en: 'Prefabricated Abutment',
fa: 'اباتمنت آماده',
nl: 'Prefab Abutment',
},
ti_base_abutment: {
en: 'Ti Base Abutment',
fa: 'اباتمنت پایه تیتانیوم',
nl: 'Ti Basis Abutment',
},
multi_unit_abutment: {
en: 'Multi Unit Abutment',
fa: 'اباتمنت مولتی یونیت',
nl: 'Multi Unit Abutment',
},
zirconia_abutment: {
en: 'Custom Abutment (Zirconia)',
fa: 'اباتمنت سفارشی (زیرکونیا)',
nl: 'Aangepast abutment (zirconia)',
},
screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
complete_denture: { en: 'Complete Denture', fa: 'دنچر کامل', nl: 'Volledige prothese' },
partial_denture: { en: 'Partial Denture', fa: 'دنچر پارسیل', nl: 'Partiële prothese' },
complete_denture: {
en: 'Complete Denture',
fa: 'دنچر کامل',
nl: 'Volledige prothese',
},
partial_denture: {
en: 'Partial Denture',
fa: 'دنچر پارسیل',
nl: 'Partiële prothese',
},
overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' },
night_guard_soft: { en: 'Night Guard · Soft', fa: 'نایت گارد · نرم', nl: 'Nachtbeugel · Zacht' },
night_guard_hard: { en: 'Night Guard · Hard', fa: 'نایت گارد · سخت', nl: 'Nachtbeugel · Hard' },
night_guard_dual: { en: 'Night Guard · Dual laminate', fa: 'نایت گارد · دو لایه', nl: 'Nachtbeugel · Dual laminate' },
night_guard_soft: {
en: 'Night Guard · Soft',
fa: 'نایت گارد · نرم',
nl: 'Nachtbeugel · Zacht',
},
night_guard_hard: {
en: 'Night Guard · Hard',
fa: 'نایت گارد · سخت',
nl: 'Nachtbeugel · Hard',
},
night_guard_dual: {
en: 'Night Guard · Dual laminate',
fa: 'نایت گارد · دو لایه',
nl: 'Nachtbeugel · Dual laminate',
},
bleaching_tray: { en: 'Bleaching Tray', fa: 'تری بلیچینگ', nl: 'Bleeklepel' },
clear_aligner: { en: 'Clear Aligner', fa: 'الاینر شفاف', nl: 'Clear aligner' },
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
surgical_guide: { en: 'Surgical Guide', fa: 'گاید جراحی', nl: 'Chirurgische mal' },
clear_aligner: {
en: 'Clear Aligner',
fa: 'الاینر شفاف',
nl: 'Clear aligner',
},
soft_structure: {
en: 'Soft Structure',
fa: 'ساختار نرم',
nl: 'Zachte Structuur',
},
surgical_guide: {
en: 'Surgical Guide',
fa: 'گاید جراحی',
nl: 'Chirurgische mal',
},
smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
veneer_zirconia: {
en: 'Veneer Zirconia',
fa: 'ونیر زیرکونیا',
nl: 'Veneer Zirconia',
},
veneer_ips_press: {
en: 'Veneer IPS Press',
fa: 'ونیر IPS پرس',
nl: 'Veneer IPS Press',
},
veneer_ips_cad: {
en: 'Veneer IPS CAD',
fa: 'ونیر IPS CAD',
nl: 'Veneer IPS CAD',
},
zirconia_overlay: {
en: 'Zirconia Overlay',
fa: 'اورلی زیرکونیا',
nl: 'Zirconia Overlay',
},
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
};
export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' },
intraoral_scan: {
en: 'Intraoral Scan',
fa: 'اسکن داخل دهان',
nl: 'Intraorale Scan',
},
choosing_abutment: {
en: 'Choosing Abutment',
fa: 'انتخاب اباتمنت',
@@ -530,7 +722,11 @@ export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' },
stain: { en: 'Stain', fa: 'رنگ‌آمیزی', nl: 'Kleuren' },
glaze: { en: 'Glaze', fa: 'گلیز', nl: 'Glazuur' },
polish_prep: { en: 'Polish/Prep', fa: 'پولیش/آماده‌سازی', nl: 'Polijsten/Voorbereiding' },
polish_prep: {
en: 'Polish/Prep',
fa: 'پولیش/آماده‌سازی',
nl: 'Polijsten/Voorbereiding',
},
packing: { en: 'Packing', fa: 'بسته‌بندی', nl: 'Verpakken' },
shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' },
pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' },
@@ -549,8 +745,58 @@ function labelsToTranslations(
return out;
}
/**
* The 7 prosthesis categories, worded from `frontend/messages/*.json`'s `category_*` keys
* (decision 47) so the extraction prompt and the manual picker read identically on day one.
* The frontend keeps its own message keys — migrating it to read these is out of scope
* (decision 48) — this seed is written *from* those keys precisely so the two agree at the
* point they diverge.
*/
export const PROSTHESIS_CATEGORY_LABELS: Record<
string,
Record<string, string>
> = {
crown: { en: 'Crowns', fa: 'روکش‌ها', nl: 'Kronen' },
indirect: {
en: 'Veneer/Inlay/Onlay/Overlay',
fa: 'ونیر/اینلی/آنلی/اورلی',
nl: 'Veneer/Inlay/Onlay/Overlay',
},
implant: { en: 'Implants', fa: 'ایمپلنت', nl: 'Implantaten' },
post_core: { en: 'Post & core', fa: 'پست و کور', nl: 'Stiftopbouw' },
removable: { en: 'Removable', fa: 'متحرک', nl: 'Uitneembaar' },
appliance: { en: 'Appliances', fa: 'اپلاینس‌ها', nl: 'Apparatuur' },
digital: { en: 'Digital', fa: 'دیجیتال', nl: 'Digitaal' },
};
/**
* Only the 5 real subcategories — the `sub_*` message key set is wider (it also carries
* technique names and two leaf codes); those are not subcategory catalog entities.
*/
export const PROSTHESIS_SUBCATEGORY_LABELS: Record<
string,
Record<string, string>
> = {
veneer: { en: 'Veneer', fa: 'ونیر', nl: 'Veneer' },
inlay: { en: 'Inlay', fa: 'اینلی', nl: 'Inlay' },
onlay: { en: 'Onlay', fa: 'آنلی', nl: 'Onlay' },
overlay: { en: 'Overlay', fa: 'اورلی', nl: 'Overlay' },
night_guard: { en: 'Night guard', fa: 'نایت گارد', nl: 'Nachtbeugel' },
};
export const CATALOG_TRANSLATIONS: CatalogTranslationSeed[] = [
...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS),
...labelsToTranslations(CatalogEntityKind.PROSTHESIS_TYPE, PROSTHESIS_LABELS),
...labelsToTranslations(CatalogEntityKind.LAB_WORKFLOW_STEP, WORKFLOW_STEP_LABELS),
...labelsToTranslations(
CatalogEntityKind.LAB_WORKFLOW_STEP,
WORKFLOW_STEP_LABELS,
),
...labelsToTranslations(
CatalogEntityKind.PROSTHESIS_CATEGORY,
PROSTHESIS_CATEGORY_LABELS,
),
...labelsToTranslations(
CatalogEntityKind.PROSTHESIS_SUBCATEGORY,
PROSTHESIS_SUBCATEGORY_LABELS,
),
];