feature: Phase4 - Clinic two-step treatment UI

This commit is contained in:
2026-06-28 17:14:02 +03:30
parent 21f545ebdb
commit 7b19d6953c
17 changed files with 1042 additions and 615 deletions

View File

@@ -0,0 +1,50 @@
/**
* One-off cleanup: remove appointments, treatments, lab cases, and related rows.
* Keeps patients, organizations, users, and catalog data intact.
*
* Usage: npx ts-node prisma/scripts/clear-clinical-test-data.ts
*/
import { PrismaClient } from '@prisma/client';
import { config } from 'dotenv';
import path from 'path';
config({ path: path.join(__dirname, '..', '..', '.env') });
const prisma = new PrismaClient();
async function main() {
const counts = {
labCaseTasks: await prisma.labCaseTask.count(),
labCaseSends: await prisma.labCaseSend.count(),
labCaseDetails: await prisma.labCaseDetail.count(),
labCases: await prisma.labCase.count(),
attachments: await prisma.treatmentDetailAttachment.count(),
treatmentDetails: await prisma.treatmentDetail.count(),
treatments: await prisma.treatment.count(),
appointments: await prisma.appointment.count(),
};
console.log('Current row counts:', counts);
await prisma.$transaction([
prisma.labCaseTask.deleteMany(),
prisma.labCaseSend.deleteMany(),
prisma.labCaseDetail.deleteMany(),
prisma.labCase.deleteMany(),
prisma.treatmentDetailAttachment.deleteMany(),
prisma.treatmentDetail.deleteMany(),
prisma.treatment.deleteMany(),
prisma.appointment.deleteMany(),
]);
console.log('✅ Cleared appointments, treatments, lab cases, tasks, and attachments.');
}
main()
.catch((error) => {
console.error('❌ Cleanup failed:', error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -396,6 +396,7 @@
"noPermissionBody": "You do not have permission to view the Treatment tab for this organization.", "noPermissionBody": "You do not have permission to view the Treatment tab for this organization.",
"title": "Treatment", "title": "Treatment",
"subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.", "subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.",
"subtitleEditPhase4": "Plan treatment details first, then group lab-dependent work into shipments in the lab dispatch panel.",
"subtitleReadOnly": "View-only access — you can review appointments and treatment history but cannot edit.", "subtitleReadOnly": "View-only access — you can review appointments and treatment history but cannot edit.",
"pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.", "pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.",
"selectedPatient": "Selected patient", "selectedPatient": "Selected patient",
@@ -423,6 +424,13 @@
"emptyDay": "No appointments assigned to you on this day.", "emptyDay": "No appointments assigned to you on this day.",
"casesTitle": "Treatment cases", "casesTitle": "Treatment cases",
"casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.", "casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.",
"detailsTitle": "Treatment details",
"detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.",
"addDetail": "Add detail",
"detailLabel": "Detail {n}",
"detailSentBadge": "sent",
"detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.",
"detailsSaveHint": "Lab dispatch is configured separately below.",
"addCase": "Add case", "addCase": "Add case",
"caseLabel": "Case {n}", "caseLabel": "Case {n}",
"comments": "Comments", "comments": "Comments",
@@ -441,6 +449,24 @@
"recent": "Recent:", "recent": "Recent:",
"noOrgMatch": "No active organization matches your search.", "noOrgMatch": "No active organization matches your search.",
"sendThisCase": "Send this case", "sendThisCase": "Send this case",
"labDispatchTitle": "Lab dispatch",
"labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.",
"addLabShipment": "Add lab shipment",
"labShipmentLabel": "Shipment {n}",
"includeDetails": "Include treatment details",
"labDetailLine": "Detail {n} · {type} · {teeth}",
"noLabDetails": "No lab-dependent treatment details yet. Add a lab type (e.g. endo) in treatment details above.",
"labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.",
"labComment": "Message for the lab",
"labCommentPlaceholder": "Optional instructions for this shipment…",
"selectLab": "Destination lab",
"selectLabPlaceholder": "Choose a linked lab…",
"sendToLab": "Send to lab",
"saveLabShipments": "Save lab shipments",
"labDispatchSaveHint": "Saves shipment grouping without sending.",
"successLabShipmentsSaved": "Lab shipments saved.",
"errorSaveLabShipments": "Failed to save lab shipments.",
"errorLabCaseNeedsDetails": "Select at least one treatment detail for this shipment.",
"saveDraft": "Save treatment draft", "saveDraft": "Save treatment draft",
"unsavedChanges": "Unsaved changes", "unsavedChanges": "Unsaved changes",
"draftSaved": "Draft saved", "draftSaved": "Draft saved",
@@ -464,7 +490,10 @@
"moreCases": "+ {n} more case(s)", "moreCases": "+ {n} more case(s)",
"previewDialogTitle": "Treatment preview", "previewDialogTitle": "Treatment preview",
"previewDialogSubtitle": "Review cases, attachments, and send destinations.", "previewDialogSubtitle": "Review cases, attachments, and send destinations.",
"previewDialogSubtitlePhase4": "Review treatment details and attachments.",
"previewLabDispatchHint": "Use the lab dispatch panel in the workspace to send work to labs.",
"noCases": "No cases in this treatment.", "noCases": "No cases in this treatment.",
"noDetails": "No treatment details in this draft.",
"typeLabel": "Type:", "typeLabel": "Type:",
"commentsLabel": "Comments:", "commentsLabel": "Comments:",
"commentsEmpty": "Comments: —", "commentsEmpty": "Comments: —",
@@ -474,7 +503,7 @@
"noActiveOrgs": "No active linked organizations.", "noActiveOrgs": "No active linked organizations.",
"confirmSend": "Confirm send", "confirmSend": "Confirm send",
"toothChartTitle": "FDI tooth chart", "toothChartTitle": "FDI tooth chart",
"toothChartHint": "Tap teeth to multi-select. Applies to the active case.", "toothChartHint": "Tap teeth to multi-select. Applies to the active detail.",
"selectedLabel": "Selected:", "selectedLabel": "Selected:",
"selectedEmpty": "—", "selectedEmpty": "—",
"upperArch": "Upper arch", "upperArch": "Upper arch",

View File

@@ -396,6 +396,7 @@
"noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.", "noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.",
"title": "درمان", "title": "درمان",
"subtitleEdit": "پرونده‌های نوبت‌های خود را مستند کنید، پیش‌نویس‌ها را ذخیره کنید و کار را به سازمان‌های مرتبط ارسال کنید.", "subtitleEdit": "پرونده‌های نوبت‌های خود را مستند کنید، پیش‌نویس‌ها را ذخیره کنید و کار را به سازمان‌های مرتبط ارسال کنید.",
"subtitleEditPhase4": "ابتدا جزئیات درمان را برنامه‌ریزی کنید، سپس کار وابسته به لابراتوار را در بخش ارسال لاب گروه‌بندی کنید.",
"subtitleReadOnly": "دسترسی فقط خواندنی — می‌توانید نوبت‌ها و تاریخچه درمان را بررسی کنید اما نمی‌توانید ویرایش کنید.", "subtitleReadOnly": "دسترسی فقط خواندنی — می‌توانید نوبت‌ها و تاریخچه درمان را بررسی کنید اما نمی‌توانید ویرایش کنید.",
"pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. می‌توانید نوبت‌ها و تاریخچه را بررسی کنید، اما پرونده‌های درمانی قابل اضافه یا تغییر نیستند.", "pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. می‌توانید نوبت‌ها و تاریخچه را بررسی کنید، اما پرونده‌های درمانی قابل اضافه یا تغییر نیستند.",
"selectedPatient": "بیمار انتخاب شده", "selectedPatient": "بیمار انتخاب شده",
@@ -423,6 +424,13 @@
"emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.", "emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.",
"casesTitle": "پرونده‌های درمانی", "casesTitle": "پرونده‌های درمانی",
"casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.", "casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.",
"detailsTitle": "جزئیات درمان",
"detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.",
"addDetail": "افزودن جزئیات",
"detailLabel": "جزئیات {n}",
"detailSentBadge": "ارسال‌شده",
"detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.",
"detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی می‌شود.",
"addCase": "افزودن پرونده", "addCase": "افزودن پرونده",
"caseLabel": "پرونده {n}", "caseLabel": "پرونده {n}",
"comments": "نظرات", "comments": "نظرات",
@@ -441,6 +449,24 @@
"recent": "اخیر:", "recent": "اخیر:",
"noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.", "noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.",
"sendThisCase": "ارسال این پرونده", "sendThisCase": "ارسال این پرونده",
"labDispatchTitle": "ارسال به لابراتوار",
"labDispatchSubtitle": "جزئیات وابسته به لاب را در محموله‌ها گروه‌بندی کرده و به لابراتوارهای متصل ارسال کنید.",
"addLabShipment": "افزودن محموله لاب",
"labShipmentLabel": "محموله {n}",
"includeDetails": "شامل جزئیات درمان",
"labDetailLine": "جزئیات {n} · {type} · {teeth}",
"noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.",
"labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.",
"labComment": "پیام برای لابراتوار",
"labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…",
"selectLab": "لابراتوار مقصد",
"selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…",
"sendToLab": "ارسال به لابراتوار",
"saveLabShipments": "ذخیره محموله‌های لاب",
"labDispatchSaveHint": "گروه‌بندی محموله را بدون ارسال ذخیره می‌کند.",
"successLabShipmentsSaved": "محموله‌های لاب ذخیره شد.",
"errorSaveLabShipments": "ذخیره محموله‌های لاب ناموفق بود.",
"errorLabCaseNeedsDetails": "حداقل یک جزئیات درمان برای این محموله انتخاب کنید.",
"saveDraft": "ذخیره پیش‌نویس درمان", "saveDraft": "ذخیره پیش‌نویس درمان",
"unsavedChanges": "تغییرات ذخیره‌نشده", "unsavedChanges": "تغییرات ذخیره‌نشده",
"draftSaved": "پیش‌نویس ذخیره شد", "draftSaved": "پیش‌نویس ذخیره شد",
@@ -464,6 +490,9 @@
"moreCases": "+ {n} پرونده دیگر", "moreCases": "+ {n} پرونده دیگر",
"previewDialogTitle": "پیش‌نمایش درمان", "previewDialogTitle": "پیش‌نمایش درمان",
"previewDialogSubtitle": "بررسی پرونده‌ها، پیوست‌ها و مقصدهای ارسال.", "previewDialogSubtitle": "بررسی پرونده‌ها، پیوست‌ها و مقصدهای ارسال.",
"previewDialogSubtitlePhase4": "بررسی جزئیات درمان و پیوست‌ها.",
"previewLabDispatchHint": "برای ارسال کار به لابراتوار از بخش ارسال لاب در فضای کاری استفاده کنید.",
"noDetails": "جزئیات درمانی در این پیش‌نویس وجود ندارد.",
"noCases": "هیچ پرونده‌ای در این درمان وجود ندارد.", "noCases": "هیچ پرونده‌ای در این درمان وجود ندارد.",
"typeLabel": "نوع:", "typeLabel": "نوع:",
"commentsLabel": "نظرات:", "commentsLabel": "نظرات:",
@@ -474,7 +503,7 @@
"noActiveOrgs": "هیچ سازمان مرتبط فعالی وجود ندارد.", "noActiveOrgs": "هیچ سازمان مرتبط فعالی وجود ندارد.",
"confirmSend": "تأیید ارسال", "confirmSend": "تأیید ارسال",
"toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitle": "نمودار دندان‌ها FDI",
"toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای پرونده فعال اعمال می‌شود.", "toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای جزئیات فعال اعمال می‌شود.",
"selectedLabel": "انتخاب شده:", "selectedLabel": "انتخاب شده:",
"selectedEmpty": "—", "selectedEmpty": "—",
"upperArch": "قوس بالا", "upperArch": "قوس بالا",

View File

@@ -396,6 +396,7 @@
"noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.", "noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.",
"title": "Behandeling", "title": "Behandeling",
"subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.", "subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.",
"subtitleEditPhase4": "Plan eerst behandeldetails, groepeer daarna lab-afhankelijk werk in het lab-dispatchpaneel.",
"subtitleReadOnly": "Alleen-lezen toegang — u kunt afspraken en behandelgeschiedenis bekijken, maar niet bewerken.", "subtitleReadOnly": "Alleen-lezen toegang — u kunt afspraken en behandelgeschiedenis bekijken, maar niet bewerken.",
"pastDayNotice": "Dagen in het verleden zijn alleen-lezen. U kunt afspraken en geschiedenis bekijken, maar behandelcasussen kunnen niet worden toegevoegd of gewijzigd.", "pastDayNotice": "Dagen in het verleden zijn alleen-lezen. U kunt afspraken en geschiedenis bekijken, maar behandelcasussen kunnen niet worden toegevoegd of gewijzigd.",
"selectedPatient": "Geselecteerde patiënt", "selectedPatient": "Geselecteerde patiënt",
@@ -423,6 +424,13 @@
"emptyDay": "Geen afspraken aan u toegewezen op deze dag.", "emptyDay": "Geen afspraken aan u toegewezen op deze dag.",
"casesTitle": "Behandelcasussen", "casesTitle": "Behandelcasussen",
"casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.", "casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.",
"detailsTitle": "Behandeldetails",
"detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.",
"addDetail": "Detail toevoegen",
"detailLabel": "Detail {n}",
"detailSentBadge": "verzonden",
"detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.",
"detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.",
"addCase": "Case toevoegen", "addCase": "Case toevoegen",
"caseLabel": "Case {n}", "caseLabel": "Case {n}",
"comments": "Opmerkingen", "comments": "Opmerkingen",
@@ -441,6 +449,24 @@
"recent": "Recent:", "recent": "Recent:",
"noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.", "noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.",
"sendThisCase": "Verzend deze case", "sendThisCase": "Verzend deze case",
"labDispatchTitle": "Lab-dispatch",
"labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.",
"addLabShipment": "Labzending toevoegen",
"labShipmentLabel": "Zending {n}",
"includeDetails": "Behandeldetails opnemen",
"labDetailLine": "Detail {n} · {type} · {teeth}",
"noLabDetails": "Nog geen lab-afhankelijke details. Voeg een labtype (bijv. endo) toe in de behandeldetails hierboven.",
"labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.",
"labComment": "Bericht voor het lab",
"labCommentPlaceholder": "Optionele instructies voor deze zending…",
"selectLab": "Bestemmingslab",
"selectLabPlaceholder": "Kies een gekoppeld lab…",
"sendToLab": "Versturen naar lab",
"saveLabShipments": "Labzendingen opslaan",
"labDispatchSaveHint": "Slaat groepering op zonder te verzenden.",
"successLabShipmentsSaved": "Labzendingen opgeslagen.",
"errorSaveLabShipments": "Labzendingen opslaan mislukt.",
"errorLabCaseNeedsDetails": "Selecteer minimaal één behandeldetail voor deze zending.",
"saveDraft": "Behandelconcept opslaan", "saveDraft": "Behandelconcept opslaan",
"unsavedChanges": "Niet-opgeslagen wijzigingen", "unsavedChanges": "Niet-opgeslagen wijzigingen",
"draftSaved": "Concept opgeslagen", "draftSaved": "Concept opgeslagen",
@@ -464,7 +490,10 @@
"moreCases": "+ {n} meer case(s)", "moreCases": "+ {n} meer case(s)",
"previewDialogTitle": "Behandelvoorbeeld", "previewDialogTitle": "Behandelvoorbeeld",
"previewDialogSubtitle": "Bekijk casussen, bijlagen en verzendbestemmingen.", "previewDialogSubtitle": "Bekijk casussen, bijlagen en verzendbestemmingen.",
"previewDialogSubtitlePhase4": "Bekijk behandeldetails en bijlagen.",
"previewLabDispatchHint": "Gebruik het lab-dispatchpaneel in de werkruimte om werk naar labs te sturen.",
"noCases": "Geen casussen in deze behandeling.", "noCases": "Geen casussen in deze behandeling.",
"noDetails": "Geen behandeldetails in dit concept.",
"typeLabel": "Type:", "typeLabel": "Type:",
"commentsLabel": "Opmerkingen:", "commentsLabel": "Opmerkingen:",
"commentsEmpty": "Opmerkingen: —", "commentsEmpty": "Opmerkingen: —",
@@ -474,7 +503,7 @@
"noActiveOrgs": "Geen actieve gekoppelde organisaties.", "noActiveOrgs": "Geen actieve gekoppelde organisaties.",
"confirmSend": "Bevestig verzending", "confirmSend": "Bevestig verzending",
"toothChartTitle": "FDI-tanddiagram", "toothChartTitle": "FDI-tanddiagram",
"toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor de actieve case.", "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.",
"selectedLabel": "Geselecteerd:", "selectedLabel": "Geselecteerd:",
"selectedEmpty": "—", "selectedEmpty": "—",
"upperArch": "Bovenboog", "upperArch": "Bovenboog",

View File

@@ -59,8 +59,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<DashboardHeader organizationName={currentOrganization.name} /> <DashboardHeader organizationName={currentOrganization.name} />
<main className="p-6 flex-1 overflow-y-auto"> <main className="p-6 flex-1 min-h-0 overflow-y-auto">
<div className="surface-panel p-6 min-h-full"> <div className="surface-panel p-6">
{children} {children}
</div> </div>
</main> </main>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useId } from 'react'; import { useId, type KeyboardEvent } from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
type CheckboxProps = { type CheckboxProps = {
@@ -14,6 +14,7 @@ type CheckboxProps = {
/** /**
* App design-system checkbox: primary fill when checked, rounded, focus-visible ring. * App design-system checkbox: primary fill when checked, rounded, focus-visible ring.
* Uses a button-like label toggle so mouse clicks do not focus a hidden input (scroll jumps).
*/ */
export function Checkbox({ export function Checkbox({
checked, checked,
@@ -26,25 +27,42 @@ export function Checkbox({
const genId = useId(); const genId = useId();
const inputId = id ?? genId; const inputId = id ?? genId;
const toggle = () => {
if (!disabled) onChange(!checked);
};
const onKeyDown = (event: KeyboardEvent<HTMLLabelElement>) => {
if (disabled) return;
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
toggle();
}
};
return ( return (
<label <label
htmlFor={inputId} id={inputId}
role="checkbox"
aria-checked={checked}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
onKeyDown={onKeyDown}
onClick={(event) => {
event.preventDefault();
toggle();
}}
onMouseDown={(event) => {
// Avoid focus-driven scroll-into-view in overflow panels.
if (event.button === 0) event.preventDefault();
}}
className={` className={`
inline-flex items-center gap-2.5 cursor-pointer select-none rounded-[var(--radius-sm)] -m-0.5 p-0.5 inline-flex items-center gap-2.5 cursor-pointer select-none rounded-[var(--radius-sm)] -m-0.5 p-0.5
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary/45 has-[:focus-visible]:ring-offset-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 focus-visible:ring-offset-2
has-[:focus-visible]:ring-offset-background-secondary focus-visible:ring-offset-background-secondary
${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className} ${className}
`} `}
> >
<input
id={inputId}
type="checkbox"
className="sr-only"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span <span
className={` className={`
flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border-2 transition-all duration-200 flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border-2 transition-all duration-200

View File

@@ -1,5 +1,5 @@
// src/components/ui/Input.tsx // src/components/ui/Input.tsx
import React, { forwardRef } from 'react'; import React, { forwardRef, useId } from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string; label?: string;
@@ -9,8 +9,8 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
export const Input = forwardRef<HTMLInputElement, InputProps>( export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, icon, className = '', id, ...props }, ref) => { ({ label, error, icon, className = '', id, ...props }, ref) => {
const inputId = const generatedId = useId();
id || `input-${Math.random().toString(36).slice(2, 9)}`; const inputId = id || generatedId;
return ( return (
<div className="w-full"> <div className="w-full">

View File

@@ -8,6 +8,8 @@ interface SearchBarProps {
placeholder: string; placeholder: string;
onSubmit?: () => void; onSubmit?: () => void;
actions?: ReactNode; actions?: ReactNode;
/** When true, renders inline without the outer surface-card wrapper (for nested panels). */
embedded?: boolean;
} }
export function SearchBar({ export function SearchBar({
@@ -16,23 +18,28 @@ export function SearchBar({
placeholder, placeholder,
onSubmit, onSubmit,
actions, actions,
embedded = false,
}: SearchBarProps) { }: SearchBarProps) {
return ( const field = (
<div className="surface-card p-4"> <div className={`flex gap-4 items-center ${embedded ? '' : 'flex-1'}`}>
<div className="flex gap-4 items-center"> <div className="flex-1 min-w-0">
<div className="flex-1"> <Input
<Input placeholder={placeholder}
placeholder={placeholder} value={value}
value={value} onChange={(e) => onChange(e.target.value)}
onChange={(e) => onChange(e.target.value)} onKeyDown={(e) => {
onKeyDown={(e) => { if (e.key === 'Enter') onSubmit?.();
if (e.key === 'Enter') onSubmit?.(); }}
}} icon={<Search className="h-4 w-4 icon-flat" />}
icon={<Search className="h-4 w-4 icon-flat" />} />
/>
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div> </div>
{actions && <div className="flex gap-2 shrink-0">{actions}</div>}
</div> </div>
); );
if (embedded) {
return field;
}
return <div className="surface-card p-4">{field}</div>;
} }

View File

@@ -0,0 +1,320 @@
'use client';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[];
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
activeLabCaseId: string | null;
onActiveLabCaseChange: (id: string) => void;
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
disabled: boolean;
canEdit: boolean;
orgs: LinkedOrganizationOption[];
organizationSearch: string;
onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
sendBusyId: string | null;
saveLabBusy: boolean;
onAddLabCase: () => void;
onSaveLabCases: () => void;
onSendLabCase: (labCase: LabCaseDraft) => void;
}
export function LabCasesDispatchPanel({
details,
labCases,
labDependentCodes,
activeLabCaseId,
onActiveLabCaseChange,
onLabCasesChange,
disabled,
canEdit,
orgs,
organizationSearch,
onOrganizationSearchChange,
recentOrganizationIds,
onRecentOrganizationPick,
sendBusyId,
saveLabBusy,
onAddLabCase,
onSaveLabCases,
onSendLabCase,
}: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment');
const activeLinkedOrganizations = orgs.filter((o) => o.active);
const filteredOrganizations = (() => {
const q = organizationSearch.trim().toLowerCase();
if (!q) return activeLinkedOrganizations;
return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
})();
const recentOrganizations = recentOrganizationIds
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
.filter(Boolean) as LinkedOrganizationOption[];
const labEligibleDetails = details.filter((d) => labDependentCodes.has(d.treatmentType));
const activeLabCase =
labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null;
const sent = Boolean(activeLabCase?.sentAt);
function detailSummary(d: TreatmentDetailDraft, idx: number) {
const typeKey = treatmentTypeLabelKey(d.treatmentType);
const typeLabel =
d.treatmentType in TREATMENT_TYPE_KEYS
? t(typeKey as 'typeEndo')
: d.treatmentType;
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
return `${t('detailLabel', { n: idx + 1 })} · ${typeLabel} · ${teeth}`;
}
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
if (!activeLabCase) return;
onLabCasesChange(
labCases.map((lc) => (lc.clientId === activeLabCase.clientId ? { ...lc, ...patch } : lc)),
);
}
function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) {
if (!activeLabCase || sent) return;
onLabCasesChange(
labCases.map((lc) => {
if (lc.sentAt) return lc;
if (lc.clientId === activeLabCase.clientId) {
const set = new Set(lc.detailClientIds);
if (checked) set.add(detailClientId);
else set.delete(detailClientId);
return { ...lc, detailClientIds: [...set] };
}
if (checked) {
return {
...lc,
detailClientIds: lc.detailClientIds.filter((id) => id !== detailClientId),
};
}
return lc;
}),
);
}
function detailAssignedElsewhere(detailClientId: string): boolean {
if (!activeLabCase) return false;
return labCases.some(
(lc) =>
!lc.sentAt &&
lc.clientId !== activeLabCase.clientId &&
lc.detailClientIds.includes(detailClientId),
);
}
if (labEligibleDetails.length === 0) {
return (
<div className="surface-card p-4 space-y-2">
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
<p className="text-xs text-text-muted">{t('noLabDetails')}</p>
</div>
);
}
return (
<div className="surface-card p-4 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
<p className="text-xs text-text-muted mt-0.5">{t('labDispatchSubtitle')}</p>
</div>
<Button
type="button"
variant="primary"
disabled={!canEdit || disabled}
onClick={onAddLabCase}
>
{t('addLabShipment')}
</Button>
</div>
{labCases.length === 0 ? (
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
) : (
<>
<div className="flex flex-wrap gap-2">
{labCases.map((lc, idx) => {
const sentSummary = formatCaseSentSummary(
lc.sends,
{
organizationIds: lc.destinationOrganizationId ? [lc.destinationOrganizationId] : [],
sentAt: lc.sentAt ?? null,
orgs,
},
t,
);
return (
<button
key={lc.clientId}
type="button"
onClick={() => onActiveLabCaseChange(lc.clientId)}
className={`
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
lc.clientId === activeLabCase?.clientId
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
{t('labShipmentLabel', { n: idx + 1 })}
{sentSummary ? ` · ${sentSummary}` : ''}
</button>
);
})}
</div>
{activeLabCase && (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
<div>
<p className="text-xs font-medium text-text-secondary mb-2">{t('includeDetails')}</p>
<div className="flex flex-col gap-2">
{labEligibleDetails.map((d, idx) => {
const assignedElsewhere = detailAssignedElsewhere(d.clientId);
const inSentCase = labCases.some(
(lc) => lc.sentAt && lc.detailClientIds.includes(d.clientId),
);
const checked = activeLabCase.detailClientIds.includes(d.clientId);
const itemDisabled =
disabled || sent || inSentCase || assignedElsewhere;
return (
<Checkbox
key={d.clientId}
checked={checked || inSentCase}
disabled={itemDisabled}
onChange={(next) => toggleDetailInActiveLabCase(d.clientId, next)}
label={detailSummary(d, idx)}
/>
);
})}
</div>
</div>
<label className="block text-xs font-medium text-text-secondary">
{t('labComment')}
<textarea
value={activeLabCase.labComment}
onChange={(e) => updateActiveLabCase({ labComment: e.target.value })}
placeholder={t('labCommentPlaceholder')}
rows={3}
disabled={disabled || sent}
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y"
/>
</label>
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<SearchBar
embedded
value={organizationSearch}
onChange={onOrganizationSearchChange}
placeholder={t('searchOrgsPlaceholder')}
/>
{recentOrganizations.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted">{t('recent')}</span>
{recentOrganizations.map((o) => (
<button
key={o.id}
type="button"
disabled={disabled || sent}
onClick={() => onRecentOrganizationPick(o.id)}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
>
{o.name}
</button>
))}
</div>
)}
<Dropdown
value={activeLabCase.destinationOrganizationId ?? ''}
onChange={(e) =>
updateActiveLabCase({
destinationOrganizationId: e.target.value || null,
})
}
disabled={disabled || sent || filteredOrganizations.length === 0}
>
<option value="">{t('selectLabPlaceholder')}</option>
{filteredOrganizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</Dropdown>
{filteredOrganizations.length === 0 && (
<p className="text-xs text-text-muted">{t('noOrgMatch')}</p>
)}
</div>
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="primary"
disabled={
disabled ||
sent ||
sendBusyId === activeLabCase.clientId ||
!activeLabCase.destinationOrganizationId ||
activeLabCase.detailClientIds.length === 0
}
isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase)}
>
{t('sendToLab')}
</Button>
{sent && (
<CaseSentLabel
treatmentCase={{
destinationOrganizationId: activeLabCase.destinationOrganizationId,
sendToOrganizationIds: activeLabCase.destinationOrganizationId
? [activeLabCase.destinationOrganizationId]
: [],
sentAt: activeLabCase.sentAt ?? null,
sends: activeLabCase.sends,
}}
orgs={orgs}
/>
)}
</div>
</div>
)}
{canEdit && (
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
<Button
type="button"
variant="secondary"
disabled={disabled || saveLabBusy}
isLoading={saveLabBusy}
onClick={onSaveLabCases}
>
{t('saveLabShipments')}
</Button>
<p className="text-xs text-text-muted self-center">{t('labDispatchSaveHint')}</p>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -1,299 +0,0 @@
'use client';
import { useRef } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import type { LinkedOrganizationOption, TreatmentCaseDraft } from '@/types/treatment';
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
interface TreatmentCasesEditorProps {
cases: TreatmentCaseDraft[];
activeCaseId: string;
onActiveCaseChange: (id: string) => void;
onCasesChange: (cases: TreatmentCaseDraft[]) => void;
disabled: boolean;
canEdit: boolean;
isDirty: boolean;
saveBusy: boolean;
sendBusyId: string | null;
uploadBusy: boolean;
orgs: LinkedOrganizationOption[];
organizationSearch: string;
onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
onAddCase: () => void;
onPreview: () => void;
onSave: () => void;
onSendCase: (c: TreatmentCaseDraft) => void;
onUploadFiles: (files: FileList | null) => void;
}
export function TreatmentCasesEditor({
cases,
activeCaseId,
onActiveCaseChange,
onCasesChange,
disabled,
canEdit,
isDirty,
saveBusy,
sendBusyId,
uploadBusy,
orgs,
organizationSearch,
onOrganizationSearchChange,
recentOrganizationIds,
onRecentOrganizationPick,
onAddCase,
onPreview,
onSave,
onSendCase,
onUploadFiles,
}: TreatmentCasesEditorProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const attachmentInputRef = useRef<HTMLInputElement>(null);
const activeCase = cases.find((c) => c.clientId === activeCaseId) ?? cases[0];
const activeLinkedOrganizations = orgs.filter((o) => o.active);
const filteredOrganizations = (() => {
const q = organizationSearch.trim().toLowerCase();
if (!q) return activeLinkedOrganizations;
return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
})();
const recentOrganizations = recentOrganizationIds
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
.filter(Boolean) as LinkedOrganizationOption[];
const treatmentTypeTextColor = activeCase
? (
{
consultation: '#ddd6fe',
filling: '#fed7aa',
endo: '#fecaca',
visit: '#bae6fd',
hygiene: '#d9f99d',
} as Record<TreatmentCaseDraft['treatmentType'], string>
)[activeCase.treatmentType]
: undefined;
if (!activeCase) return null;
return (
<div className="surface-card p-4 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('casesTitle')}</h3>
<p className="text-xs text-text-muted mt-0.5">
{t('casesSubtitle')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" disabled={!canEdit || disabled} onClick={onPreview}>
{tCommon('preview')}
</Button>
<Button type="button" variant="primary" disabled={!canEdit || disabled} onClick={onAddCase}>
{t('addCase')}
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{cases.map((c, idx) => {
const sentSummary = formatCaseSentSummary(c.sends, {
organizationIds: c.sendToOrganizationIds ?? [],
sentAt: c.sentAt ?? null,
orgs,
}, t);
return (
<button
key={c.clientId}
type="button"
onClick={() => onActiveCaseChange(c.clientId)}
className={`
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
c.clientId === activeCaseId
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
{t('caseLabel', { n: idx + 1 })}
{sentSummary ? ` · ${sentSummary}` : ''}
</button>
);
})}
</div>
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
<label className="block text-xs font-medium text-text-secondary">
{t('comments')}
<textarea
value={activeCase.comment}
onChange={(e) => {
const v = e.target.value;
onCasesChange(
cases.map((c) => (c.clientId === activeCaseId ? { ...c, comment: v } : c)),
);
}}
placeholder={t('commentsPlaceholder')}
rows={5}
disabled={disabled || Boolean(activeCase.sentAt)}
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
/>
</label>
<div>
<Dropdown
label={t('treatmentType')}
value={activeCase.treatmentType}
onChange={(e) => {
const nextType = e.target.value as TreatmentCaseDraft['treatmentType'];
onCasesChange(
cases.map((c) =>
c.clientId === activeCaseId ? { ...c, treatmentType: nextType } : c,
),
);
}}
disabled={disabled || Boolean(activeCase.sentAt)}
className="capitalize"
style={{ color: treatmentTypeTextColor }}
>
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">{t('typeConsultation')}</option>
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">{t('typeFilling')}</option>
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">{t('typeEndo')}</option>
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">{t('typeVisit')}</option>
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">{t('typeHygiene')}</option>
</Dropdown>
</div>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
<input
ref={attachmentInputRef}
id="treatment-case-attachments"
type="file"
multiple
disabled={disabled || uploadBusy || Boolean(activeCase.sentAt)}
onChange={(e) => {
onUploadFiles(e.target.files);
e.target.value = '';
}}
className="sr-only"
aria-label={t('attachFiles')}
/>
<Button
type="button"
variant="primary"
disabled={disabled || uploadBusy || Boolean(activeCase.sentAt)}
isLoading={uploadBusy}
onClick={() => attachmentInputRef.current?.click()}
aria-controls="treatment-case-attachments"
>
{t('chooseFiles')}
</Button>
{activeCase.attachmentMetas.length > 0 && (
<ul className="mt-2 space-y-1 text-xs text-text-muted">
{activeCase.attachmentMetas.map((f) => (
<li key={f.id} className="truncate">
{f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
</li>
))}
</ul>
)}
</div>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('sendToOrgs')}
</p>
<div className="space-y-2 mb-2">
<SearchBar
value={organizationSearch}
onChange={onOrganizationSearchChange}
placeholder={t('searchOrgsPlaceholder')}
/>
{recentOrganizations.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted">{t('recent')}</span>
{recentOrganizations.map((o) => (
<button
key={o.id}
type="button"
disabled={disabled || Boolean(activeCase.sentAt)}
onClick={() => onRecentOrganizationPick(o.id)}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
>
{o.name}
</button>
))}
</div>
)}
</div>
<div className="flex flex-col gap-2">
{filteredOrganizations.map((o) => (
<Checkbox
key={o.id}
checked={activeCase.sendToOrganizationIds.includes(o.id)}
disabled={disabled || Boolean(activeCase.sentAt)}
onChange={(checked) => {
onCasesChange(
cases.map((c) => {
if (c.clientId !== activeCaseId) return c;
const next = new Set(c.sendToOrganizationIds);
if (checked) next.add(o.id);
else next.delete(o.id);
return { ...c, sendToOrganizationIds: [...next] };
}),
);
}}
label={o.name}
/>
))}
{filteredOrganizations.length === 0 && (
<p className="text-xs text-text-muted">{t('noOrgMatch')}</p>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="primary"
disabled={disabled || Boolean(activeCase.sentAt) || sendBusyId === activeCase.clientId}
isLoading={sendBusyId === activeCase.clientId}
onClick={() => onSendCase(activeCase)}
>
{t('sendThisCase')}
</Button>
{activeCase.sentAt && (
<CaseSentLabel treatmentCase={activeCase} orgs={orgs} />
)}
</div>
</div>
{canEdit && (
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
<Button
type="button"
variant="primary"
disabled={disabled || saveBusy}
isLoading={saveBusy}
onClick={onSave}
>
{t('saveDraft')}
</Button>
<p className="text-xs text-text-muted self-center">
{isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('sendSavesFirst')}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useRef } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import type { TreatmentDetailDraft } from '@/types/treatment';
import { TREATMENT_TYPE_COLORS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
interface TreatmentDetailsEditorProps {
details: TreatmentDetailDraft[];
activeDetailId: string;
onActiveDetailChange: (id: string) => void;
onDetailsChange: (details: TreatmentDetailDraft[]) => void;
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
disabled: boolean;
canEdit: boolean;
isDirty: boolean;
saveBusy: boolean;
uploadBusy: boolean;
onAddDetail: () => void;
onPreview: () => void;
onSave: () => void;
onUploadFiles: (files: FileList | null) => void;
}
export function TreatmentDetailsEditor({
details,
activeDetailId,
onActiveDetailChange,
onDetailsChange,
isDetailLocked,
disabled,
canEdit,
isDirty,
saveBusy,
uploadBusy,
onAddDetail,
onPreview,
onSave,
onUploadFiles,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const attachmentInputRef = useRef<HTMLInputElement>(null);
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
if (!activeDetail) return null;
const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked;
const treatmentTypeTextColor = TREATMENT_TYPE_COLORS[activeDetail.treatmentType];
return (
<div className="surface-card p-4 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
<p className="text-xs text-text-muted mt-0.5">{t('detailsSubtitle')}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" disabled={!canEdit || disabled} onClick={onPreview}>
{tCommon('preview')}
</Button>
<Button type="button" variant="primary" disabled={!canEdit || disabled} onClick={onAddDetail}>
{t('addDetail')}
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{details.map((d, idx) => (
<button
key={d.clientId}
type="button"
onClick={() => onActiveDetailChange(d.clientId)}
className={`
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
d.clientId === activeDetailId
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
{t('detailLabel', { n: idx + 1 })}
{isDetailLocked(d) ? ` · ${t('detailSentBadge')}` : ''}
</button>
))}
</div>
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
{locked && (
<p className="text-xs text-text-muted rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-2 py-1.5">
{t('detailLockedInShipment')}
</p>
)}
<label className="block text-xs font-medium text-text-secondary">
{t('comments')}
<textarea
value={activeDetail.comment}
onChange={(e) => {
const v = e.target.value;
onDetailsChange(
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
);
}}
placeholder={t('commentsPlaceholder')}
rows={5}
disabled={readOnly}
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
/>
</label>
<div>
<Dropdown
label={t('treatmentType')}
value={activeDetail.treatmentType}
onChange={(e) => {
const nextType = e.target.value as TreatmentDetailDraft['treatmentType'];
onDetailsChange(
details.map((d) =>
d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
),
);
}}
disabled={readOnly}
className="capitalize"
style={{ color: treatmentTypeTextColor }}
>
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">{t('typeConsultation')}</option>
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">{t('typeFilling')}</option>
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">{t('typeEndo')}</option>
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">{t('typeVisit')}</option>
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">{t('typeHygiene')}</option>
</Dropdown>
</div>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
<input
ref={attachmentInputRef}
id="treatment-detail-attachments"
type="file"
multiple
disabled={readOnly || uploadBusy}
onChange={(e) => {
onUploadFiles(e.target.files);
e.target.value = '';
}}
className="sr-only"
aria-label={t('attachFiles')}
/>
<Button
type="button"
variant="primary"
disabled={readOnly || uploadBusy}
isLoading={uploadBusy}
onClick={() => attachmentInputRef.current?.click()}
aria-controls="treatment-detail-attachments"
>
{t('chooseFiles')}
</Button>
{activeDetail.attachmentMetas.length > 0 && (
<ul className="mt-2 space-y-1 text-xs text-text-muted">
{activeDetail.attachmentMetas.map((f) => (
<li key={f.id} className="truncate">
{f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
</li>
))}
</ul>
)}
</div>
</div>
{canEdit && (
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
<Button
type="button"
variant="primary"
disabled={disabled || saveBusy}
isLoading={saveBusy}
onClick={onSave}
>
{t('saveDraft')}
</Button>
<p className="text-xs text-text-muted self-center">
{isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('detailsSaveHint')}
</p>
</div>
)}
</div>
);
}

View File

@@ -1,14 +1,13 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useRef } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Loader2, Paperclip, Send } from 'lucide-react'; import { Loader2, Paperclip } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import type { LinkedOrganizationOption, PastTreatment, PastTreatmentCase } from '@/types/treatment'; import type { LinkedOrganizationOption, PastTreatment, PastTreatmentCase } from '@/types/treatment';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { TreatmentLatestAttachmentPreview } from '@/components/ui/treatment/TreatmentLatestAttachmentPreview'; import { TreatmentLatestAttachmentPreview } from '@/components/ui/treatment/TreatmentLatestAttachmentPreview';
import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
export type TreatmentPreviewMode = 'readonly' | 'editable'; export type TreatmentPreviewMode = 'readonly' | 'editable';
@@ -18,12 +17,8 @@ interface TreatmentPreviewDialogProps {
treatment: PastTreatment | null; treatment: PastTreatment | null;
mode: TreatmentPreviewMode; mode: TreatmentPreviewMode;
orgs?: LinkedOrganizationOption[]; orgs?: LinkedOrganizationOption[];
sendBusyCaseId?: string | null;
uploadBusyCaseId?: string | null; uploadBusyCaseId?: string | null;
onAttach?: (caseKey: string, files: FileList) => void | Promise<void>; onAttach?: (caseKey: string, files: FileList) => void | Promise<void>;
onSend?: (caseKey: string, organizationIds: string[]) => void | Promise<void>;
getCaseOrgIds?: (caseKey: string) => string[];
onToggleCaseOrg?: (caseKey: string, organizationId: string, checked: boolean) => void;
} }
function caseKey(c: PastTreatmentCase): string { function caseKey(c: PastTreatmentCase): string {
@@ -33,35 +28,21 @@ function caseKey(c: PastTreatmentCase): string {
const caseActionIconClass = const caseActionIconClass =
'inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1.5 text-text-secondary transition-colors hover:bg-background-card/80 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40'; 'inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1.5 text-text-secondary transition-colors hover:bg-background-card/80 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
export function TreatmentPreviewDialog({ export function TreatmentPreviewDialog({
open, open,
onClose, onClose,
treatment, treatment,
mode, mode,
orgs = [], orgs = [],
sendBusyCaseId,
uploadBusyCaseId, uploadBusyCaseId,
onAttach, onAttach,
onSend,
getCaseOrgIds,
onToggleCaseOrg,
}: TreatmentPreviewDialogProps) { }: TreatmentPreviewDialogProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
const [expandedSendCaseId, setExpandedSendCaseId] = useState<string | null>(null);
const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({}); const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({});
if (!open || !treatment) return null; if (!open || !treatment) return null;
const editable = mode === 'editable'; const editable = mode === 'editable';
const activeOrgs = orgs.filter((o) => o.active);
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
@@ -76,9 +57,7 @@ export function TreatmentPreviewDialog({
<h2 id="treatment-preview-title" className="text-lg font-semibold text-text-primary pr-2"> <h2 id="treatment-preview-title" className="text-lg font-semibold text-text-primary pr-2">
{t('previewDialogTitle')} {t('previewDialogTitle')}
</h2> </h2>
<p className="text-xs text-text-muted mt-0.5"> <p className="text-xs text-text-muted mt-0.5">{t('previewDialogSubtitlePhase4')}</p>
{t('previewDialogSubtitle')}
</p>
</div> </div>
<DialogCloseButton onClick={onClose} /> <DialogCloseButton onClick={onClose} />
</div> </div>
@@ -94,8 +73,12 @@ export function TreatmentPreviewDialog({
{t('statusLabel')} {treatment.status} {t('statusLabel')} {treatment.status}
</p> </p>
{editable && (
<p className="text-xs text-text-muted">{t('previewLabDispatchHint')}</p>
)}
{treatment.details.length === 0 ? ( {treatment.details.length === 0 ? (
<p className="text-sm text-text-muted">{t('noCases')}</p> <p className="text-sm text-text-muted">{t('noDetails')}</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{treatment.details.map((c, idx) => { {treatment.details.map((c, idx) => {
@@ -105,15 +88,10 @@ export function TreatmentPreviewDialog({
attachments.length > 0 ? attachments[attachments.length - 1] : null; attachments.length > 0 ? attachments[attachments.length - 1] : null;
const sent = Boolean(c.sentAt); const sent = Boolean(c.sentAt);
const actionsEnabled = editable && !sent; const actionsEnabled = editable && !sent;
const selectedOrgIds =
getCaseOrgIds?.(key) ??
(c.destinationOrganizationId ? [c.destinationOrganizationId] : []);
const sendExpanded = expandedSendCaseId === key;
const comment = c.notes?.trim() ?? ''; const comment = c.notes?.trim() ?? '';
const attachBusy = uploadBusyCaseId === key; const attachBusy = uploadBusyCaseId === key;
const sendBusy = sendBusyCaseId === key; const typeKey = treatmentTypeLabelKey(c.treatmentType);
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS]; const typeLabel = t(typeKey as 'typeConsultation');
const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
return ( return (
<div <div
@@ -124,9 +102,9 @@ export function TreatmentPreviewDialog({
<div className="min-w-0 space-y-0.5"> <div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-xs font-medium text-text-primary"> <p className="text-xs font-medium text-text-primary">
{t('caseLabel', { n: idx + 1 })} {t('detailLabel', { n: idx + 1 })}
</p> </p>
{actionsEnabled && ( {actionsEnabled && onAttach && (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<input <input
ref={(el) => { ref={(el) => {
@@ -138,7 +116,7 @@ export function TreatmentPreviewDialog({
aria-hidden aria-hidden
onChange={(e) => { onChange={(e) => {
if (e.target.files?.length) { if (e.target.files?.length) {
void onAttach?.(key, e.target.files); void onAttach(key, e.target.files);
} }
e.target.value = ''; e.target.value = '';
}} }}
@@ -157,25 +135,6 @@ export function TreatmentPreviewDialog({
<Paperclip className="h-3.5 w-3.5" aria-hidden /> <Paperclip className="h-3.5 w-3.5" aria-hidden />
)} )}
</button> </button>
<button
type="button"
className={`${caseActionIconClass} ${
sendExpanded ? 'bg-primary-soft text-primary' : ''
}`}
disabled={sendBusy}
aria-label={t('sendCase')}
title={t('sendCase')}
aria-expanded={sendExpanded}
onClick={() =>
setExpandedSendCaseId((prev) => (prev === key ? null : key))
}
>
{sendBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Send className="h-3.5 w-3.5" aria-hidden />
)}
</button>
</div> </div>
)} )}
</div> </div>
@@ -191,7 +150,7 @@ export function TreatmentPreviewDialog({
{t('commentsLabel')} {comment} {t('commentsLabel')} {comment}
</p> </p>
) : ( ) : (
<p className="text-[11px] text-text-muted">{t('commentsEmpty')}</p> <p className="text-text-muted text-[11px]">{t('commentsEmpty')}</p>
)} )}
</div> </div>
@@ -209,38 +168,6 @@ export function TreatmentPreviewDialog({
<TreatmentLatestAttachmentPreview attachment={latestAttachment} /> <TreatmentLatestAttachmentPreview attachment={latestAttachment} />
</div> </div>
</div> </div>
{sendExpanded && editable && !sent && (
<div className="mt-2 space-y-2 border-t border-border/40 pt-2">
<p className="text-xs font-medium text-text-secondary">
{t('sendToLinkedOrgs')}
</p>
{activeOrgs.length === 0 ? (
<p className="text-xs text-text-muted">{t('noActiveOrgs')}</p>
) : (
<div className="flex flex-col gap-1.5">
{activeOrgs.map((o) => (
<Checkbox
key={o.id}
checked={selectedOrgIds.includes(o.id)}
onChange={(checked) => onToggleCaseOrg?.(key, o.id, checked)}
label={o.name}
/>
))}
</div>
)}
<Button
type="button"
variant="primary"
size="sm"
disabled={!selectedOrgIds.length || sendBusy}
isLoading={sendBusy}
onClick={() => void onSend?.(key, selectedOrgIds)}
>
{t('confirmSend')}
</Button>
</div>
)}
</div> </div>
); );
})} })}

View File

@@ -4,14 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip'; import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentCasesEditor } from '@/components/ui/treatment/TreatmentCasesEditor'; import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard'; import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import { import {
TreatmentPreviewDialog, TreatmentPreviewDialog,
type TreatmentPreviewMode, type TreatmentPreviewMode,
} from '@/components/ui/treatment/TreatmentPreviewDialog'; } from '@/components/ui/treatment/TreatmentPreviewDialog';
import { ToastStack } from '@/components/ui/shared/Toast'; import { ToastStack } from '@/components/ui/shared/Toast';
import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
import { import {
addCalendarDays, addCalendarDays,
compareLocalDayStart, compareLocalDayStart,
@@ -19,6 +21,7 @@ import {
startOfLocalDay, startOfLocalDay,
} from '@/components/appointments/appointmentTime'; } from '@/components/appointments/appointmentTime';
import { appointmentsApi } from '@/lib/api/appointments'; import { appointmentsApi } from '@/lib/api/appointments';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentsApi } from '@/lib/api/treatments'; import { treatmentsApi } from '@/lib/api/treatments';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions'; import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions';
@@ -28,27 +31,21 @@ import type { Organization } from '@/types/organization';
import type { AppointmentRecord } from '@/types/appointment'; import type { AppointmentRecord } from '@/types/appointment';
import type { import type {
FdiToothId, FdiToothId,
LabCaseDraft,
LinkedOrganizationOption, LinkedOrganizationOption,
PastLabCase,
PastTreatment, PastTreatment,
PastTreatmentCase, PastTreatmentCase,
TreatmentAppointment, TreatmentAppointment,
TreatmentCaseDraft, TreatmentDetailDraft,
} from '@/types/treatment'; } from '@/types/treatment';
const TREATMENT_TYPE_KEYS = { function newDetail(): TreatmentDetailDraft {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
function newCase(): TreatmentCaseDraft {
return { return {
clientId: clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID() ? crypto.randomUUID()
: `case-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, : `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: 'consultation', treatmentType: 'consultation',
teeth: [], teeth: [],
comment: '', comment: '',
@@ -58,6 +55,20 @@ function newCase(): TreatmentCaseDraft {
}; };
} }
function newLabCaseDraft(): LabCaseDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
destinationOrganizationId: null,
labComment: '',
detailClientIds: [],
sentAt: null,
sends: [],
};
}
function mapAppointment(record: AppointmentRecord): TreatmentAppointment { function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
return { return {
id: record.id, id: record.id,
@@ -71,7 +82,7 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
}; };
} }
function mapDetailFromApi(d: PastTreatmentCase): TreatmentCaseDraft { function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
return { return {
clientId: d.clientId, clientId: d.clientId,
id: d.id, id: d.id,
@@ -86,23 +97,33 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentCaseDraft {
}; };
} }
function serializeCases(cases: TreatmentCaseDraft[]) { function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId,
labComment: lc.labComment ?? '',
detailClientIds: lc.details.map((d) => d.clientId),
sentAt: lc.sentAt ?? null,
sends: lc.sends ?? [],
};
}
function serializeDetails(details: TreatmentDetailDraft[]) {
return JSON.stringify( return JSON.stringify(
cases.map((c) => ({ details.map((d) => ({
clientId: c.clientId, clientId: d.clientId,
id: c.id, id: d.id,
treatmentType: c.treatmentType, treatmentType: d.treatmentType,
teeth: c.teeth, teeth: d.teeth,
comment: c.comment, comment: d.comment,
attachmentMetas: c.attachmentMetas, attachmentMetas: d.attachmentMetas,
sendToOrganizationIds: c.sendToOrganizationIds,
sentAt: c.sentAt,
})), })),
); );
} }
function casesToPreviewTreatment( function detailsToPreviewTreatment(
cases: TreatmentCaseDraft[], details: TreatmentDetailDraft[],
meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string }, meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string },
): PastTreatment { ): PastTreatment {
return { return {
@@ -111,17 +132,17 @@ function casesToPreviewTreatment(
title: meta.title, title: meta.title,
treatmentAt: meta.treatmentAt, treatmentAt: meta.treatmentAt,
status: meta.status, status: meta.status,
details: cases.map((c, idx) => ({ details: details.map((d, idx) => ({
id: c.id ?? c.clientId ?? `draft-${idx + 1}`, id: d.id ?? d.clientId ?? `draft-${idx + 1}`,
clientId: c.clientId, clientId: d.clientId,
treatmentType: c.treatmentType, treatmentType: d.treatmentType,
teeth: c.teeth, teeth: d.teeth,
notes: c.comment || null, notes: d.comment || null,
attachmentMetas: c.attachmentMetas, attachmentMetas: d.attachmentMetas,
labCaseId: c.labCaseId ?? null, labCaseId: d.labCaseId ?? null,
destinationOrganizationId: c.sendToOrganizationIds[0] ?? null, destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
sends: c.sends ?? [], sends: d.sends ?? [],
sentAt: c.sentAt ?? null, sentAt: d.sentAt ?? null,
})), })),
labCases: [], labCases: [],
documents: [], documents: [],
@@ -152,17 +173,21 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [historyLoading, setHistoryLoading] = useState(false); const [historyLoading, setHistoryLoading] = useState(false);
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]); const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
const [cases, setCases] = useState<TreatmentCaseDraft[]>(() => [newCase()]); const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
const [activeCaseId, setActiveCaseId] = useState<string>(() => cases[0].clientId); const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
const [activeDetailId, setActiveDetailId] = useState<string>(() => details[0].clientId);
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null); const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
const selectionLockedRef = useRef(selectionLocked); const selectionLockedRef = useRef(selectionLocked);
selectionLockedRef.current = selectionLocked; selectionLockedRef.current = selectionLocked;
const [saveBusy, setSaveBusy] = useState(false); const [saveBusy, setSaveBusy] = useState(false);
const [saveLabBusy, setSaveLabBusy] = useState(false);
const [sendBusyId, setSendBusyId] = useState<string | null>(null); const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [uploadBusyCaseId, setUploadBusyCaseId] = useState<string | null>(null); const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState(''); const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]); const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
@@ -170,12 +195,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [previewTreatment, setPreviewTreatment] = useState<PastTreatment | null>(null); const [previewTreatment, setPreviewTreatment] = useState<PastTreatment | null>(null);
const [previewMode, setPreviewMode] = useState<TreatmentPreviewMode>('readonly'); const [previewMode, setPreviewMode] = useState<TreatmentPreviewMode>('readonly');
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId)),
[labCaseDrafts],
);
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (savedSnapshot === null) { if (savedSnapshot === null) {
return cases.length !== 1 || cases[0].comment !== '' || cases[0].teeth.length > 0; return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
} }
return serializeCases(cases) !== savedSnapshot; return serializeDetails(details) !== savedSnapshot;
}, [cases, savedSnapshot]); }, [details, savedSnapshot]);
const selectedAppointment = useMemo( const selectedAppointment = useMemo(
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null, () => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
@@ -189,16 +220,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const canEditTreatmentForDay = canEdit && Boolean(selectedAppointment) && !isViewingPastDay; const canEditTreatmentForDay = canEdit && Boolean(selectedAppointment) && !isViewingPastDay;
const activeCase = useMemo( const activeDetail = useMemo(
() => cases.find((c) => c.clientId === activeCaseId) ?? cases[0], () => details.find((d) => d.clientId === activeDetailId) ?? details[0],
[cases, activeCaseId], [details, activeDetailId],
); );
const selectedTeethSet = useMemo(() => new Set(activeCase?.teeth ?? []), [activeCase?.teeth]); const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const currentDraftPreview = useMemo<PastTreatment | null>(() => { const currentDraftPreview = useMemo<PastTreatment | null>(() => {
if (!selectedAppointment) return null; if (!selectedAppointment) return null;
return casesToPreviewTreatment(cases, { return detailsToPreviewTreatment(details, {
title: t('draftTitle', { title: t('draftTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`, patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}), }),
@@ -206,7 +237,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
treatmentAt: new Date().toISOString(), treatmentAt: new Date().toISOString(),
status: 'draft', status: 'draft',
}); });
}, [cases, selectedAppointment, t]); }, [details, selectedAppointment, t]);
useEffect(() => { useEffect(() => {
setSelectionLocked(false); setSelectionLocked(false);
@@ -262,8 +293,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
try { try {
const list = await treatmentsApi.listLinkedOrganizations(); const [orgsResponse, catalogResponse] = await Promise.all([
if (!cancelled) setOrgs(list.data); treatmentsApi.listLinkedOrganizations(),
treatmentCatalogApi.list(),
]);
if (cancelled) return;
setOrgs(orgsResponse.data);
setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
);
} catch (error: unknown) { } catch (error: unknown) {
if (!cancelled) { if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadOrgs'))); showError(formatApiErrorMessage(error, t('errorLoadOrgs')));
@@ -311,18 +349,22 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (response.data?.details?.length) { if (response.data?.details?.length) {
const mapped = response.data.details.map(mapDetailFromApi); const mapped = response.data.details.map(mapDetailFromApi);
setCases(mapped); setDetails(mapped);
setActiveCaseId((prev) => { setActiveDetailId((prev) => {
const stillExists = mapped.some((c) => c.clientId === prev); const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0].clientId; return stillExists ? prev : mapped[0].clientId;
}); });
setSavedSnapshot(serializeCases(mapped)); setSavedSnapshot(serializeDetails(mapped));
} else { } else {
const first = newCase(); const first = newDetail();
setCases([first]); setDetails([first]);
setActiveCaseId(first.clientId); setActiveDetailId(first.clientId);
setSavedSnapshot(serializeCases([first])); setSavedSnapshot(serializeDetails([first]));
} }
const mappedLabCases = (response.data?.labCases ?? []).map(mapLabCaseDraftFromApi);
setLabCaseDrafts(mappedLabCases);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch(''); setOrganizationSearch('');
} catch (error: unknown) { } catch (error: unknown) {
if (!cancelled) { if (!cancelled) {
@@ -357,31 +399,31 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[confirmDiscardIfDirty], [confirmDiscardIfDirty],
); );
const uploadForCase = useCallback( const uploadForDetail = useCallback(
async (caseClientId: string, files: FileList | File[]) => { async (detailClientId: string, files: FileList | File[]) => {
if (!canEditTreatmentForDay || !selectedAppointment) return; if (!canEditTreatmentForDay || !selectedAppointment) return;
const list = files instanceof FileList ? Array.from(files) : files; const list = files instanceof FileList ? Array.from(files) : files;
if (!list.length) return; if (!list.length) return;
setUploadBusyCaseId(caseClientId); setUploadBusyDetailId(detailClientId);
try { try {
const uploaded = await treatmentsApi.uploadCaseAttachments( const uploaded = await treatmentsApi.uploadCaseAttachments(
selectedAppointment.id, selectedAppointment.id,
caseClientId, detailClientId,
list, list,
); );
setCases((prev) => setDetails((prev) =>
prev.map((c) => prev.map((d) =>
c.clientId === caseClientId d.clientId === detailClientId
? { ...c, attachmentMetas: [...c.attachmentMetas, ...uploaded.data] } ? { ...d, attachmentMetas: [...d.attachmentMetas, ...uploaded.data] }
: c, : d,
), ),
); );
showSuccess(t('successFilesUploaded', { count: uploaded.data.length })); showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
} catch (error: unknown) { } catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpload'))); showError(formatApiErrorMessage(error, t('errorUpload')));
} finally { } finally {
setUploadBusyCaseId(null); setUploadBusyDetailId(null);
} }
}, },
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t], [canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t],
@@ -391,7 +433,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (!selectedAppointment) throw new Error('No appointment selected'); if (!selectedAppointment) throw new Error('No appointment selected');
const response = await treatmentsApi.saveDraft(selectedAppointment.id, { const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
details: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ details: details.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
clientId, clientId,
id, id,
treatmentType, treatmentType,
@@ -401,14 +443,50 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
})), })),
}); });
const mapped = response.data.details.map(mapDetailFromApi); const mapped = response.data.details.map(mapDetailFromApi);
setCases(mapped); setDetails(mapped);
setActiveCaseId((prev) => { setActiveDetailId((prev) => {
const stillExists = mapped.some((c) => c.clientId === prev); const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev; return stillExists ? prev : mapped[0]?.clientId ?? prev;
}); });
setSavedSnapshot(serializeCases(mapped)); setSavedSnapshot(serializeDetails(mapped));
return response.data; return response.data;
}, [cases, selectedAppointment]); }, [details, selectedAppointment]);
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment) => {
if (!selectedAppointment) throw new Error('No appointment selected');
const detailIdByClientId = new Map(
savedTreatment.details.map((d) => [d.clientId, d.id]),
);
const payload = labCaseDrafts.map((lc) => ({
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
labComment: lc.labComment.trim() || undefined,
treatmentDetailIds: lc.detailClientIds
.map((clientId) => detailIdByClientId.get(clientId))
.filter((id): id is string => Boolean(id)),
}));
if (payload.length === 0) {
return savedTreatment;
}
const response = await treatmentsApi.saveLabCases(selectedAppointment.id, {
labCases: payload,
});
const mapped = response.data.labCases.map(mapLabCaseDraftFromApi);
setLabCaseDrafts(mapped);
setActiveLabCaseId((prev) => {
if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
return mapped[0]?.clientId ?? null;
});
return response.data;
},
[labCaseDrafts, selectedAppointment],
);
const handleSaveAll = useCallback(async () => { const handleSaveAll = useCallback(async () => {
if (!canEditTreatmentForDay || !selectedAppointment) return; if (!canEditTreatmentForDay || !selectedAppointment) return;
@@ -423,70 +501,69 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
} }
}, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError, t]); }, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError, t]);
const handleSendCase = useCallback( const handleSaveLabCases = useCallback(async () => {
async (treatmentCase: TreatmentCaseDraft) => { if (!canEditTreatmentForDay || !selectedAppointment) return;
setSaveLabBusy(true);
try {
const saved = await persistDraft();
await persistLabCases(saved);
showSuccess(t('successLabShipmentsSaved'));
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
} finally {
setSaveLabBusy(false);
}
}, [
canEditTreatmentForDay,
selectedAppointment,
persistDraft,
persistLabCases,
showSuccess,
showError,
t,
]);
const handleSendLabCase = useCallback(
async (labCase: LabCaseDraft) => {
if (!canEditTreatmentForDay || !selectedAppointment) return; if (!canEditTreatmentForDay || !selectedAppointment) return;
const destinationOrgId = treatmentCase.sendToOrganizationIds.find((id) => if (!labCase.destinationOrganizationId) {
orgs.some((o) => o.id === id && o.active),
);
if (!destinationOrgId) {
showError(t('errorChooseOrg')); showError(t('errorChooseOrg'));
return; return;
} }
setSendBusyId(treatmentCase.clientId); if (labCase.detailClientIds.length === 0) {
showError(t('errorLabCaseNeedsDetails'));
return;
}
setSendBusyId(labCase.clientId);
try { try {
const saved = await persistDraft(); const saved = await persistDraft();
const serverDetail = saved.details.find((c) => c.clientId === treatmentCase.clientId); const afterLabCases = await persistLabCases(saved);
if (!serverDetail?.id) throw new Error(t('errorCaseMustSave'));
const labCaseClientId = treatmentCase.labCaseId const refreshedLabCase = afterLabCases.labCases.find(
? saved.labCases.find((lc) => lc.id === treatmentCase.labCaseId)?.clientId (lc) => lc.clientId === labCase.clientId || lc.id === labCase.id,
: `lab-${treatmentCase.clientId}`;
const existingLabCase = saved.labCases.find(
(lc) =>
lc.treatmentDetailIds.includes(serverDetail.id) &&
!lc.sentAt,
); );
if (!refreshedLabCase?.id) throw new Error(t('errorSendCase'));
const withLabCases = await treatmentsApi.saveLabCases(selectedAppointment.id, { const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
labCases: [
{
clientId: existingLabCase?.clientId ?? labCaseClientId ?? `lab-${treatmentCase.clientId}`,
id: existingLabCase?.id ?? treatmentCase.labCaseId ?? undefined,
destinationOrganizationId: destinationOrgId,
treatmentDetailIds: [serverDetail.id],
},
],
});
const labCase = withLabCases.data.labCases.find((lc) => setLabCaseDrafts((prev) =>
lc.treatmentDetailIds.includes(serverDetail.id), prev.map((lc) =>
); lc.clientId === labCase.clientId
if (!labCase?.id) throw new Error(t('errorSendCase'));
const response = await treatmentsApi.sendLabCase(labCase.id);
setCases((prev) => {
const next = prev.map((c) =>
c.clientId === treatmentCase.clientId
? { ? {
...c, ...lc,
labCaseId: response.data.id, id: response.data.id,
sentAt: response.data.sentAt, sentAt: response.data.sentAt,
sendToOrganizationIds: response.data.destinationOrganizationId destinationOrganizationId: response.data.destinationOrganizationId,
? [response.data.destinationOrganizationId]
: [],
sends: response.data.sends, sends: response.data.sends,
} }
: c, : lc,
); ),
setSavedSnapshot(serializeCases(next)); );
return next;
});
setRecentOrganizationIds((prev) => { setRecentOrganizationIds((prev) => {
const next = [destinationOrgId, ...prev.filter((id) => id !== destinationOrgId)]; const orgId = labCase.destinationOrganizationId!;
return next.slice(0, 10); return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
}); });
showSuccess(t('successCaseSent')); showSuccess(t('successCaseSent'));
} catch (error: unknown) { } catch (error: unknown) {
@@ -495,47 +572,33 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setSendBusyId(null); setSendBusyId(null);
} }
}, },
[canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, showSuccess, showError, t], [
canEditTreatmentForDay,
selectedAppointment,
persistDraft,
persistLabCases,
showSuccess,
showError,
t,
],
); );
const openPreview = useCallback( const openPreview = useCallback((treatment: PastTreatment, mode: TreatmentPreviewMode) => {
(treatment: PastTreatment, mode: TreatmentPreviewMode) => { setPreviewTreatment(treatment);
setPreviewTreatment(treatment); setPreviewMode(mode);
setPreviewMode(mode); setPreviewOpen(true);
setPreviewOpen(true); }, []);
},
[],
);
const openCurrentDraftPreview = useCallback(() => { const openCurrentDraftPreview = useCallback(() => {
if (!currentDraftPreview) return; if (!currentDraftPreview) return;
openPreview(currentDraftPreview, canEditTreatmentForDay ? 'editable' : 'readonly'); openPreview(currentDraftPreview, canEditTreatmentForDay ? 'editable' : 'readonly');
}, [currentDraftPreview, canEditTreatmentForDay, openPreview]); }, [currentDraftPreview, canEditTreatmentForDay, openPreview]);
const getCaseOrgIds = useCallback(
(caseKey: string) => cases.find((c) => c.clientId === caseKey)?.sendToOrganizationIds ?? [],
[cases],
);
const toggleCaseOrg = useCallback((caseKey: string, orgId: string, checked: boolean) => {
setCases((prev) =>
prev.map((c) => {
if (c.clientId !== caseKey || c.sentAt) return c;
const next = new Set(c.sendToOrganizationIds);
if (checked) next.add(orgId);
else next.delete(orgId);
return { ...c, sendToOrganizationIds: [...next] };
}),
);
}, []);
if (!canView) { if (!canView) {
return ( return (
<div className="surface-card p-6 max-w-xl"> <div className="surface-card p-6 max-w-xl">
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2> <h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
<p className="text-sm text-text-secondary mt-2"> <p className="text-sm text-text-secondary mt-2">{t('noPermissionBody')}</p>
{t('noPermissionBody')}
</p>
</div> </div>
); );
} }
@@ -545,7 +608,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1> <h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
{canEdit ? t('subtitleEdit') : t('subtitleReadOnly')} {canEdit ? t('subtitleEditPhase4') : t('subtitleReadOnly')}
</p> </p>
</header> </header>
@@ -579,7 +642,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<p className="text-[11px] text-text-secondary"> <p className="text-[11px] text-text-secondary">
{t('purposeLabel')}{' '} {t('purposeLabel')}{' '}
<span className="capitalize text-text-primary"> <span className="capitalize text-text-primary">
{t(TREATMENT_TYPE_KEYS[selectedAppointment.purpose as keyof typeof TREATMENT_TYPE_KEYS] ?? selectedAppointment.purpose)} {t(
treatmentTypeLabelKey(selectedAppointment.purpose) as 'typeConsultation',
)}
</span> </span>
</p> </p>
</div> </div>
@@ -598,7 +663,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<PastTreatmentsPanel <PastTreatmentsPanel
items={history} items={history}
loading={historyLoading} loading={historyLoading}
onReviewTreatment={(t) => openPreview(t, 'readonly')} onReviewTreatment={(item) => openPreview(item, 'readonly')}
/> />
</div> </div>
@@ -606,53 +671,73 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<FdiToothChart <FdiToothChart
selected={selectedTeethSet} selected={selectedTeethSet}
onToggle={(fdi) => { onToggle={(fdi) => {
if (!canEditTreatmentForDay) return; if (!canEditTreatmentForDay || isDetailLocked(activeDetail)) return;
setCases((prev) => setDetails((prev) =>
prev.map((c) => { prev.map((d) => {
if (c.clientId !== activeCaseId) return c; if (d.clientId !== activeDetailId) return d;
const set = new Set(c.teeth); const set = new Set(d.teeth);
if (set.has(fdi)) set.delete(fdi); if (set.has(fdi)) set.delete(fdi);
else set.add(fdi); else set.add(fdi);
return { ...c, teeth: [...set].sort() as FdiToothId[] }; return { ...d, teeth: [...set].sort() as FdiToothId[] };
}), }),
); );
}} }}
disabled={!canEditTreatmentForDay} disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
/> />
<TreatmentCasesEditor <TreatmentDetailsEditor
cases={cases} details={details}
activeCaseId={activeCaseId} activeDetailId={activeDetailId}
onActiveCaseChange={setActiveCaseId} onActiveDetailChange={setActiveDetailId}
onCasesChange={setCases} onDetailsChange={setDetails}
isDetailLocked={isDetailLocked}
disabled={!canEditTreatmentForDay} disabled={!canEditTreatmentForDay}
canEdit={canEdit} canEdit={canEdit}
isDirty={isDirty} isDirty={isDirty}
saveBusy={saveBusy} saveBusy={saveBusy}
sendBusyId={sendBusyId} uploadBusy={uploadBusyDetailId === activeDetailId}
uploadBusy={uploadBusyCaseId === activeCaseId} onAddDetail={() => {
const next = newDetail();
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
}}
onPreview={openCurrentDraftPreview}
onSave={() => void handleSaveAll()}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
/>
<LabCasesDispatchPanel
details={details}
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
activeLabCaseId={activeLabCaseId}
onActiveLabCaseChange={setActiveLabCaseId}
onLabCasesChange={setLabCaseDrafts}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
orgs={orgs} orgs={orgs}
organizationSearch={organizationSearch} organizationSearch={organizationSearch}
onOrganizationSearchChange={setOrganizationSearch} onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds} recentOrganizationIds={recentOrganizationIds}
onRecentOrganizationPick={(orgId) => { onRecentOrganizationPick={(orgId) => {
setCases((prev) => if (!activeLabCaseId) return;
prev.map((c) => { setLabCaseDrafts((prev) =>
if (c.clientId !== activeCaseId || c.sentAt) return c; prev.map((lc) =>
if (c.sendToOrganizationIds.includes(orgId)) return c; lc.clientId === activeLabCaseId && !lc.sentAt
return { ...c, sendToOrganizationIds: [...c.sendToOrganizationIds, orgId] }; ? { ...lc, destinationOrganizationId: orgId }
}), : lc,
),
); );
}} }}
onAddCase={() => { sendBusyId={sendBusyId}
const nextCase = newCase(); saveLabBusy={saveLabBusy}
setCases((prev) => [...prev, nextCase]); onAddLabCase={() => {
setActiveCaseId(nextCase.clientId); const next = newLabCaseDraft();
setLabCaseDrafts((prev) => [...prev, next]);
setActiveLabCaseId(next.clientId);
}} }}
onPreview={openCurrentDraftPreview} onSaveLabCases={() => void handleSaveLabCases()}
onSave={() => void handleSaveAll()} onSendLabCase={(lc) => void handleSendLabCase(lc)}
onSendCase={(c) => void handleSendCase(c)}
onUploadFiles={(files) => void uploadForCase(activeCaseId, files ?? [])}
/> />
</div> </div>
</div> </div>
@@ -665,16 +750,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
} }
mode={previewMode} mode={previewMode}
orgs={orgs} orgs={orgs}
sendBusyCaseId={sendBusyId} uploadBusyCaseId={uploadBusyDetailId}
uploadBusyCaseId={uploadBusyCaseId} onAttach={(caseKey, files) => uploadForDetail(caseKey, files)}
onAttach={(caseKey, files) => uploadForCase(caseKey, files)}
onSend={(caseKey, organizationIds) => {
const c = cases.find((item) => item.clientId === caseKey);
if (!c) return;
void handleSendCase({ ...c, sendToOrganizationIds: organizationIds });
}}
getCaseOrgIds={getCaseOrgIds}
onToggleCaseOrg={toggleCaseOrg}
/> />
</div> </div>
); );

View File

@@ -0,0 +1,19 @@
export const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
export const TREATMENT_TYPE_COLORS: Record<string, string> = {
consultation: '#ddd6fe',
filling: '#fed7aa',
endo: '#fecaca',
visit: '#bae6fd',
hygiene: '#d9f99d',
};
export function treatmentTypeLabelKey(code: string): string {
return TREATMENT_TYPE_KEYS[code as keyof typeof TREATMENT_TYPE_KEYS] ?? code;
}

View File

@@ -0,0 +1,9 @@
import { apiClient } from './client';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
export const treatmentCatalogApi = {
list: async (): Promise<{ success: boolean; data: TreatmentCatalogEntry[] }> => {
const response = await apiClient.get('/treatment-catalog');
return response.data;
},
};

View File

@@ -0,0 +1,6 @@
export interface TreatmentCatalogEntry {
id: string;
code: string;
labDependent: boolean;
sortOrder: number;
}

View File

@@ -136,6 +136,16 @@ export interface TreatmentDetailDraft {
/** @deprecated Use TreatmentDetailDraft — kept for editor components until Phase 4 rename */ /** @deprecated Use TreatmentDetailDraft — kept for editor components until Phase 4 rename */
export type TreatmentCaseDraft = TreatmentDetailDraft; export type TreatmentCaseDraft = TreatmentDetailDraft;
export interface LabCaseDraft {
clientId: string;
id?: string;
destinationOrganizationId: string | null;
labComment: string;
detailClientIds: string[];
sentAt?: string | null;
sends?: LabCaseSendInfo[];
}
export type SavedTreatmentDetailPayload = { export type SavedTreatmentDetailPayload = {
clientId: string; clientId: string;
id?: string; id?: string;