feature: Phase4 - Clinic two-step treatment UI
This commit is contained in:
@@ -396,6 +396,7 @@
|
||||
"noPermissionBody": "You do not have permission to view the Treatment tab for this organization.",
|
||||
"title": "Treatment",
|
||||
"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.",
|
||||
"pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.",
|
||||
"selectedPatient": "Selected patient",
|
||||
@@ -423,6 +424,13 @@
|
||||
"emptyDay": "No appointments assigned to you on this day.",
|
||||
"casesTitle": "Treatment cases",
|
||||
"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",
|
||||
"caseLabel": "Case {n}",
|
||||
"comments": "Comments",
|
||||
@@ -441,6 +449,24 @@
|
||||
"recent": "Recent:",
|
||||
"noOrgMatch": "No active organization matches your search.",
|
||||
"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",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"draftSaved": "Draft saved",
|
||||
@@ -464,7 +490,10 @@
|
||||
"moreCases": "+ {n} more case(s)",
|
||||
"previewDialogTitle": "Treatment preview",
|
||||
"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.",
|
||||
"noDetails": "No treatment details in this draft.",
|
||||
"typeLabel": "Type:",
|
||||
"commentsLabel": "Comments:",
|
||||
"commentsEmpty": "Comments: —",
|
||||
@@ -474,7 +503,7 @@
|
||||
"noActiveOrgs": "No active linked organizations.",
|
||||
"confirmSend": "Confirm send",
|
||||
"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:",
|
||||
"selectedEmpty": "—",
|
||||
"upperArch": "Upper arch",
|
||||
|
||||
@@ -396,6 +396,7 @@
|
||||
"noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.",
|
||||
"title": "درمان",
|
||||
"subtitleEdit": "پروندههای نوبتهای خود را مستند کنید، پیشنویسها را ذخیره کنید و کار را به سازمانهای مرتبط ارسال کنید.",
|
||||
"subtitleEditPhase4": "ابتدا جزئیات درمان را برنامهریزی کنید، سپس کار وابسته به لابراتوار را در بخش ارسال لاب گروهبندی کنید.",
|
||||
"subtitleReadOnly": "دسترسی فقط خواندنی — میتوانید نوبتها و تاریخچه درمان را بررسی کنید اما نمیتوانید ویرایش کنید.",
|
||||
"pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. میتوانید نوبتها و تاریخچه را بررسی کنید، اما پروندههای درمانی قابل اضافه یا تغییر نیستند.",
|
||||
"selectedPatient": "بیمار انتخاب شده",
|
||||
@@ -423,6 +424,13 @@
|
||||
"emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.",
|
||||
"casesTitle": "پروندههای درمانی",
|
||||
"casesSubtitle": "هر پرونده دارای دندانها، یادداشتها، پیوستها و مقصدهای ارسال خود است.",
|
||||
"detailsTitle": "جزئیات درمان",
|
||||
"detailsSubtitle": "دندانها، نوع، یادداشت و پیوستها را برای هر خط جزئیات برنامهریزی کنید.",
|
||||
"addDetail": "افزودن جزئیات",
|
||||
"detailLabel": "جزئیات {n}",
|
||||
"detailSentBadge": "ارسالشده",
|
||||
"detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.",
|
||||
"detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی میشود.",
|
||||
"addCase": "افزودن پرونده",
|
||||
"caseLabel": "پرونده {n}",
|
||||
"comments": "نظرات",
|
||||
@@ -441,6 +449,24 @@
|
||||
"recent": "اخیر:",
|
||||
"noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.",
|
||||
"sendThisCase": "ارسال این پرونده",
|
||||
"labDispatchTitle": "ارسال به لابراتوار",
|
||||
"labDispatchSubtitle": "جزئیات وابسته به لاب را در محمولهها گروهبندی کرده و به لابراتوارهای متصل ارسال کنید.",
|
||||
"addLabShipment": "افزودن محموله لاب",
|
||||
"labShipmentLabel": "محموله {n}",
|
||||
"includeDetails": "شامل جزئیات درمان",
|
||||
"labDetailLine": "جزئیات {n} · {type} · {teeth}",
|
||||
"noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.",
|
||||
"labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروهبندی و ارسال کنید.",
|
||||
"labComment": "پیام برای لابراتوار",
|
||||
"labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…",
|
||||
"selectLab": "لابراتوار مقصد",
|
||||
"selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…",
|
||||
"sendToLab": "ارسال به لابراتوار",
|
||||
"saveLabShipments": "ذخیره محمولههای لاب",
|
||||
"labDispatchSaveHint": "گروهبندی محموله را بدون ارسال ذخیره میکند.",
|
||||
"successLabShipmentsSaved": "محمولههای لاب ذخیره شد.",
|
||||
"errorSaveLabShipments": "ذخیره محمولههای لاب ناموفق بود.",
|
||||
"errorLabCaseNeedsDetails": "حداقل یک جزئیات درمان برای این محموله انتخاب کنید.",
|
||||
"saveDraft": "ذخیره پیشنویس درمان",
|
||||
"unsavedChanges": "تغییرات ذخیرهنشده",
|
||||
"draftSaved": "پیشنویس ذخیره شد",
|
||||
@@ -464,6 +490,9 @@
|
||||
"moreCases": "+ {n} پرونده دیگر",
|
||||
"previewDialogTitle": "پیشنمایش درمان",
|
||||
"previewDialogSubtitle": "بررسی پروندهها، پیوستها و مقصدهای ارسال.",
|
||||
"previewDialogSubtitlePhase4": "بررسی جزئیات درمان و پیوستها.",
|
||||
"previewLabDispatchHint": "برای ارسال کار به لابراتوار از بخش ارسال لاب در فضای کاری استفاده کنید.",
|
||||
"noDetails": "جزئیات درمانی در این پیشنویس وجود ندارد.",
|
||||
"noCases": "هیچ پروندهای در این درمان وجود ندارد.",
|
||||
"typeLabel": "نوع:",
|
||||
"commentsLabel": "نظرات:",
|
||||
@@ -474,7 +503,7 @@
|
||||
"noActiveOrgs": "هیچ سازمان مرتبط فعالی وجود ندارد.",
|
||||
"confirmSend": "تأیید ارسال",
|
||||
"toothChartTitle": "نمودار دندانها FDI",
|
||||
"toothChartHint": "برای انتخاب چندگانه روی دندانها ضربه بزنید. برای پرونده فعال اعمال میشود.",
|
||||
"toothChartHint": "برای انتخاب چندگانه روی دندانها ضربه بزنید. برای جزئیات فعال اعمال میشود.",
|
||||
"selectedLabel": "انتخاب شده:",
|
||||
"selectedEmpty": "—",
|
||||
"upperArch": "قوس بالا",
|
||||
|
||||
@@ -396,6 +396,7 @@
|
||||
"noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.",
|
||||
"title": "Behandeling",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -423,6 +424,13 @@
|
||||
"emptyDay": "Geen afspraken aan u toegewezen op deze dag.",
|
||||
"casesTitle": "Behandelcasussen",
|
||||
"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",
|
||||
"caseLabel": "Case {n}",
|
||||
"comments": "Opmerkingen",
|
||||
@@ -441,6 +449,24 @@
|
||||
"recent": "Recent:",
|
||||
"noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.",
|
||||
"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",
|
||||
"unsavedChanges": "Niet-opgeslagen wijzigingen",
|
||||
"draftSaved": "Concept opgeslagen",
|
||||
@@ -464,7 +490,10 @@
|
||||
"moreCases": "+ {n} meer case(s)",
|
||||
"previewDialogTitle": "Behandelvoorbeeld",
|
||||
"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.",
|
||||
"noDetails": "Geen behandeldetails in dit concept.",
|
||||
"typeLabel": "Type:",
|
||||
"commentsLabel": "Opmerkingen:",
|
||||
"commentsEmpty": "Opmerkingen: —",
|
||||
@@ -474,7 +503,7 @@
|
||||
"noActiveOrgs": "Geen actieve gekoppelde organisaties.",
|
||||
"confirmSend": "Bevestig verzending",
|
||||
"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:",
|
||||
"selectedEmpty": "—",
|
||||
"upperArch": "Bovenboog",
|
||||
|
||||
@@ -59,8 +59,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
<div className="flex-1 flex flex-col">
|
||||
<DashboardHeader organizationName={currentOrganization.name} />
|
||||
|
||||
<main className="p-6 flex-1 overflow-y-auto">
|
||||
<div className="surface-panel p-6 min-h-full">
|
||||
<main className="p-6 flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="surface-panel p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useId } from 'react';
|
||||
import { useId, type KeyboardEvent } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
type CheckboxProps = {
|
||||
@@ -14,6 +14,7 @@ type CheckboxProps = {
|
||||
|
||||
/**
|
||||
* 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({
|
||||
checked,
|
||||
@@ -26,25 +27,42 @@ export function Checkbox({
|
||||
const genId = useId();
|
||||
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 (
|
||||
<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={`
|
||||
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
|
||||
has-[:focus-visible]:ring-offset-background-secondary
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 focus-visible:ring-offset-2
|
||||
focus-visible:ring-offset-background-secondary
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span
|
||||
className={`
|
||||
flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border-2 transition-all duration-200
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/components/ui/Input.tsx
|
||||
import React, { forwardRef } from 'react';
|
||||
import React, { forwardRef, useId } from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
@@ -9,8 +9,8 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, icon, className = '', id, ...props }, ref) => {
|
||||
const inputId =
|
||||
id || `input-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const generatedId = useId();
|
||||
const inputId = id || generatedId;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -8,6 +8,8 @@ interface SearchBarProps {
|
||||
placeholder: string;
|
||||
onSubmit?: () => void;
|
||||
actions?: ReactNode;
|
||||
/** When true, renders inline without the outer surface-card wrapper (for nested panels). */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
@@ -16,23 +18,28 @@ export function SearchBar({
|
||||
placeholder,
|
||||
onSubmit,
|
||||
actions,
|
||||
embedded = false,
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
<div className="surface-card p-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSubmit?.();
|
||||
}}
|
||||
icon={<Search className="h-4 w-4 icon-flat" />}
|
||||
/>
|
||||
</div>
|
||||
{actions && <div className="flex gap-2">{actions}</div>}
|
||||
const field = (
|
||||
<div className={`flex gap-4 items-center ${embedded ? '' : 'flex-1'}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSubmit?.();
|
||||
}}
|
||||
icon={<Search className="h-4 w-4 icon-flat" />}
|
||||
/>
|
||||
</div>
|
||||
{actions && <div className="flex gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return field;
|
||||
}
|
||||
|
||||
return <div className="surface-card p-4">{field}</div>;
|
||||
}
|
||||
|
||||
320
frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
Normal file
320
frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
196
frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
Normal file
196
frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRef } from 'react';
|
||||
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 { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import type { LinkedOrganizationOption, PastTreatment, PastTreatmentCase } from '@/types/treatment';
|
||||
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||
import { TreatmentLatestAttachmentPreview } from '@/components/ui/treatment/TreatmentLatestAttachmentPreview';
|
||||
import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
|
||||
export type TreatmentPreviewMode = 'readonly' | 'editable';
|
||||
|
||||
@@ -18,12 +17,8 @@ interface TreatmentPreviewDialogProps {
|
||||
treatment: PastTreatment | null;
|
||||
mode: TreatmentPreviewMode;
|
||||
orgs?: LinkedOrganizationOption[];
|
||||
sendBusyCaseId?: string | null;
|
||||
uploadBusyCaseId?: string | null;
|
||||
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 {
|
||||
@@ -33,35 +28,21 @@ function caseKey(c: PastTreatmentCase): string {
|
||||
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';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
export function TreatmentPreviewDialog({
|
||||
open,
|
||||
onClose,
|
||||
treatment,
|
||||
mode,
|
||||
orgs = [],
|
||||
sendBusyCaseId,
|
||||
uploadBusyCaseId,
|
||||
onAttach,
|
||||
onSend,
|
||||
getCaseOrgIds,
|
||||
onToggleCaseOrg,
|
||||
}: TreatmentPreviewDialogProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const [expandedSendCaseId, setExpandedSendCaseId] = useState<string | null>(null);
|
||||
const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
if (!open || !treatment) return null;
|
||||
|
||||
const editable = mode === 'editable';
|
||||
const activeOrgs = orgs.filter((o) => o.active);
|
||||
|
||||
return (
|
||||
<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">
|
||||
{t('previewDialogTitle')}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{t('previewDialogSubtitle')}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t('previewDialogSubtitlePhase4')}</p>
|
||||
</div>
|
||||
<DialogCloseButton onClick={onClose} />
|
||||
</div>
|
||||
@@ -94,8 +73,12 @@ export function TreatmentPreviewDialog({
|
||||
{t('statusLabel')} {treatment.status}
|
||||
</p>
|
||||
|
||||
{editable && (
|
||||
<p className="text-xs text-text-muted">{t('previewLabDispatchHint')}</p>
|
||||
)}
|
||||
|
||||
{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">
|
||||
{treatment.details.map((c, idx) => {
|
||||
@@ -105,15 +88,10 @@ export function TreatmentPreviewDialog({
|
||||
attachments.length > 0 ? attachments[attachments.length - 1] : null;
|
||||
const sent = Boolean(c.sentAt);
|
||||
const actionsEnabled = editable && !sent;
|
||||
const selectedOrgIds =
|
||||
getCaseOrgIds?.(key) ??
|
||||
(c.destinationOrganizationId ? [c.destinationOrganizationId] : []);
|
||||
const sendExpanded = expandedSendCaseId === key;
|
||||
const comment = c.notes?.trim() ?? '';
|
||||
const attachBusy = uploadBusyCaseId === key;
|
||||
const sendBusy = sendBusyCaseId === key;
|
||||
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
|
||||
const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
|
||||
const typeKey = treatmentTypeLabelKey(c.treatmentType);
|
||||
const typeLabel = t(typeKey as 'typeConsultation');
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -124,9 +102,9 @@ export function TreatmentPreviewDialog({
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-text-primary">
|
||||
{t('caseLabel', { n: idx + 1 })}
|
||||
{t('detailLabel', { n: idx + 1 })}
|
||||
</p>
|
||||
{actionsEnabled && (
|
||||
{actionsEnabled && onAttach && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input
|
||||
ref={(el) => {
|
||||
@@ -138,7 +116,7 @@ export function TreatmentPreviewDialog({
|
||||
aria-hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
void onAttach?.(key, e.target.files);
|
||||
void onAttach(key, e.target.files);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
@@ -157,25 +135,6 @@ export function TreatmentPreviewDialog({
|
||||
<Paperclip className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</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>
|
||||
@@ -191,7 +150,7 @@ export function TreatmentPreviewDialog({
|
||||
{t('commentsLabel')} {comment}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-text-muted">{t('commentsEmpty')}</p>
|
||||
<p className="text-text-muted text-[11px]">{t('commentsEmpty')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -209,38 +168,6 @@ export function TreatmentPreviewDialog({
|
||||
<TreatmentLatestAttachmentPreview attachment={latestAttachment} />
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,14 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
||||
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 {
|
||||
TreatmentPreviewDialog,
|
||||
type TreatmentPreviewMode,
|
||||
} from '@/components/ui/treatment/TreatmentPreviewDialog';
|
||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||
import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import {
|
||||
addCalendarDays,
|
||||
compareLocalDayStart,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
startOfLocalDay,
|
||||
} from '@/components/appointments/appointmentTime';
|
||||
import { appointmentsApi } from '@/lib/api/appointments';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { treatmentsApi } from '@/lib/api/treatments';
|
||||
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
||||
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 {
|
||||
FdiToothId,
|
||||
LabCaseDraft,
|
||||
LinkedOrganizationOption,
|
||||
PastLabCase,
|
||||
PastTreatment,
|
||||
PastTreatmentCase,
|
||||
TreatmentAppointment,
|
||||
TreatmentCaseDraft,
|
||||
TreatmentDetailDraft,
|
||||
} from '@/types/treatment';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
function newCase(): TreatmentCaseDraft {
|
||||
function newDetail(): TreatmentDetailDraft {
|
||||
return {
|
||||
clientId:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `case-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
treatmentType: 'consultation',
|
||||
teeth: [],
|
||||
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 {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -71,7 +82,7 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
|
||||
};
|
||||
}
|
||||
|
||||
function mapDetailFromApi(d: PastTreatmentCase): TreatmentCaseDraft {
|
||||
function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
|
||||
return {
|
||||
clientId: d.clientId,
|
||||
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(
|
||||
cases.map((c) => ({
|
||||
clientId: c.clientId,
|
||||
id: c.id,
|
||||
treatmentType: c.treatmentType,
|
||||
teeth: c.teeth,
|
||||
comment: c.comment,
|
||||
attachmentMetas: c.attachmentMetas,
|
||||
sendToOrganizationIds: c.sendToOrganizationIds,
|
||||
sentAt: c.sentAt,
|
||||
details.map((d) => ({
|
||||
clientId: d.clientId,
|
||||
id: d.id,
|
||||
treatmentType: d.treatmentType,
|
||||
teeth: d.teeth,
|
||||
comment: d.comment,
|
||||
attachmentMetas: d.attachmentMetas,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function casesToPreviewTreatment(
|
||||
cases: TreatmentCaseDraft[],
|
||||
function detailsToPreviewTreatment(
|
||||
details: TreatmentDetailDraft[],
|
||||
meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string },
|
||||
): PastTreatment {
|
||||
return {
|
||||
@@ -111,17 +132,17 @@ function casesToPreviewTreatment(
|
||||
title: meta.title,
|
||||
treatmentAt: meta.treatmentAt,
|
||||
status: meta.status,
|
||||
details: cases.map((c, idx) => ({
|
||||
id: c.id ?? c.clientId ?? `draft-${idx + 1}`,
|
||||
clientId: c.clientId,
|
||||
treatmentType: c.treatmentType,
|
||||
teeth: c.teeth,
|
||||
notes: c.comment || null,
|
||||
attachmentMetas: c.attachmentMetas,
|
||||
labCaseId: c.labCaseId ?? null,
|
||||
destinationOrganizationId: c.sendToOrganizationIds[0] ?? null,
|
||||
sends: c.sends ?? [],
|
||||
sentAt: c.sentAt ?? null,
|
||||
details: details.map((d, idx) => ({
|
||||
id: d.id ?? d.clientId ?? `draft-${idx + 1}`,
|
||||
clientId: d.clientId,
|
||||
treatmentType: d.treatmentType,
|
||||
teeth: d.teeth,
|
||||
notes: d.comment || null,
|
||||
attachmentMetas: d.attachmentMetas,
|
||||
labCaseId: d.labCaseId ?? null,
|
||||
destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
|
||||
sends: d.sends ?? [],
|
||||
sentAt: d.sentAt ?? null,
|
||||
})),
|
||||
labCases: [],
|
||||
documents: [],
|
||||
@@ -152,17 +173,21 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
|
||||
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
|
||||
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
|
||||
|
||||
const [cases, setCases] = useState<TreatmentCaseDraft[]>(() => [newCase()]);
|
||||
const [activeCaseId, setActiveCaseId] = useState<string>(() => cases[0].clientId);
|
||||
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
|
||||
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 selectionLockedRef = useRef(selectionLocked);
|
||||
selectionLockedRef.current = selectionLocked;
|
||||
|
||||
const [saveBusy, setSaveBusy] = useState(false);
|
||||
const [saveLabBusy, setSaveLabBusy] = useState(false);
|
||||
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 [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
|
||||
|
||||
@@ -170,12 +195,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
const [previewTreatment, setPreviewTreatment] = useState<PastTreatment | null>(null);
|
||||
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(() => {
|
||||
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;
|
||||
}, [cases, savedSnapshot]);
|
||||
return serializeDetails(details) !== savedSnapshot;
|
||||
}, [details, savedSnapshot]);
|
||||
|
||||
const selectedAppointment = useMemo(
|
||||
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
|
||||
@@ -189,16 +220,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
const canEditTreatmentForDay = canEdit && Boolean(selectedAppointment) && !isViewingPastDay;
|
||||
|
||||
const activeCase = useMemo(
|
||||
() => cases.find((c) => c.clientId === activeCaseId) ?? cases[0],
|
||||
[cases, activeCaseId],
|
||||
const activeDetail = useMemo(
|
||||
() => details.find((d) => d.clientId === activeDetailId) ?? details[0],
|
||||
[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>(() => {
|
||||
if (!selectedAppointment) return null;
|
||||
return casesToPreviewTreatment(cases, {
|
||||
return detailsToPreviewTreatment(details, {
|
||||
title: t('draftTitle', {
|
||||
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
||||
}),
|
||||
@@ -206,7 +237,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
treatmentAt: new Date().toISOString(),
|
||||
status: 'draft',
|
||||
});
|
||||
}, [cases, selectedAppointment, t]);
|
||||
}, [details, selectedAppointment, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionLocked(false);
|
||||
@@ -262,8 +293,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await treatmentsApi.listLinkedOrganizations();
|
||||
if (!cancelled) setOrgs(list.data);
|
||||
const [orgsResponse, catalogResponse] = await Promise.all([
|
||||
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) {
|
||||
if (!cancelled) {
|
||||
showError(formatApiErrorMessage(error, t('errorLoadOrgs')));
|
||||
@@ -311,18 +349,22 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
if (response.data?.details?.length) {
|
||||
const mapped = response.data.details.map(mapDetailFromApi);
|
||||
setCases(mapped);
|
||||
setActiveCaseId((prev) => {
|
||||
const stillExists = mapped.some((c) => c.clientId === prev);
|
||||
setDetails(mapped);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||
return stillExists ? prev : mapped[0].clientId;
|
||||
});
|
||||
setSavedSnapshot(serializeCases(mapped));
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
} else {
|
||||
const first = newCase();
|
||||
setCases([first]);
|
||||
setActiveCaseId(first.clientId);
|
||||
setSavedSnapshot(serializeCases([first]));
|
||||
const first = newDetail();
|
||||
setDetails([first]);
|
||||
setActiveDetailId(first.clientId);
|
||||
setSavedSnapshot(serializeDetails([first]));
|
||||
}
|
||||
|
||||
const mappedLabCases = (response.data?.labCases ?? []).map(mapLabCaseDraftFromApi);
|
||||
setLabCaseDrafts(mappedLabCases);
|
||||
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
||||
setOrganizationSearch('');
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
@@ -357,31 +399,31 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
[confirmDiscardIfDirty],
|
||||
);
|
||||
|
||||
const uploadForCase = useCallback(
|
||||
async (caseClientId: string, files: FileList | File[]) => {
|
||||
const uploadForDetail = useCallback(
|
||||
async (detailClientId: string, files: FileList | File[]) => {
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
const list = files instanceof FileList ? Array.from(files) : files;
|
||||
if (!list.length) return;
|
||||
|
||||
setUploadBusyCaseId(caseClientId);
|
||||
setUploadBusyDetailId(detailClientId);
|
||||
try {
|
||||
const uploaded = await treatmentsApi.uploadCaseAttachments(
|
||||
selectedAppointment.id,
|
||||
caseClientId,
|
||||
detailClientId,
|
||||
list,
|
||||
);
|
||||
setCases((prev) =>
|
||||
prev.map((c) =>
|
||||
c.clientId === caseClientId
|
||||
? { ...c, attachmentMetas: [...c.attachmentMetas, ...uploaded.data] }
|
||||
: c,
|
||||
setDetails((prev) =>
|
||||
prev.map((d) =>
|
||||
d.clientId === detailClientId
|
||||
? { ...d, attachmentMetas: [...d.attachmentMetas, ...uploaded.data] }
|
||||
: d,
|
||||
),
|
||||
);
|
||||
showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, t('errorUpload')));
|
||||
} finally {
|
||||
setUploadBusyCaseId(null);
|
||||
setUploadBusyDetailId(null);
|
||||
}
|
||||
},
|
||||
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t],
|
||||
@@ -391,7 +433,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
if (!selectedAppointment) throw new Error('No appointment selected');
|
||||
|
||||
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,
|
||||
id,
|
||||
treatmentType,
|
||||
@@ -401,14 +443,50 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
})),
|
||||
});
|
||||
const mapped = response.data.details.map(mapDetailFromApi);
|
||||
setCases(mapped);
|
||||
setActiveCaseId((prev) => {
|
||||
const stillExists = mapped.some((c) => c.clientId === prev);
|
||||
setDetails(mapped);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
||||
});
|
||||
setSavedSnapshot(serializeCases(mapped));
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
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 () => {
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
@@ -423,70 +501,69 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
}
|
||||
}, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError, t]);
|
||||
|
||||
const handleSendCase = useCallback(
|
||||
async (treatmentCase: TreatmentCaseDraft) => {
|
||||
const handleSaveLabCases = useCallback(async () => {
|
||||
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;
|
||||
const destinationOrgId = treatmentCase.sendToOrganizationIds.find((id) =>
|
||||
orgs.some((o) => o.id === id && o.active),
|
||||
);
|
||||
if (!destinationOrgId) {
|
||||
if (!labCase.destinationOrganizationId) {
|
||||
showError(t('errorChooseOrg'));
|
||||
return;
|
||||
}
|
||||
setSendBusyId(treatmentCase.clientId);
|
||||
if (labCase.detailClientIds.length === 0) {
|
||||
showError(t('errorLabCaseNeedsDetails'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSendBusyId(labCase.clientId);
|
||||
try {
|
||||
const saved = await persistDraft();
|
||||
const serverDetail = saved.details.find((c) => c.clientId === treatmentCase.clientId);
|
||||
if (!serverDetail?.id) throw new Error(t('errorCaseMustSave'));
|
||||
const afterLabCases = await persistLabCases(saved);
|
||||
|
||||
const labCaseClientId = treatmentCase.labCaseId
|
||||
? saved.labCases.find((lc) => lc.id === treatmentCase.labCaseId)?.clientId
|
||||
: `lab-${treatmentCase.clientId}`;
|
||||
|
||||
const existingLabCase = saved.labCases.find(
|
||||
(lc) =>
|
||||
lc.treatmentDetailIds.includes(serverDetail.id) &&
|
||||
!lc.sentAt,
|
||||
const refreshedLabCase = afterLabCases.labCases.find(
|
||||
(lc) => lc.clientId === labCase.clientId || lc.id === labCase.id,
|
||||
);
|
||||
if (!refreshedLabCase?.id) throw new Error(t('errorSendCase'));
|
||||
|
||||
const withLabCases = await treatmentsApi.saveLabCases(selectedAppointment.id, {
|
||||
labCases: [
|
||||
{
|
||||
clientId: existingLabCase?.clientId ?? labCaseClientId ?? `lab-${treatmentCase.clientId}`,
|
||||
id: existingLabCase?.id ?? treatmentCase.labCaseId ?? undefined,
|
||||
destinationOrganizationId: destinationOrgId,
|
||||
treatmentDetailIds: [serverDetail.id],
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
|
||||
|
||||
const labCase = withLabCases.data.labCases.find((lc) =>
|
||||
lc.treatmentDetailIds.includes(serverDetail.id),
|
||||
);
|
||||
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
|
||||
setLabCaseDrafts((prev) =>
|
||||
prev.map((lc) =>
|
||||
lc.clientId === labCase.clientId
|
||||
? {
|
||||
...c,
|
||||
labCaseId: response.data.id,
|
||||
...lc,
|
||||
id: response.data.id,
|
||||
sentAt: response.data.sentAt,
|
||||
sendToOrganizationIds: response.data.destinationOrganizationId
|
||||
? [response.data.destinationOrganizationId]
|
||||
: [],
|
||||
destinationOrganizationId: response.data.destinationOrganizationId,
|
||||
sends: response.data.sends,
|
||||
}
|
||||
: c,
|
||||
);
|
||||
setSavedSnapshot(serializeCases(next));
|
||||
return next;
|
||||
});
|
||||
: lc,
|
||||
),
|
||||
);
|
||||
|
||||
setRecentOrganizationIds((prev) => {
|
||||
const next = [destinationOrgId, ...prev.filter((id) => id !== destinationOrgId)];
|
||||
return next.slice(0, 10);
|
||||
const orgId = labCase.destinationOrganizationId!;
|
||||
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
|
||||
});
|
||||
showSuccess(t('successCaseSent'));
|
||||
} catch (error: unknown) {
|
||||
@@ -495,47 +572,33 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
setSendBusyId(null);
|
||||
}
|
||||
},
|
||||
[canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, showSuccess, showError, t],
|
||||
[
|
||||
canEditTreatmentForDay,
|
||||
selectedAppointment,
|
||||
persistDraft,
|
||||
persistLabCases,
|
||||
showSuccess,
|
||||
showError,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const openPreview = useCallback(
|
||||
(treatment: PastTreatment, mode: TreatmentPreviewMode) => {
|
||||
setPreviewTreatment(treatment);
|
||||
setPreviewMode(mode);
|
||||
setPreviewOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const openPreview = useCallback((treatment: PastTreatment, mode: TreatmentPreviewMode) => {
|
||||
setPreviewTreatment(treatment);
|
||||
setPreviewMode(mode);
|
||||
setPreviewOpen(true);
|
||||
}, []);
|
||||
|
||||
const openCurrentDraftPreview = useCallback(() => {
|
||||
if (!currentDraftPreview) return;
|
||||
openPreview(currentDraftPreview, canEditTreatmentForDay ? 'editable' : 'readonly');
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="surface-card p-6 max-w-xl">
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
|
||||
<p className="text-sm text-text-secondary mt-2">
|
||||
{t('noPermissionBody')}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary mt-2">{t('noPermissionBody')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -545,7 +608,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{canEdit ? t('subtitleEdit') : t('subtitleReadOnly')}
|
||||
{canEdit ? t('subtitleEditPhase4') : t('subtitleReadOnly')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -579,7 +642,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
{t('purposeLabel')}{' '}
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
@@ -598,7 +663,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<PastTreatmentsPanel
|
||||
items={history}
|
||||
loading={historyLoading}
|
||||
onReviewTreatment={(t) => openPreview(t, 'readonly')}
|
||||
onReviewTreatment={(item) => openPreview(item, 'readonly')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -606,53 +671,73 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<FdiToothChart
|
||||
selected={selectedTeethSet}
|
||||
onToggle={(fdi) => {
|
||||
if (!canEditTreatmentForDay) return;
|
||||
setCases((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.clientId !== activeCaseId) return c;
|
||||
const set = new Set(c.teeth);
|
||||
if (!canEditTreatmentForDay || isDetailLocked(activeDetail)) return;
|
||||
setDetails((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.clientId !== activeDetailId) return d;
|
||||
const set = new Set(d.teeth);
|
||||
if (set.has(fdi)) set.delete(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
|
||||
cases={cases}
|
||||
activeCaseId={activeCaseId}
|
||||
onActiveCaseChange={setActiveCaseId}
|
||||
onCasesChange={setCases}
|
||||
<TreatmentDetailsEditor
|
||||
details={details}
|
||||
activeDetailId={activeDetailId}
|
||||
onActiveDetailChange={setActiveDetailId}
|
||||
onDetailsChange={setDetails}
|
||||
isDetailLocked={isDetailLocked}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
canEdit={canEdit}
|
||||
isDirty={isDirty}
|
||||
saveBusy={saveBusy}
|
||||
sendBusyId={sendBusyId}
|
||||
uploadBusy={uploadBusyCaseId === activeCaseId}
|
||||
uploadBusy={uploadBusyDetailId === activeDetailId}
|
||||
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}
|
||||
organizationSearch={organizationSearch}
|
||||
onOrganizationSearchChange={setOrganizationSearch}
|
||||
recentOrganizationIds={recentOrganizationIds}
|
||||
onRecentOrganizationPick={(orgId) => {
|
||||
setCases((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.clientId !== activeCaseId || c.sentAt) return c;
|
||||
if (c.sendToOrganizationIds.includes(orgId)) return c;
|
||||
return { ...c, sendToOrganizationIds: [...c.sendToOrganizationIds, orgId] };
|
||||
}),
|
||||
if (!activeLabCaseId) return;
|
||||
setLabCaseDrafts((prev) =>
|
||||
prev.map((lc) =>
|
||||
lc.clientId === activeLabCaseId && !lc.sentAt
|
||||
? { ...lc, destinationOrganizationId: orgId }
|
||||
: lc,
|
||||
),
|
||||
);
|
||||
}}
|
||||
onAddCase={() => {
|
||||
const nextCase = newCase();
|
||||
setCases((prev) => [...prev, nextCase]);
|
||||
setActiveCaseId(nextCase.clientId);
|
||||
sendBusyId={sendBusyId}
|
||||
saveLabBusy={saveLabBusy}
|
||||
onAddLabCase={() => {
|
||||
const next = newLabCaseDraft();
|
||||
setLabCaseDrafts((prev) => [...prev, next]);
|
||||
setActiveLabCaseId(next.clientId);
|
||||
}}
|
||||
onPreview={openCurrentDraftPreview}
|
||||
onSave={() => void handleSaveAll()}
|
||||
onSendCase={(c) => void handleSendCase(c)}
|
||||
onUploadFiles={(files) => void uploadForCase(activeCaseId, files ?? [])}
|
||||
onSaveLabCases={() => void handleSaveLabCases()}
|
||||
onSendLabCase={(lc) => void handleSendLabCase(lc)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -665,16 +750,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
}
|
||||
mode={previewMode}
|
||||
orgs={orgs}
|
||||
sendBusyCaseId={sendBusyId}
|
||||
uploadBusyCaseId={uploadBusyCaseId}
|
||||
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}
|
||||
uploadBusyCaseId={uploadBusyDetailId}
|
||||
onAttach={(caseKey, files) => uploadForDetail(caseKey, files)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
19
frontend/src/components/ui/treatment/treatmentTypeDisplay.ts
Normal file
19
frontend/src/components/ui/treatment/treatmentTypeDisplay.ts
Normal 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;
|
||||
}
|
||||
9
frontend/src/lib/api/treatment-catalog.ts
Normal file
9
frontend/src/lib/api/treatment-catalog.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
6
frontend/src/types/treatment-catalog.ts
Normal file
6
frontend/src/types/treatment-catalog.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface TreatmentCatalogEntry {
|
||||
id: string;
|
||||
code: string;
|
||||
labDependent: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
@@ -136,6 +136,16 @@ export interface TreatmentDetailDraft {
|
||||
/** @deprecated Use TreatmentDetailDraft — kept for editor components until Phase 4 rename */
|
||||
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 = {
|
||||
clientId: string;
|
||||
id?: string;
|
||||
|
||||
Reference in New Issue
Block a user