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.",
"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",

View File

@@ -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": "قوس بالا",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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>;
}

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';
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>
);
})}

View File

@@ -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>
);

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 */
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;