feature: treatment frontend wired with the newly implemented backend. no mocked data no more.

This commit is contained in:
2026-05-19 23:43:43 +03:30
parent f743046b38
commit 633c939560
13 changed files with 1199 additions and 528 deletions

View File

@@ -22,7 +22,10 @@ const treatmentInclude = {
orderBy: [{ sortOrder: 'asc' as const }], orderBy: [{ sortOrder: 'asc' as const }],
include: { include: {
attachments: { orderBy: [{ createdAt: 'asc' as const }] }, attachments: { orderBy: [{ createdAt: 'asc' as const }] },
sends: { orderBy: [{ sentAt: 'asc' as const }] }, sends: {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
}, },
}, },
}; };
@@ -332,7 +335,10 @@ export class TreatmentsService {
where: { id: caseId }, where: { id: caseId },
include: { include: {
attachments: { orderBy: [{ createdAt: 'asc' }] }, attachments: { orderBy: [{ createdAt: 'asc' }] },
sends: { orderBy: [{ sentAt: 'asc' }] }, sends: {
orderBy: [{ sentAt: 'asc' }],
include: { organization: { select: { id: true, name: true } } },
},
}, },
}); });
@@ -459,7 +465,7 @@ export class TreatmentsService {
mimeType: string; mimeType: string;
sizeBytes: number; sizeBytes: number;
}>; }>;
sends: Array<{ organizationId: string; sentAt: Date }>; sends: Array<{ organizationId: string; sentAt: Date; organization: { id: string; name: string } }>;
}>; }>;
}) { }) {
const documents = treatment.cases.flatMap((c) => const documents = treatment.cases.flatMap((c) =>
@@ -491,7 +497,7 @@ export class TreatmentsService {
mimeType: string; mimeType: string;
sizeBytes: number; sizeBytes: number;
}>; }>;
sends?: Array<{ organizationId: string; sentAt: Date }>; sends?: Array<{ organizationId: string; sentAt: Date; organization?: { id: string; name: string } }>;
}) { }) {
return { return {
id: c.id, id: c.id,
@@ -501,6 +507,12 @@ export class TreatmentsService {
notes: c.comment ?? null, notes: c.comment ?? null,
sentAt: c.sentAt?.toISOString() ?? null, sentAt: c.sentAt?.toISOString() ?? null,
sendToOrganizationIds: c.sends?.map((s) => s.organizationId) ?? [], sendToOrganizationIds: c.sends?.map((s) => s.organizationId) ?? [],
sends:
c.sends?.map((s) => ({
organizationId: s.organizationId,
organizationName: s.organization?.name ?? 'Unknown organization',
sentAt: s.sentAt.toISOString(),
})) ?? [],
attachmentMetas: (c.attachments ?? []).map((a) => this.mapAttachment(a)), attachmentMetas: (c.attachments ?? []).map((a) => this.mapAttachment(a)),
}; };
} }

View File

@@ -0,0 +1,40 @@
import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment';
export function formatCaseSentLines(
sends: TreatmentCaseSendInfo[] | undefined,
fallback?: {
organizationIds: string[];
sentAt: string | null;
orgs?: LinkedOrganizationOption[];
},
): string[] {
if (sends?.length) {
return sends.map((s) => {
const at = new Date(s.sentAt).toLocaleString();
return `Sent to ${s.organizationName} at ${at}`;
});
}
if (fallback?.sentAt && fallback.organizationIds.length > 0) {
const at = new Date(fallback.sentAt).toLocaleString();
const nameById = new Map(fallback.orgs?.map((o) => [o.id, o.name]) ?? []);
return fallback.organizationIds.map((id) => {
const name = nameById.get(id) ?? 'organization';
return `Sent to ${name} at ${at}`;
});
}
return [];
}
export function formatCaseSentSummary(
sends: TreatmentCaseSendInfo[] | undefined,
fallback?: {
organizationIds: string[];
sentAt: string | null;
orgs?: LinkedOrganizationOption[];
},
): string | null {
const lines = formatCaseSentLines(sends, fallback);
return lines.length > 0 ? lines.join(' · ') : null;
}

View File

@@ -0,0 +1,72 @@
'use client';
import { useEffect, useState } from 'react';
import { FileText } from 'lucide-react';
import { treatmentsApi } from '@/lib/api/treatments';
import type { TreatmentAttachmentMeta } from '@/types/treatment';
interface CaseLatestAttachmentPreviewProps {
attachment: TreatmentAttachmentMeta;
className?: string;
}
export function CaseLatestAttachmentPreview({
attachment,
className = 'aspect-square w-full max-w-[11rem]',
}: CaseLatestAttachmentPreviewProps) {
const [url, setUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
void (async () => {
try {
const blob = await treatmentsApi.getAttachmentFileBlob(attachment.id);
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
setFailed(false);
} catch {
if (!cancelled) setFailed(true);
}
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachment.id]);
const isImage = attachment.mimeType.startsWith('image/');
const isPdf = attachment.mimeType === 'application/pdf';
return (
<div
className={`${className} rounded-[var(--radius-md)] border border-border/60 bg-background-secondary overflow-hidden`}
title={attachment.fileName}
>
{url && isImage ? (
<img
src={url}
alt={attachment.fileName}
className="h-full w-full object-fill"
/>
) : url && isPdf ? (
<iframe
src={url}
title={attachment.fileName}
className="h-full w-full border-0"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 p-2 text-text-muted">
<FileText className="h-8 w-8 shrink-0 icon-flat" aria-hidden />
<span className="line-clamp-2 text-center text-[10px] leading-tight">
{failed ? 'Preview unavailable' : attachment.fileName}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { formatCaseSentLines } from '@/components/treatment/caseSendLabel';
import type { LinkedOrganizationOption, PastTreatmentCase, TreatmentCaseDraft } from '@/types/treatment';
interface CaseSentLabelProps {
treatmentCase: Pick<
PastTreatmentCase | TreatmentCaseDraft,
'sends' | 'sendToOrganizationIds' | 'sentAt'
>;
orgs?: LinkedOrganizationOption[];
className?: string;
}
export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) {
const lines = formatCaseSentLines(treatmentCase.sends, {
organizationIds: treatmentCase.sendToOrganizationIds ?? [],
sentAt: treatmentCase.sentAt ?? null,
orgs,
});
if (lines.length === 0) return null;
return (
<div className={className}>
{lines.map((line, i) => (
<span key={`${line}-${i}`} className="block">
{line}
</span>
))}
</div>
);
}

View File

@@ -103,7 +103,7 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
}; };
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => ( const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
<div className="flex flex-wrap justify-center gap-x-1 gap-y-1"> <div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
{teeth.map((fdi, i) => { {teeth.map((fdi, i) => {
const kind = getToothShapeKind(fdi); const kind = getToothShapeKind(fdi);
const gid = `${uid}-g-${fdi}-${i}`; const gid = `${uid}-g-${fdi}-${i}`;
@@ -148,61 +148,36 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
); );
return ( return (
<div className="surface-card p-4 space-y-6"> <div className="surface-card p-3 space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3> <h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3>
<p className="text-xs text-text-muted mt-0.5"> <p className="text-[11px] text-text-muted mt-0.5">
Tap teeth to multi-select (FDI). Selection applies to the active treatment case until you save. Tap teeth to multi-select. Applies to the active case.
</p>
</div>
<div className="flex flex-col gap-2 sm:items-end w-full sm:max-w-xs">
<p className="text-xs text-text-secondary tabular-nums text-right">
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
</p> </p>
</div> </div>
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
</p>
</div> </div>
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">Upper arch</p> <p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">Upper arch</p>
<div className="relative isolate py-1"> <div className="overflow-x-auto py-1 -mx-1 px-1">
<div <div className="relative isolate min-w-max mx-auto w-fit">
className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
aria-hidden
/>
<div className="relative z-10 space-y-0">
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
<div className={`flex justify-center gap-x-1 ${TOOTH_NUMBER_GAP}`}>
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`u-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
>
{fdi}
</span>
);
})}
</div>
<div <div
className="my-4 h-px w-full max-w-[min(100%,42rem)] mx-auto bg-border/70" className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
role="separator"
aria-hidden aria-hidden
/> />
<div className="relative z-10 space-y-0">
<div className="my-4 pt-1"> <Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
<div className="flex justify-center gap-x-1"> <div className={`flex flex-nowrap justify-center gap-x-1 ${TOOTH_NUMBER_GAP}`}>
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => { {FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi); const size = toothSizeClass(fdi);
const isSel = selected.has(fdi); const isSel = selected.has(fdi);
return ( return (
<span <span
key={`l-${fdi}`} key={`u-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${ className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted' isSel ? 'text-primary' : 'text-text-muted'
}`} }`}
@@ -212,8 +187,29 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
); );
})} })}
</div> </div>
<div className={TOOTH_NUMBER_GAP}>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} /> <div className="my-2 h-px w-full bg-border/70" role="separator" aria-hidden />
<div className="pt-0.5">
<div className="flex flex-nowrap justify-center gap-x-1">
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`l-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
>
{fdi}
</span>
);
})}
</div>
<div className={TOOTH_NUMBER_GAP}>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,98 +2,117 @@
import { FileText } from 'lucide-react'; import { FileText } from 'lucide-react';
import type { PastTreatment } from '@/types/treatment'; import type { PastTreatment } from '@/types/treatment';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
interface PastTreatmentsPanelProps { interface PastTreatmentsPanelProps {
items: PastTreatment[]; items: PastTreatment[];
loading?: boolean; loading?: boolean;
selectedTreatmentId?: string | null; onReviewTreatment?: (treatment: PastTreatment) => void;
onSelectTreatment?: (treatment: PastTreatment) => void;
} }
export function PastTreatmentsPanel({ export function PastTreatmentsPanel({
items, items,
loading, loading,
selectedTreatmentId, onReviewTreatment,
onSelectTreatment,
}: PastTreatmentsPanelProps) { }: PastTreatmentsPanelProps) {
return ( return (
<div className="surface-card p-4 space-y-3"> <div className="surface-card p-4 space-y-3">
<div> <div>
<h3 className="text-sm font-semibold text-text-primary">Previous treatments</h3> <h3 className="text-sm font-semibold text-text-primary">Previous treatments</h3>
<p className="text-xs text-text-muted mt-0.5"> <p className="text-[11px] text-text-muted mt-0.5">
Document preview is not implemented yet; file names are listed for context. Completed treatments for this patient. Each case is listed separately.
</p> </p>
</div> </div>
{loading && <p className="text-sm text-text-muted">Loading history...</p>} {loading && <p className="text-sm text-text-muted">Loading history</p>}
{!loading && items.length === 0 && ( {!loading && items.length === 0 && (
<p className="text-sm text-text-muted">No prior treatments for this patient.</p> <p className="text-sm text-text-muted">No prior treatments for this patient.</p>
)} )}
<div className="space-y-3 max-h-[min(420px,55vh)] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[min(420px,50vh)] overflow-y-auto pr-1">
{items.map((t) => ( {items.map((t) => (
<article <article
key={t.id} key={t.id}
className={`border rounded-[var(--radius-md)] p-3 ${ className="border border-border/70 rounded-[var(--radius-md)] p-2.5 bg-background-secondary/40 space-y-2"
selectedTreatmentId === t.id
? 'border-primary/70 bg-primary-soft/35'
: 'border-border/70 bg-background-secondary/40'
}`}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{t.title}</p> <div className="min-w-0">
<time className="text-xs text-text-muted tabular-nums shrink-0" dateTime={t.treatmentAt}> <p className="text-sm font-medium text-text-primary truncate">{t.title}</p>
<p className="text-[11px] text-text-secondary capitalize mt-0.5">Status: {t.status}</p>
</div>
<time
className="text-[11px] text-text-muted tabular-nums shrink-0"
dateTime={t.treatmentAt}
>
{new Date(t.treatmentAt).toLocaleDateString()} {new Date(t.treatmentAt).toLocaleDateString()}
</time> </time>
</div> </div>
<p className="text-xs text-text-secondary mt-1">Status: {t.status}</p>
{t.cases.length > 0 && ( <div className="space-y-1.5">
<ul className="mt-2 space-y-1.5 text-xs text-text-secondary"> {t.cases.map((c, idx) => {
{t.cases.map((c) => ( const attachments = c.attachmentMetas ?? [];
<li key={c.id}> return (
<span className="text-text-primary font-medium">Case: </span> <div
<span className="capitalize">{c.treatmentType}</span> key={c.id}
{' | '} className="border border-border/60 rounded-[var(--radius-sm)] px-2.5 py-2 bg-background-secondary/30 space-y-1"
{c.teeth.length > 0 ? `Teeth ${[...c.teeth].sort().join(', ')}` : 'No teeth tagged'} >
{c.notes ? `${c.notes}` : ''} <div className="flex items-center justify-between gap-2">
</li> <p className="text-xs font-medium text-text-primary capitalize">
))} Case {idx + 1} · {c.treatmentType}
</ul> </p>
)} {c.sentAt && (
<CaseSentLabel
treatmentCase={c}
className="text-[10px] text-text-muted shrink-0 text-right"
/>
)}
</div>
<p className="text-[11px] text-text-secondary">
Teeth: {c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
</p>
{c.notes?.trim() && (
<p className="text-[11px] text-text-muted line-clamp-2">{c.notes}</p>
)}
<div>
<p className="text-[10px] uppercase tracking-wide text-text-muted mb-1">
Attachments
</p>
{attachments.length === 0 ? (
<p className="text-[11px] text-text-muted">None</p>
) : (
<ul className="space-y-0.5">
{attachments.map((doc) => (
<li
key={doc.id}
className="flex items-center gap-1.5 text-[11px] text-text-secondary"
>
<FileText className="w-3 h-3 shrink-0 icon-flat" aria-hidden />
<span className="truncate">{doc.fileName}</span>
<span className="text-text-muted tabular-nums shrink-0">
{(doc.sizeBytes / 1024).toFixed(1)} KB
</span>
</li>
))}
</ul>
)}
</div>
</div>
);
})}
</div>
{onSelectTreatment && ( {onReviewTreatment && (
<div className="mt-3 pt-2 border-t border-border/50 flex justify-end"> <div className="pt-1 flex justify-end">
<button <button
type="button" type="button"
onClick={() => onSelectTreatment(t)} onClick={() => onReviewTreatment(t)}
className="text-xs text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 rounded-[var(--radius-sm)] px-1" className="text-xs text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 rounded-[var(--radius-sm)] px-1"
> >
Review details Review details
</button> </button>
</div> </div>
)} )}
{t.documents.length > 0 && (
<div className="mt-3 pt-2 border-t border-border/50">
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1.5">Attachments</p>
<ul className="space-y-1">
{t.documents.map((doc) => (
<li
key={doc.id}
className="flex items-center gap-2 text-xs text-text-secondary"
>
<FileText className="w-3.5 h-3.5 shrink-0 icon-flat" aria-hidden />
<span className="truncate">{doc.fileName}</span>
<span className="text-text-muted tabular-nums shrink-0">
{(doc.sizeBytes / 1024).toFixed(1)} KB
</span>
</li>
))}
</ul>
</div>
)}
</article> </article>
))} ))}
</div> </div>

View File

@@ -0,0 +1,296 @@
'use client';
import { useRef } from 'react';
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 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">Treatment cases</h3>
<p className="text-xs text-text-muted mt-0.5">
Each case has its own teeth, notes, attachments, and destinations for send.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" disabled={!canEdit || disabled} onClick={onPreview}>
Preview
</Button>
<Button type="button" variant="primary" disabled={!canEdit || disabled} onClick={onAddCase}>
Add case
</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,
});
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'
}
`}
>
Case {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">
Comments
<textarea
value={activeCase.comment}
onChange={(e) => {
const v = e.target.value;
onCasesChange(
cases.map((c) => (c.clientId === activeCaseId ? { ...c, comment: v } : c)),
);
}}
placeholder="Write clinical notes for this case…"
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="Treatment type"
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">consultation</option>
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">filling</option>
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">endo</option>
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">visit</option>
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">hygiene</option>
</Dropdown>
</div>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">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="Attach files for this treatment case"
/>
<Button
type="button"
variant="primary"
disabled={disabled || uploadBusy || Boolean(activeCase.sentAt)}
isLoading={uploadBusy}
onClick={() => attachmentInputRef.current?.click()}
aria-controls="treatment-case-attachments"
>
Choose files
</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">
Send this case to linked organizations
</p>
<div className="space-y-2 mb-2">
<SearchBar
value={organizationSearch}
onChange={onOrganizationSearchChange}
placeholder="Search active organizations..."
/>
{recentOrganizations.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted">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">No active organization matches your search.</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)}
>
Send this case
</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}
>
Save treatment draft
</Button>
<p className="text-xs text-text-muted self-center">
{isDirty ? 'Unsaved changes' : 'Draft saved'}. Sending is per case and saves first automatically.
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { useEffect, useState } from 'react';
import { FileText } from 'lucide-react';
import { treatmentsApi } from '@/lib/api/treatments';
import type { TreatmentAttachmentMeta } from '@/types/treatment';
interface TreatmentLatestAttachmentPreviewProps {
attachment?: TreatmentAttachmentMeta | null;
className?: string;
}
function isImageMime(mimeType: string): boolean {
return mimeType.startsWith('image/');
}
function isPdfMime(mimeType: string): boolean {
return mimeType === 'application/pdf';
}
export function TreatmentLatestAttachmentPreview({
attachment,
className = '',
}: TreatmentLatestAttachmentPreviewProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [loading, setLoading] = useState(false);
const canRenderPreview = attachment
? isImageMime(attachment.mimeType) || isPdfMime(attachment.mimeType)
: false;
useEffect(() => {
if (!attachment || !canRenderPreview) {
setPreviewUrl(null);
setLoadFailed(false);
setLoading(false);
return;
}
let cancelled = false;
let objectUrl: string | null = null;
setLoading(true);
setLoadFailed(false);
setPreviewUrl(null);
void treatmentsApi
.getAttachmentFileBlob(attachment.id)
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setPreviewUrl(objectUrl);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachment, canRenderPreview]);
return (
<div
className={`aspect-square w-[6rem] shrink-0 overflow-hidden rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/50 ${className}`}
title={attachment?.fileName}
>
{!attachment ? (
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
None
</div>
) : loading ? (
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
</div>
) : loadFailed || !canRenderPreview || !previewUrl ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-1.5 text-center">
<FileText className="h-4 w-4 shrink-0 icon-flat text-text-muted" aria-hidden />
<span className="line-clamp-2 text-[9px] leading-tight text-text-secondary">
{attachment.fileName}
</span>
</div>
) : isImageMime(attachment.mimeType) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={attachment.fileName}
className="h-full w-full object-fill"
/>
) : (
<iframe
src={previewUrl}
title={attachment.fileName}
className="h-full w-full border-0"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { Button } from '@/components/ui/shared/Button';
import type { PastTreatment } from '@/types/treatment';
interface TreatmentPreviewCardProps {
draft: PastTreatment | null;
disabled?: boolean;
onPreview: () => void;
}
export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPreviewCardProps) {
return (
<div className="surface-card p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-primary">Treatment preview</h3>
<Button type="button" variant="primary" disabled={disabled || !draft} onClick={onPreview}>
Preview current draft
</Button>
</div>
{!draft ? (
<p className="text-sm text-text-muted">Select an appointment to preview its draft.</p>
) : (
<div className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{draft.title}</p>
<span className="text-xs text-text-muted tabular-nums shrink-0 capitalize">{draft.status}</span>
</div>
<p className="text-xs text-text-secondary">
{draft.cases.length} case{draft.cases.length === 1 ? '' : 's'} ·{' '}
{draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0)} attachment
{draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0) === 1 ? '' : 's'}
</p>
<div className="space-y-2">
{draft.cases.slice(0, 2).map((c, idx) => (
<div
key={c.id}
className="rounded-[var(--radius-sm)] border border-border/60 px-2.5 py-2 text-xs text-text-secondary"
>
<span className="text-text-primary font-medium capitalize">
Case {idx + 1}: {c.treatmentType}
</span>
{c.teeth.length > 0 && (
<span className="ml-1 tabular-nums">· Teeth {[...c.teeth].sort().join(', ')}</span>
)}
</div>
))}
{draft.cases.length > 2 && (
<p className="text-xs text-text-muted">+ {draft.cases.length - 2} more case(s)</p>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,235 @@
'use client';
import { useRef, useState } from 'react';
import { Loader2, Paperclip, Send } 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';
export type TreatmentPreviewMode = 'readonly' | 'editable';
interface TreatmentPreviewDialogProps {
open: boolean;
onClose: () => void;
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 {
return c.clientId ?? c.id;
}
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';
export function TreatmentPreviewDialog({
open,
onClose,
treatment,
mode,
orgs = [],
sendBusyCaseId,
uploadBusyCaseId,
onAttach,
onSend,
getCaseOrgIds,
onToggleCaseOrg,
}: TreatmentPreviewDialogProps) {
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">
<div
className="w-full max-w-[min(56rem,calc(100vw-17rem))] max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
role="dialog"
aria-modal="true"
aria-labelledby="treatment-preview-title"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 id="treatment-preview-title" className="text-lg font-semibold text-text-primary pr-2">
Treatment preview
</h2>
<p className="text-xs text-text-muted mt-0.5">
Review cases, attachments, and send destinations.
</p>
</div>
<DialogCloseButton onClick={onClose} />
</div>
<div className="border border-border/70 rounded-[var(--radius-md)] p-4 bg-background-secondary/40 space-y-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{treatment.title}</p>
<span className="text-xs text-text-muted tabular-nums shrink-0">
{new Date(treatment.treatmentAt).toLocaleDateString()}
</span>
</div>
<p className="text-xs text-text-secondary capitalize">Status: {treatment.status}</p>
{treatment.cases.length === 0 ? (
<p className="text-sm text-text-muted">No cases in this treatment.</p>
) : (
<div className="space-y-2">
{treatment.cases.map((c, idx) => {
const key = caseKey(c);
const attachments = c.attachmentMetas ?? [];
const latestAttachment =
attachments.length > 0 ? attachments[attachments.length - 1] : null;
const sent = Boolean(c.sentAt);
const actionsEnabled = editable && !sent;
const selectedOrgIds = getCaseOrgIds?.(key) ?? c.sendToOrganizationIds ?? [];
const sendExpanded = expandedSendCaseId === key;
const comment = c.notes?.trim() ?? '';
const attachBusy = uploadBusyCaseId === key;
const sendBusy = sendBusyCaseId === key;
return (
<div
key={key}
className="rounded-[var(--radius-md)] border border-border/60 px-3 py-2 bg-background-secondary/30"
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1">
<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">Case {idx + 1}</p>
{actionsEnabled && (
<div className="flex items-center gap-0.5">
<input
ref={(el) => {
fileInputsRef.current[key] = el;
}}
type="file"
multiple
className="sr-only"
aria-hidden
onChange={(e) => {
if (e.target.files?.length) {
void onAttach?.(key, e.target.files);
}
e.target.value = '';
}}
/>
<button
type="button"
className={caseActionIconClass}
disabled={attachBusy}
aria-label="Attach files"
title="Attach files"
onClick={() => fileInputsRef.current[key]?.click()}
>
{attachBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<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="Send this case"
title="Send this case"
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>
<p className="text-[11px] text-text-secondary capitalize">
Type: {c.treatmentType}
</p>
<p className="text-[11px] text-text-secondary">
Teeth:{' '}
{c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
</p>
{comment ? (
<p className="text-[11px] text-text-muted line-clamp-2" title={comment}>
Comments: {comment}
</p>
) : (
<p className="text-[11px] text-text-muted">Comments: </p>
)}
</div>
<div className="flex min-w-[6rem] flex-col items-end gap-1">
{sent && (
<CaseSentLabel
treatmentCase={c}
orgs={orgs}
className="text-[10px] text-text-muted text-right"
/>
)}
<p className="text-[10px] uppercase tracking-wide text-text-muted">
Attachments
</p>
<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">
Send to linked organizations
</p>
{activeOrgs.length === 0 ? (
<p className="text-xs text-text-muted">No active linked organizations.</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)}
>
Confirm send
</Button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -4,11 +4,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip'; import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { Checkbox } from '@/components/ui/shared/Checkbox'; import { TreatmentCasesEditor } from '@/components/ui/treatment/TreatmentCasesEditor';
import { Button } from '@/components/ui/shared/Button'; import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import { Dropdown } from '@/components/ui/shared/Dropdown'; import {
import { SearchBar } from '@/components/ui/shared/SearchBar'; TreatmentPreviewDialog,
import { Toast } from '@/components/ui/shared/Toast'; type TreatmentPreviewMode,
} from '@/components/ui/treatment/TreatmentPreviewDialog';
import { ToastStack } from '@/components/ui/shared/Toast';
import { import {
addCalendarDays, addCalendarDays,
compareLocalDayStart, compareLocalDayStart,
@@ -20,6 +22,7 @@ import { treatmentsApi } from '@/lib/api/treatments';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions'; import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions';
import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { Organization } from '@/types/organization'; import type { Organization } from '@/types/organization';
import type { AppointmentRecord } from '@/types/appointment'; import type { AppointmentRecord } from '@/types/appointment';
import type { import type {
@@ -28,7 +31,6 @@ import type {
PastTreatment, PastTreatment,
PastTreatmentCase, PastTreatmentCase,
TreatmentAppointment, TreatmentAppointment,
TreatmentAttachmentMeta,
TreatmentCaseDraft, TreatmentCaseDraft,
} from '@/types/treatment'; } from '@/types/treatment';
@@ -69,6 +71,7 @@ function mapCaseFromApi(c: PastTreatmentCase): TreatmentCaseDraft {
comment: c.notes ?? '', comment: c.notes ?? '',
attachmentMetas: c.attachmentMetas ?? [], attachmentMetas: c.attachmentMetas ?? [],
sendToOrganizationIds: c.sendToOrganizationIds ?? [], sendToOrganizationIds: c.sendToOrganizationIds ?? [],
sends: c.sends ?? [],
sentAt: c.sentAt ?? null, sentAt: c.sentAt ?? null,
}; };
} }
@@ -88,17 +91,41 @@ function serializeCases(cases: TreatmentCaseDraft[]) {
); );
} }
function casesToPreviewTreatment(
cases: TreatmentCaseDraft[],
meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string },
): PastTreatment {
return {
id: meta.id ?? 'current-draft',
patientId: meta.patientId,
title: meta.title,
treatmentAt: meta.treatmentAt,
status: meta.status,
cases: 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,
sendToOrganizationIds: c.sendToOrganizationIds,
sentAt: c.sentAt ?? null,
})),
documents: [],
};
}
interface TreatmentWorkspaceProps { interface TreatmentWorkspaceProps {
userId: string; userId: string;
currentOrganization: Organization | null; currentOrganization: Organization | null;
} }
export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) { export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) {
const { showError, showSuccess, messages: toastMessages } = useToast();
const canView = canViewTreatment(currentOrganization); const canView = canViewTreatment(currentOrganization);
const canEdit = canEditTreatment(currentOrganization); const canEdit = canEditTreatment(currentOrganization);
const [stripHidden, setStripHidden] = useState(false); const [stripHidden, setStripHidden] = useState(false);
const todayStart = useMemo(() => startOfLocalDay(new Date()), []); const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date())); const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
@@ -119,16 +146,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const selectionLockedRef = useRef(selectionLocked); const selectionLockedRef = useRef(selectionLocked);
selectionLockedRef.current = selectionLocked; selectionLockedRef.current = selectionLocked;
const attachmentInputRef = useRef<HTMLInputElement>(null);
const [saveBusy, setSaveBusy] = useState(false); const [saveBusy, setSaveBusy] = useState(false);
const [sendBusyId, setSendBusyId] = useState<string | null>(null); const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [uploadBusy, setUploadBusy] = useState(false); const [uploadBusyCaseId, setUploadBusyCaseId] = useState<string | null>(null);
const [banner, setBanner] = useState<string | null>(null);
const [errorBanner, setErrorBanner] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState(''); const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]); const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [reviewTreatment, setReviewTreatment] = useState<PastTreatment | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewTreatment, setPreviewTreatment] = useState<PastTreatment | null>(null);
const [previewMode, setPreviewMode] = useState<TreatmentPreviewMode>('readonly');
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (savedSnapshot === null) { if (savedSnapshot === null) {
@@ -154,69 +180,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[cases, activeCaseId], [cases, activeCaseId],
); );
const selectedTeethSet = useMemo(() => new Set(activeCase.teeth), [activeCase.teeth]); const selectedTeethSet = useMemo(() => new Set(activeCase?.teeth ?? []), [activeCase?.teeth]);
const activeLinkedOrganizations = useMemo(() => orgs.filter((o) => o.active), [orgs]);
const filteredOrganizations = useMemo(() => {
const q = organizationSearch.trim().toLowerCase();
if (!q) return activeLinkedOrganizations;
return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
}, [organizationSearch, activeLinkedOrganizations]);
const recentOrganizations = useMemo(() => {
if (recentOrganizationIds.length === 0) return [];
const recentSet = new Set(recentOrganizationIds);
return activeLinkedOrganizations
.filter((o) => recentSet.has(o.id))
.sort((a, b) => recentOrganizationIds.indexOf(a.id) - recentOrganizationIds.indexOf(b.id))
.slice(0, 3);
}, [recentOrganizationIds, activeLinkedOrganizations]);
const currentDraftPreview = useMemo<PastTreatment | null>(() => { const currentDraftPreview = useMemo<PastTreatment | null>(() => {
if (!selectedAppointment) return null; if (!selectedAppointment) return null;
return { return casesToPreviewTreatment(cases, {
id: 'current-draft', title: `Draft · ${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
patientId: selectedAppointment.patientId, patientId: selectedAppointment.patientId,
title: `Current draft for ${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
treatmentAt: new Date().toISOString(), treatmentAt: new Date().toISOString(),
status: 'draft', status: 'draft',
cases: 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,
})),
documents: cases.flatMap((c) => c.attachmentMetas),
};
}, [cases, selectedAppointment]); }, [cases, selectedAppointment]);
const treatmentTypeTextColor = useMemo(() => {
const map: Record<TreatmentCaseDraft['treatmentType'], string> = {
consultation: '#ddd6fe',
filling: '#fed7aa',
endo: '#fecaca',
visit: '#bae6fd',
hygiene: '#d9f99d',
};
return map[activeCase.treatmentType];
}, [activeCase.treatmentType]);
const loadDraftForAppointment = useCallback(async (appointmentId: string) => {
const response = await treatmentsApi.getDraft(appointmentId);
if (response.data?.cases?.length) {
const mapped = response.data.cases.map(mapCaseFromApi);
setCases(mapped);
setActiveCaseId(mapped[0].clientId);
setSavedSnapshot(serializeCases(mapped));
} else {
const first = newCase();
setCases([first]);
setActiveCaseId(first.clientId);
setSavedSnapshot(serializeCases([first]));
}
setOrganizationSearch('');
setReviewTreatment(null);
}, []);
useEffect(() => { useEffect(() => {
setSelectionLocked(false); setSelectionLocked(false);
}, [selectedDay]); }, [selectedDay]);
@@ -242,7 +217,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
} }
} catch (error: unknown) { } catch (error: unknown) {
if (!cancelled) { if (!cancelled) {
setErrorBanner(formatApiErrorMessage(error, 'Failed to load appointments.')); showError(formatApiErrorMessage(error, 'Failed to load appointments.'));
} }
} finally { } finally {
if (!cancelled) setApptsLoading(false); if (!cancelled) setApptsLoading(false);
@@ -251,7 +226,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [userId, selectedDay]); }, [userId, selectedDay, showError]);
useEffect(() => { useEffect(() => {
const today = startOfLocalDay(new Date()); const today = startOfLocalDay(new Date());
@@ -275,14 +250,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (!cancelled) setOrgs(list.data); if (!cancelled) setOrgs(list.data);
} catch (error: unknown) { } catch (error: unknown) {
if (!cancelled) { if (!cancelled) {
setErrorBanner(formatApiErrorMessage(error, 'Failed to load linked organizations.')); showError(formatApiErrorMessage(error, 'Failed to load linked organizations.'));
} }
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [showError]);
useEffect(() => { useEffect(() => {
if (!selectedAppointment) { if (!selectedAppointment) {
@@ -294,12 +269,10 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
void (async () => { void (async () => {
try { try {
const response = await treatmentsApi.listPatientHistory(selectedAppointment.patientId); const response = await treatmentsApi.listPatientHistory(selectedAppointment.patientId);
if (!cancelled) { if (!cancelled) setHistory(response.data);
setHistory(response.data);
}
} catch (error: unknown) { } catch (error: unknown) {
if (!cancelled) { if (!cancelled) {
setErrorBanner(formatApiErrorMessage(error, 'Failed to load treatment history.')); showError(formatApiErrorMessage(error, 'Failed to load treatment history.'));
} }
} finally { } finally {
if (!cancelled) setHistoryLoading(false); if (!cancelled) setHistoryLoading(false);
@@ -308,45 +281,43 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [selectedAppointment?.patientId]); }, [selectedAppointment?.patientId, showError]);
useEffect(() => { useEffect(() => {
if (!selectedAppointment?.id) return; const appointmentId = selectedAppointment?.id;
if (!appointmentId) return;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
try { try {
await loadDraftForAppointment(selectedAppointment.id); const response = await treatmentsApi.getDraft(appointmentId);
if (cancelled) return;
if (response.data?.cases?.length) {
const mapped = response.data.cases.map(mapCaseFromApi);
setCases(mapped);
setActiveCaseId((prev) => {
const stillExists = mapped.some((c) => c.clientId === prev);
return stillExists ? prev : mapped[0].clientId;
});
setSavedSnapshot(serializeCases(mapped));
} else {
const first = newCase();
setCases([first]);
setActiveCaseId(first.clientId);
setSavedSnapshot(serializeCases([first]));
}
setOrganizationSearch('');
} catch (error: unknown) { } catch (error: unknown) {
if (!cancelled) { if (!cancelled) {
setErrorBanner(formatApiErrorMessage(error, 'Failed to load treatment draft.')); showError(formatApiErrorMessage(error, 'Failed to load treatment draft.'));
} }
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [selectedAppointment?.id, loadDraftForAppointment]); }, [selectedAppointment?.id, showError]);
const fixActiveAfterCasesChange = useCallback((next: TreatmentCaseDraft[]) => {
setCases(next);
setActiveCaseId((id) => (next.some((c) => c.clientId === id) ? id : next[0].clientId));
}, []);
const toggleTooth = useCallback(
(fdi: FdiToothId) => {
if (!canEditTreatmentForDay) return;
setCases((prev) =>
prev.map((c) => {
if (c.clientId !== activeCaseId) return c;
const set = new Set(c.teeth);
if (set.has(fdi)) set.delete(fdi);
else set.add(fdi);
return { ...c, teeth: [...set].sort() as FdiToothId[] };
}),
);
},
[activeCaseId, canEditTreatmentForDay],
);
const confirmDiscardIfDirty = useCallback(() => { const confirmDiscardIfDirty = useCallback(() => {
if (!isDirty) return true; if (!isDirty) return true;
@@ -370,36 +341,41 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[confirmDiscardIfDirty], [confirmDiscardIfDirty],
); );
const addAttachments = useCallback( const uploadForCase = useCallback(
async (files: FileList | null) => { async (caseClientId: string, files: FileList | File[]) => {
if (!files?.length || !canEditTreatmentForDay || !selectedAppointment) return; if (!canEditTreatmentForDay || !selectedAppointment) return;
setUploadBusy(true); const list = files instanceof FileList ? Array.from(files) : files;
setErrorBanner(null); if (!list.length) return;
setUploadBusyCaseId(caseClientId);
try { try {
const uploaded = await treatmentsApi.uploadCaseAttachments( const uploaded = await treatmentsApi.uploadCaseAttachments(
selectedAppointment.id, selectedAppointment.id,
activeCaseId, caseClientId,
Array.from(files), list,
); );
setCases((prev) => setCases((prev) =>
prev.map((c) => { prev.map((c) =>
if (c.clientId !== activeCaseId) return c; c.clientId === caseClientId
return { ...c, attachmentMetas: [...c.attachmentMetas, ...uploaded.data] }; ? { ...c, attachmentMetas: [...c.attachmentMetas, ...uploaded.data] }
}), : c,
),
);
showSuccess(
`${uploaded.data.length} file${uploaded.data.length === 1 ? '' : 's'} uploaded successfully.`,
); );
} catch (error: unknown) { } catch (error: unknown) {
setErrorBanner(formatApiErrorMessage(error, 'Failed to upload attachments.')); showError(formatApiErrorMessage(error, 'Failed to upload attachments.'));
} finally { } finally {
setUploadBusy(false); setUploadBusyCaseId(null);
} }
}, },
[activeCaseId, canEditTreatmentForDay, selectedAppointment], [canEditTreatmentForDay, selectedAppointment, showSuccess, showError],
); );
const persistDraft = useCallback(async () => { const persistDraft = useCallback(async () => {
if (!selectedAppointment) { if (!selectedAppointment) throw new Error('No appointment selected');
throw new Error('No appointment selected');
}
const response = await treatmentsApi.saveDraft(selectedAppointment.id, { const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
cases: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ cases: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
clientId, clientId,
@@ -423,17 +399,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const handleSaveAll = useCallback(async () => { const handleSaveAll = useCallback(async () => {
if (!canEditTreatmentForDay || !selectedAppointment) return; if (!canEditTreatmentForDay || !selectedAppointment) return;
setSaveBusy(true); setSaveBusy(true);
setBanner(null);
setErrorBanner(null);
try { try {
await persistDraft(); await persistDraft();
setBanner('Treatment draft saved. You can send cases later.'); showSuccess('Treatment draft saved.');
} catch (error: unknown) { } catch (error: unknown) {
setErrorBanner(formatApiErrorMessage(error, 'Failed to save treatment draft.')); showError(formatApiErrorMessage(error, 'Failed to save treatment draft.'));
} finally { } finally {
setSaveBusy(false); setSaveBusy(false);
} }
}, [canEditTreatmentForDay, selectedAppointment, persistDraft]); }, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError]);
const handleSendCase = useCallback( const handleSendCase = useCallback(
async (treatmentCase: TreatmentCaseDraft) => { async (treatmentCase: TreatmentCaseDraft) => {
@@ -442,18 +416,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
orgs.some((o) => o.id === id && o.active), orgs.some((o) => o.id === id && o.active),
); );
if (targets.length === 0) { if (targets.length === 0) {
setErrorBanner('Choose at least one active organization to send this case.'); showError('Choose at least one active organization to send this case.');
return; return;
} }
setSendBusyId(treatmentCase.clientId); setSendBusyId(treatmentCase.clientId);
setBanner(null);
setErrorBanner(null);
try { try {
const saved = await persistDraft(); const saved = await persistDraft();
const serverCase = saved.cases.find((c) => c.clientId === treatmentCase.clientId); const serverCase = saved.cases.find((c) => c.clientId === treatmentCase.clientId);
if (!serverCase?.id) { if (!serverCase?.id) throw new Error('Case must be saved before sending.');
throw new Error('Case must be saved before sending.');
}
const response = await treatmentsApi.sendCase(serverCase.id, { organizationIds: targets }); const response = await treatmentsApi.sendCase(serverCase.id, { organizationIds: targets });
setCases((prev) => { setCases((prev) => {
const next = prev.map((c) => const next = prev.map((c) =>
@@ -463,6 +434,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
id: response.data.id, id: response.data.id,
sentAt: response.data.sentAt, sentAt: response.data.sentAt,
sendToOrganizationIds: response.data.sendToOrganizationIds, sendToOrganizationIds: response.data.sendToOrganizationIds,
sends: response.data.sends,
} }
: c, : c,
); );
@@ -470,19 +442,50 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
return next; return next;
}); });
setRecentOrganizationIds((prev) => { setRecentOrganizationIds((prev) => {
const next = [...targets.filter((id) => id && !prev.includes(id)), ...prev]; const next = [...targets.filter((id) => !prev.includes(id)), ...prev];
return next.slice(0, 10); return next.slice(0, 10);
}); });
setBanner('Case sent to selected organizations.'); showSuccess('Case sent to selected organizations.');
} catch (error: unknown) { } catch (error: unknown) {
setErrorBanner(formatApiErrorMessage(error, 'Failed to send case.')); showError(formatApiErrorMessage(error, 'Failed to send case.'));
} finally { } finally {
setSendBusyId(null); setSendBusyId(null);
} }
}, },
[canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, cases], [canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, showSuccess, showError],
); );
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) { if (!canView) {
return ( return (
<div className="surface-card p-6 max-w-xl"> <div className="surface-card p-6 max-w-xl">
@@ -495,7 +498,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
} }
return ( return (
<div className="relative space-y-6 pb-24"> <div className="space-y-4">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-2xl font-semibold text-text-primary">Treatment</h1> <h1 className="text-2xl font-semibold text-text-primary">Treatment</h1>
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
@@ -505,6 +508,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
</p> </p>
</header> </header>
<ToastStack {...toastMessages} />
<AppointmentsStrip <AppointmentsStrip
stripHidden={stripHidden} stripHidden={stripHidden}
onToggleStripHidden={() => setStripHidden((s) => !s)} onToggleStripHidden={() => setStripHidden((s) => !s)}
@@ -523,325 +528,112 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
</p> </p>
)} )}
<div className="grid grid-cols-1 xl:grid-cols-[minmax(280px,380px)_minmax(0,1fr)] gap-6 items-start"> <div className="grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
<div className="space-y-4"> <div className="space-y-3 min-w-0 xl:max-w-[380px]">
{selectedAppointment ? ( {selectedAppointment ? (
<div className="surface-card p-4 space-y-1"> <div className="surface-card p-3 space-y-0.5">
<p className="text-xs uppercase tracking-wide text-text-muted">Selected patient</p> <p className="text-[10px] uppercase tracking-wide text-text-muted">Selected patient</p>
<p className="text-lg font-semibold text-text-primary"> <p className="text-base font-semibold text-text-primary">
{selectedAppointment.patientFirstName} {selectedAppointment.patientLastName} {selectedAppointment.patientFirstName} {selectedAppointment.patientLastName}
</p> </p>
<p className="text-xs text-text-secondary"> <p className="text-[11px] text-text-secondary">
Appointment purpose:{' '} Purpose:{' '}
<span className="capitalize text-text-primary">{selectedAppointment.purpose}</span> <span className="capitalize text-text-primary">{selectedAppointment.purpose}</span>
</p> </p>
</div> </div>
) : ( ) : (
<div className="surface-card p-4 text-sm text-text-muted"> <div className="surface-card p-3 text-sm text-text-muted">
{apptsLoading ? 'Loading appointments…' : 'Select a day with at least one appointment.'} {apptsLoading ? 'Loading appointments…' : 'Select a day with at least one appointment.'}
</div> </div>
)} )}
<TreatmentPreviewCard
draft={currentDraftPreview}
disabled={!selectedAppointment}
onPreview={openCurrentDraftPreview}
/>
<PastTreatmentsPanel <PastTreatmentsPanel
items={history} items={history}
loading={historyLoading} loading={historyLoading}
selectedTreatmentId={reviewTreatment?.id ?? null} onReviewTreatment={(t) => openPreview(t, 'readonly')}
onSelectTreatment={setReviewTreatment}
/> />
<div className="surface-card p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-primary">Treatment review</h3>
<Button
type="button"
variant="primary"
disabled={!selectedAppointment}
onClick={() => setReviewTreatment(currentDraftPreview)}
>
Preview current treatment
</Button>
</div>
{!reviewTreatment && (
<p className="text-sm text-text-muted">
Select a treatment from history, or preview the current draft.
</p>
)}
{reviewTreatment && (
<div className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{reviewTreatment.title}</p>
<span className="text-xs text-text-muted tabular-nums">
{new Date(reviewTreatment.treatmentAt).toLocaleDateString()}
</span>
</div>
<p className="text-xs text-text-secondary capitalize">Status: {reviewTreatment.status}</p>
<div className="space-y-2">
{reviewTreatment.cases.map((c, idx) => (
<div key={c.id} className="rounded-[var(--radius-sm)] border border-border/60 px-2.5 py-2">
<p className="text-xs text-text-primary font-medium">Case {idx + 1}</p>
<p className="text-xs text-text-secondary capitalize mt-1">
Type: {c.treatmentType}
</p>
<p className="text-xs text-text-secondary">
Teeth: {c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
</p>
{c.notes && <p className="text-xs text-text-muted mt-1">Notes: {c.notes}</p>}
</div>
))}
</div>
<p className="text-xs text-text-muted">
Attachments: {reviewTreatment.documents.length}
</p>
</div>
)}
</div>
</div> </div>
<div className="space-y-4 min-w-0"> <div className="space-y-3 min-w-0 w-full">
<FdiToothChart <FdiToothChart
selected={selectedTeethSet} selected={selectedTeethSet}
onToggle={toggleTooth} onToggle={(fdi) => {
if (!canEditTreatmentForDay) return;
setCases((prev) =>
prev.map((c) => {
if (c.clientId !== activeCaseId) return c;
const set = new Set(c.teeth);
if (set.has(fdi)) set.delete(fdi);
else set.add(fdi);
return { ...c, teeth: [...set].sort() as FdiToothId[] };
}),
);
}}
disabled={!canEditTreatmentForDay} disabled={!canEditTreatmentForDay}
/> />
<div className="surface-card p-4 space-y-4"> <TreatmentCasesEditor
<div className="flex flex-wrap items-center justify-between gap-3"> cases={cases}
<div> activeCaseId={activeCaseId}
<h3 className="text-sm font-semibold text-text-primary">Treatment cases</h3> onActiveCaseChange={setActiveCaseId}
<p className="text-xs text-text-muted mt-0.5"> onCasesChange={setCases}
Each case has its own teeth, notes, attachments, and destinations for send. disabled={!canEditTreatmentForDay}
</p> canEdit={canEdit}
</div> isDirty={isDirty}
<Button saveBusy={saveBusy}
type="button" sendBusyId={sendBusyId}
variant="primary" uploadBusy={uploadBusyCaseId === activeCaseId}
disabled={!canEditTreatmentForDay} orgs={orgs}
onClick={() => { organizationSearch={organizationSearch}
const nextCase = newCase(); onOrganizationSearchChange={setOrganizationSearch}
fixActiveAfterCasesChange([...cases, nextCase]); recentOrganizationIds={recentOrganizationIds}
setActiveCaseId(nextCase.clientId); onRecentOrganizationPick={(orgId) => {
}} setCases((prev) =>
> prev.map((c) => {
Add case if (c.clientId !== activeCaseId || c.sentAt) return c;
</Button> if (c.sendToOrganizationIds.includes(orgId)) return c;
</div> return { ...c, sendToOrganizationIds: [...c.sendToOrganizationIds, orgId] };
}),
<div className="flex flex-wrap gap-2"> );
{cases.map((c, idx) => ( }}
<button onAddCase={() => {
key={c.clientId} const nextCase = newCase();
type="button" setCases((prev) => [...prev, nextCase]);
onClick={() => setActiveCaseId(c.clientId)} setActiveCaseId(nextCase.clientId);
className={` }}
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors onPreview={openCurrentDraftPreview}
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 onSave={() => void handleSaveAll()}
${ onSendCase={(c) => void handleSendCase(c)}
c.clientId === activeCaseId onUploadFiles={(files) => void uploadForCase(activeCaseId, files ?? [])}
? 'border-primary bg-primary-soft font-medium text-text-primary' />
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
Case {idx + 1}
{c.sentAt ? ' · sent' : ''}
</button>
))}
</div>
{activeCase && (
<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">
Comments
<textarea
value={activeCase.comment}
onChange={(e) => {
const v = e.target.value;
setCases((prev) =>
prev.map((c) => (c.clientId === activeCaseId ? { ...c, comment: v } : c)),
);
}}
placeholder="Write clinical notes for this case…"
rows={5}
disabled={!canEditTreatmentForDay || 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="Treatment type"
value={activeCase.treatmentType}
onChange={(e) => {
const nextType = e.target.value as TreatmentCaseDraft['treatmentType'];
setCases((prev) =>
prev.map((c) =>
c.clientId === activeCaseId ? { ...c, treatmentType: nextType } : c,
),
);
}}
disabled={!canEditTreatmentForDay || Boolean(activeCase.sentAt)}
className="capitalize"
style={{ color: treatmentTypeTextColor }}
>
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">consultation</option>
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">filling</option>
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">endo</option>
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">visit</option>
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">hygiene</option>
</Dropdown>
</div>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">Attachments</p>
<input
ref={attachmentInputRef}
id="treatment-case-attachments"
type="file"
multiple
disabled={!canEditTreatmentForDay || uploadBusy || Boolean(activeCase.sentAt)}
onChange={(e) => {
void addAttachments(e.target.files);
e.target.value = '';
}}
className="sr-only"
aria-label="Attach files for this treatment case"
/>
<Button
type="button"
variant="primary"
disabled={!canEditTreatmentForDay || uploadBusy || Boolean(activeCase.sentAt)}
isLoading={uploadBusy}
onClick={() => attachmentInputRef.current?.click()}
aria-controls="treatment-case-attachments"
>
Choose files
</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">
Send this case to linked organizations
</p>
<div className="space-y-2 mb-2">
<SearchBar
value={organizationSearch}
onChange={setOrganizationSearch}
placeholder="Search active organizations..."
/>
{recentOrganizations.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted">Recent:</span>
{recentOrganizations.map((o) => (
<button
key={o.id}
type="button"
disabled={!canEditTreatmentForDay || Boolean(activeCase.sentAt)}
onClick={() => {
setCases((prev) =>
prev.map((c) => {
if (c.clientId !== activeCaseId || c.sentAt) return c;
if (c.sendToOrganizationIds.includes(o.id)) return c;
return { ...c, sendToOrganizationIds: [...c.sendToOrganizationIds, 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={!canEditTreatmentForDay || Boolean(activeCase.sentAt)}
onChange={(checked) => {
setCases((prev) =>
prev.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">No active organization matches your search.</p>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="primary"
disabled={
!canEditTreatmentForDay ||
Boolean(activeCase.sentAt) ||
sendBusyId === activeCase.clientId
}
isLoading={sendBusyId === activeCase.clientId}
onClick={() => void handleSendCase(activeCase)}
>
Send this case
</Button>
{activeCase.sentAt && (
<span className="text-xs text-text-muted">
Sent at {new Date(activeCase.sentAt).toLocaleString()}
</span>
)}
</div>
</div>
)}
{canEdit && (
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
<Button
type="button"
variant="primary"
disabled={!canEditTreatmentForDay || saveBusy}
isLoading={saveBusy}
onClick={() => void handleSaveAll()}
>
Save treatment draft
</Button>
<p className="text-xs text-text-muted self-center">
{isDirty ? 'Unsaved changes' : 'Draft saved'}. Sending is per case and saves first automatically.
</p>
</div>
)}
</div>
</div> </div>
</div> </div>
{(banner || errorBanner) && ( <TreatmentPreviewDialog
<div className="fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none"> open={previewOpen}
<div className="pointer-events-auto w-full"> onClose={() => setPreviewOpen(false)}
{banner && <Toast variant="success">{banner}</Toast>} treatment={
{errorBanner && ( previewMode === 'editable' && currentDraftPreview ? currentDraftPreview : previewTreatment
<div className={banner ? 'mt-2' : ''}> }
<Toast variant="danger">{errorBanner}</Toast> mode={previewMode}
</div> orgs={orgs}
)} sendBusyCaseId={sendBusyId}
</div> uploadBusyCaseId={uploadBusyCaseId}
</div> onAttach={(caseKey, files) => uploadForCase(caseKey, files)}
)} onSend={(caseKey, organizationIds) => {
const c = cases.find((item) => item.clientId === caseKey);
if (!c) return;
void handleSendCase({ ...c, sendToOrganizationIds: organizationIds });
}}
getCaseOrgIds={getCaseOrgIds}
onToggleCaseOrg={toggleCaseOrg}
/>
</div> </div>
); );
} }

View File

@@ -5,6 +5,7 @@ import type {
SaveTreatmentPayload, SaveTreatmentPayload,
SendTreatmentCasePayload, SendTreatmentCasePayload,
TreatmentAttachmentMeta, TreatmentAttachmentMeta,
TreatmentCaseSendInfo,
} from '@/types/treatment'; } from '@/types/treatment';
export const treatmentsApi = { export const treatmentsApi = {
@@ -62,6 +63,14 @@ export const treatmentsApi = {
const response = await apiClient.post(`/treatments/cases/${caseId}/send`, payload); const response = await apiClient.post(`/treatments/cases/${caseId}/send`, payload);
return response.data; return response.data;
}, },
getAttachmentFileBlob: async (attachmentId: string): Promise<Blob> => {
const response = await apiClient.get(`/treatments/attachments/${attachmentId}/file`, {
responseType: 'blob',
timeout: 120_000,
});
return response.data;
},
}; };
export interface PastTreatmentCaseResponse { export interface PastTreatmentCaseResponse {
@@ -72,5 +81,6 @@ export interface PastTreatmentCaseResponse {
notes: string | null; notes: string | null;
sentAt: string | null; sentAt: string | null;
sendToOrganizationIds: string[]; sendToOrganizationIds: string[];
sends: TreatmentCaseSendInfo[];
attachmentMetas: TreatmentAttachmentMeta[]; attachmentMetas: TreatmentAttachmentMeta[];
} }

View File

@@ -61,6 +61,12 @@ export const TREATMENT_TYPES = [
export type TreatmentType = (typeof TREATMENT_TYPES)[number]; export type TreatmentType = (typeof TREATMENT_TYPES)[number];
export interface TreatmentCaseSendInfo {
organizationId: string;
organizationName: string;
sentAt: string;
}
export interface PastTreatmentCase { export interface PastTreatmentCase {
id: string; id: string;
clientId: string; clientId: string;
@@ -69,6 +75,7 @@ export interface PastTreatmentCase {
notes?: string | null; notes?: string | null;
attachmentMetas?: TreatmentAttachmentMeta[]; attachmentMetas?: TreatmentAttachmentMeta[];
sendToOrganizationIds?: string[]; sendToOrganizationIds?: string[];
sends?: TreatmentCaseSendInfo[];
sentAt?: string | null; sentAt?: string | null;
} }
@@ -97,6 +104,7 @@ export interface TreatmentCaseDraft {
comment: string; comment: string;
attachmentMetas: TreatmentAttachmentMeta[]; attachmentMetas: TreatmentAttachmentMeta[];
sendToOrganizationIds: string[]; sendToOrganizationIds: string[];
sends?: TreatmentCaseSendInfo[];
sentAt?: string | null; sentAt?: string | null;
} }