TreatmentType and ProsthesisType database shcema and data updated. Lab dispatch wired through new data.

This commit is contained in:
2026-07-06 20:40:19 +03:30
parent 3e81c110a3
commit 667b08ed0c
48 changed files with 1813 additions and 275 deletions

View File

@@ -330,7 +330,7 @@
"treatmentDetails": "Treatment details",
"teethLabel": "Teeth",
"tasksByTooth": "Tasks by tooth",
"toothGroupTitle": "Tooth {tooth} · {type}",
"toothGroupTitle": "Tooth {tooth} · {prosthesis} · {type}",
"noTasks": "No tasks were generated for this case.",
"unassigned": "Unassigned",
"statusPending": "Pending",
@@ -497,8 +497,14 @@
"labShipmentNoIncludedDetails": "No details were included in this shipment.",
"labShipmentNoDetailsAvailable": "All lab details are already in other shipments or have been sent.",
"labDetailLine": "Detail {n} · {type} · {teeth}",
"noLabDetails": "No lab-dependent treatment details yet. Add a lab type (e.g. endo) in treatment details above.",
"noLabDetails": "No prosthesis treatment details yet. Add prosthesis in treatment details above.",
"labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.",
"prosthesisTypesTitle": "Prosthesis types",
"prosthesisApplyAll": "Apply to all teeth",
"prosthesisSelectPlaceholder": "Select prosthesis type…",
"prosthesisColTooth": "Tooth",
"prosthesisColDetail": "Detail",
"prosthesisColType": "Prosthesis type",
"labComment": "Message for the lab",
"labCommentPlaceholder": "Optional instructions for this shipment…",
"selectLab": "Destination lab",

View File

@@ -330,7 +330,7 @@
"treatmentDetails": "جزئیات درمان",
"teethLabel": "دندان‌ها",
"tasksByTooth": "وظایف به تفکیک دندان",
"toothGroupTitle": "دندان {tooth} · {type}",
"toothGroupTitle": "دندان {tooth} · {prosthesis} · {type}",
"noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.",
"unassigned": "بدون مسئول",
"statusPending": "در انتظار",
@@ -497,8 +497,14 @@
"labShipmentNoIncludedDetails": "جزئیاتی در این محموله گنجانده نشده است.",
"labShipmentNoDetailsAvailable": "همه جزئیات لاب در محموله‌های دیگر هستند یا ارسال شده‌اند.",
"labDetailLine": "جزئیات {n} · {type} · {teeth}",
"noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.",
"noLabDetails": "هنوز جزئیات پروتز وجود ندارد. پروتز را در جزئیات درمان بالا اضافه کنید.",
"labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.",
"prosthesisTypesTitle": "انواع پروتز",
"prosthesisApplyAll": "اعمال برای همه دندان‌ها",
"prosthesisSelectPlaceholder": "نوع پروتز را انتخاب کنید…",
"prosthesisColTooth": "دندان",
"prosthesisColDetail": "جزئیات",
"prosthesisColType": "نوع پروتز",
"labComment": "پیام برای لابراتوار",
"labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…",
"selectLab": "لابراتوار مقصد",

View File

@@ -330,7 +330,7 @@
"treatmentDetails": "Behandeldetails",
"teethLabel": "Tanden",
"tasksByTooth": "Taken per tand",
"toothGroupTitle": "Tand {tooth} · {type}",
"toothGroupTitle": "Tand {tooth} · {prosthesis} · {type}",
"noTasks": "Er zijn geen taken gegenereerd voor dit dossier.",
"unassigned": "Niet toegewezen",
"statusPending": "In afwachting",
@@ -497,8 +497,14 @@
"labShipmentNoIncludedDetails": "Geen details opgenomen in deze zending.",
"labShipmentNoDetailsAvailable": "Alle labdetails zitten al in andere zendingen of zijn verzonden.",
"labDetailLine": "Detail {n} · {type} · {teeth}",
"noLabDetails": "Nog geen lab-afhankelijke details. Voeg een labtype (bijv. endo) toe in de behandeldetails hierboven.",
"noLabDetails": "Nog geen prothese-details. Voeg prothese toe in de behandeldetails hierboven.",
"labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.",
"prosthesisTypesTitle": "Prothesetypes",
"prosthesisApplyAll": "Toepassen op alle tanden",
"prosthesisSelectPlaceholder": "Selecteer prothesetype…",
"prosthesisColTooth": "Tand",
"prosthesisColDetail": "Detail",
"prosthesisColType": "Prothesetype",
"labComment": "Bericht voor het lab",
"labCommentPlaceholder": "Optionele instructies voor deze zending…",
"selectLab": "Bestemmingslab",

View File

@@ -10,9 +10,12 @@ import { useToast } from '@/lib/hooks/useToast';
import { canEditCases } from '@/components/shared/permissions';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { casesApi } from '@/lib/api/cases';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type {
AssignableMember,
CasesFilterOptions,
@@ -22,14 +25,6 @@ import type {
PaginatedLabCases,
} from '@/types/cases';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
const PAGE_SIZE = 20;
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
@@ -101,6 +96,7 @@ export default function CasesPage() {
clinics: [],
treatmentTypes: [],
});
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
@@ -113,11 +109,8 @@ export default function CasesPage() {
const locale = user?.language ?? 'en';
const treatmentLabel = useCallback(
(type: string) => {
const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS];
return key ? tTreatment(key) : type;
},
[tTreatment],
(type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
[treatmentCatalog],
);
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
@@ -179,6 +172,7 @@ export default function CasesPage() {
useEffect(() => {
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
}, []);
@@ -490,6 +484,7 @@ export default function CasesPage() {
<div className="text-sm font-medium text-text-primary">
{t('toothGroupTitle', {
tooth: group.tooth,
prosthesis: group.prosthesisTypeLabel,
type: treatmentLabel(group.treatmentType),
})}
</div>

View File

@@ -12,7 +12,10 @@ import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 50;
@@ -46,6 +49,7 @@ export default function TasksPage() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const canView = canViewTasks(currentOrganization);
const canEdit = canEditTasks(currentOrganization);
@@ -64,6 +68,10 @@ export default function TasksPage() {
[t],
);
useEffect(() => {
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
}, []);
useEffect(() => {
if (!canView) return;
@@ -166,6 +174,7 @@ export default function TasksPage() {
<p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} ·{' '}
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
{task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}
</p>
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
@@ -213,7 +222,10 @@ export default function TasksPage() {
<Badge variant="default" fixedWidth={false}>
{t('priorityLabel', { n: task.priority })}
</Badge>
<TreatmentTypeBadge type={task.treatmentType} />
<TreatmentTypeBadge
type={task.treatmentType}
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
/>
</div>
</li>
);

View File

@@ -6,20 +6,15 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { organizationApi } from '@/lib/api/organization';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 20;
@@ -78,7 +73,6 @@ export function ConnectionCaseHistoryContent({
}: ConnectionCaseHistoryContentProps) {
const t = useTranslations('organizations');
const tCases = useTranslations('cases');
const tTreatment = useTranslations('treatment');
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
const { showError, setError, messages: toastMessages } = useToast();
@@ -94,6 +88,7 @@ export function ConnectionCaseHistoryContent({
});
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
@@ -104,13 +99,14 @@ export function ConnectionCaseHistoryContent({
tRef.current = t;
const treatmentLabel = useCallback(
(type: string) => {
const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS];
return key ? tTreatment(key) : type;
},
[tTreatment],
(type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
[treatmentCatalog],
);
useEffect(() => {
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
}, []);
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'PENDING', label: tCases('statusPending') },
@@ -375,6 +371,7 @@ export function ConnectionCaseHistoryContent({
<div className="text-sm font-medium text-text-primary">
{tCases('toothGroupTitle', {
tooth: group.tooth,
prosthesis: group.prosthesisTypeLabel,
type: treatmentLabel(group.treatmentType),
})}
</div>

View File

@@ -6,16 +6,10 @@ import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeSty
import { Card } from '@/components/ui/shared/Card';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TreatmentAppointment } from '@/types/treatment';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
interface AppointmentsStripProps {
stripHidden: boolean;
onToggleStripHidden: () => void;
@@ -24,6 +18,7 @@ interface AppointmentsStripProps {
appointments: TreatmentAppointment[];
selectedAppointmentId: string | null;
onSelectAppointment: (id: string) => void;
treatmentCatalog: TreatmentCatalogEntry[];
loading?: boolean;
}
@@ -35,6 +30,7 @@ export function AppointmentsStrip({
appointments,
selectedAppointmentId,
onSelectAppointment,
treatmentCatalog,
loading = false,
}: AppointmentsStripProps) {
const t = useTranslations('treatment');
@@ -96,7 +92,7 @@ export function AppointmentsStrip({
minute: '2-digit',
})}`;
const palette = purposeStyle(a.purpose);
const purposeKey = TREATMENT_TYPE_KEYS[a.purpose as keyof typeof TREATMENT_TYPE_KEYS];
const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
return (
<Card
as="button"
@@ -115,9 +111,7 @@ export function AppointmentsStrip({
<p className="text-sm font-medium leading-tight truncate mt-0.5">
{a.patientFirstName} {a.patientLastName}
</p>
<p className="text-[11px] opacity-90 capitalize mt-0.5">
{purposeKey ? t(purposeKey) : a.purpose}
</p>
<p className="text-[11px] opacity-90 mt-0.5 truncate">{purposeLabel}</p>
</Card>
);
})}

View File

@@ -1,20 +1,24 @@
'use client';
import { useMemo } from 'react';
import { useEffect, useMemo, useState } 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 { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
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 { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[];
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
activeLabCaseId: string | null;
onActiveLabCaseChange: (id: string) => void;
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
@@ -52,7 +56,6 @@ function detailInOtherDraftShipment(
);
}
/** Lab-dependent details not yet sent to any lab. */
function unsentLabDetails(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
@@ -62,7 +65,6 @@ function unsentLabDetails(
return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId));
}
/** Unsent lab details not already assigned to another draft shipment. */
function detailsAvailableForNewShipment(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
@@ -73,7 +75,6 @@ function detailsAvailableForNewShipment(
);
}
/** Details the user can pick for the active draft shipment. */
function selectableDetailsForDraftShipment(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
@@ -89,10 +90,40 @@ function selectableDetailsForDraftShipment(
});
}
function prosthesisTeethRows(
labCase: LabCaseDraft,
details: TreatmentDetailDraft[],
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
for (const clientId of labCase.detailClientIds) {
const detail = details.find((d) => d.clientId === clientId);
if (!detail || detail.treatmentType !== 'prosthesis') continue;
const detailNumber = details.findIndex((d) => d.clientId === clientId) + 1;
for (const tooth of detail.teeth) {
rows.push({ detailClientId: clientId, tooth, detailNumber });
}
}
return rows;
}
function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetailDraft[]): boolean {
const rows = prosthesisTeethRows(labCase, details);
if (rows.length === 0) return true;
return rows.every((row) =>
labCase.toothProsthesis.some(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.tooth === row.tooth &&
Boolean(tp.prosthesisTypeCode),
),
);
}
export function LabCasesDispatchPanel({
details,
labCases,
labDependentCodes,
treatmentCatalog,
activeLabCaseId,
onActiveLabCaseChange,
onLabCasesChange,
@@ -108,6 +139,9 @@ export function LabCasesDispatchPanel({
onSendLabCase,
}: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment');
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const activeLinkedOrganizations = orgs.filter((o) => o.active);
const filteredOrganizations = (() => {
const q = organizationSearch.trim().toLowerCase();
@@ -136,17 +170,39 @@ export function LabCasesDispatchPanel({
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
: null;
const prosthesisRows = activeLabCase ? prosthesisTeethRows(activeLabCase, details) : [];
const prosthesisComplete = activeLabCase
? isProsthesisMapComplete(activeLabCase, details)
: true;
useEffect(() => {
if (!activeLabCase?.destinationOrganizationId) {
setProsthesisOptions([]);
return;
}
let cancelled = false;
void prosthesisCatalogApi
.list(activeLabCase.destinationOrganizationId)
.then((res) => {
if (!cancelled) setProsthesisOptions(res.data);
})
.catch(() => {
if (!cancelled) setProsthesisOptions([]);
});
return () => {
cancelled = true;
};
}, [activeLabCase?.destinationOrganizationId]);
function detailNumber(d: TreatmentDetailDraft) {
const idx = details.findIndex((row) => row.clientId === d.clientId);
return idx >= 0 ? idx + 1 : 0;
}
function detailSummary(d: TreatmentDetailDraft) {
const typeKey = treatmentTypeLabelKey(d.treatmentType);
const typeLabel =
d.treatmentType in TREATMENT_TYPE_KEYS
? t(typeKey as 'typeEndo')
: d.treatmentType;
const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog);
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`;
}
@@ -158,6 +214,31 @@ export function LabCasesDispatchPanel({
);
}
function setToothProsthesis(
detailClientId: string,
tooth: string,
prosthesisTypeCode: string,
) {
if (!activeLabCase) return;
const rest = activeLabCase.toothProsthesis.filter(
(tp) => !(tp.detailClientId === detailClientId && tp.tooth === tooth),
);
const next = prosthesisTypeCode
? [...rest, { detailClientId, tooth, prosthesisTypeCode }]
: rest;
updateActiveLabCase({ toothProsthesis: next });
}
function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return;
const next = prosthesisRows.map((row) => ({
detailClientId: row.detailClientId,
tooth: row.tooth,
prosthesisTypeCode: code,
}));
updateActiveLabCase({ toothProsthesis: next });
}
function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) {
if (!activeLabCase || sent) return;
@@ -169,7 +250,12 @@ export function LabCasesDispatchPanel({
const set = new Set(lc.detailClientIds);
if (checked) set.add(detailClientId);
else set.delete(detailClientId);
return { ...lc, detailClientIds: [...set] };
const keptProsthesis = lc.toothProsthesis.filter((tp) =>
[...set].includes(tp.detailClientId),
);
return { ...lc, detailClientIds: [...set], toothProsthesis: keptProsthesis };
}
if (checked) {
@@ -375,11 +461,14 @@ export function LabCasesDispatchPanel({
)}
<Dropdown
value={activeLabCase.destinationOrganizationId ?? ''}
onChange={(e) =>
onChange={(e) => {
const nextOrgId = e.target.value || null;
updateActiveLabCase({
destinationOrganizationId: e.target.value || null,
})
}
destinationOrganizationId: nextOrgId,
toothProsthesis: [],
});
setApplyAllProsthesis('');
}}
disabled={disabled || filteredOrganizations.length === 0}
>
<option value="">{t('selectLabPlaceholder')}</option>
@@ -394,6 +483,84 @@ export function LabCasesDispatchPanel({
)}
</div>
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (
<div className="space-y-3 border-t border-border/60 pt-3">
<p className="text-xs font-medium text-text-secondary">
{t('prosthesisTypesTitle')}
</p>
<label className="block text-xs text-text-muted space-y-1">
{t('prosthesisApplyAll')}
<select
value={applyAllProsthesis}
disabled={disabled || prosthesisOptions.length === 0}
onChange={(e) => {
const code = e.target.value;
setApplyAllProsthesis(code);
if (code) applyProsthesisToAll(code);
}}
className={`${FORM_SELECT_CLASS} w-full mt-1`}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</label>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted">
<th className="pb-2 pr-3 font-medium">{t('prosthesisColTooth')}</th>
<th className="pb-2 pr-3 font-medium">{t('prosthesisColDetail')}</th>
<th className="pb-2 font-medium">{t('prosthesisColType')}</th>
</tr>
</thead>
<tbody>
{prosthesisRows.map((row) => {
const current =
activeLabCase.toothProsthesis.find(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.tooth === row.tooth,
)?.prosthesisTypeCode ?? '';
return (
<tr key={`${row.detailClientId}-${row.tooth}`} className="border-t border-border/40">
<td className="py-2 pr-3 text-text-primary">{row.tooth}</td>
<td className="py-2 pr-3 text-text-secondary">
{t('detailLabel', { n: row.detailNumber })}
</td>
<td className="py-2">
<select
value={current}
disabled={disabled}
onChange={(e) =>
setToothProsthesis(
row.detailClientId,
row.tooth,
e.target.value,
)
}
className={`${FORM_SELECT_CLASS} w-full min-w-[160px]`}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
@@ -402,7 +569,8 @@ export function LabCasesDispatchPanel({
disabled ||
sendBusyId === activeLabCase.clientId ||
!activeLabCase.destinationOrganizationId ||
activeLabCase.detailClientIds.length === 0
activeLabCase.detailClientIds.length === 0 ||
!prosthesisComplete
}
isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase)}

View File

@@ -3,9 +3,11 @@
import { useTranslations } from 'next-intl';
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
import type { PastTreatment } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface PastTreatmentsPanelProps {
items: PastTreatment[];
treatmentCatalog: TreatmentCatalogEntry[];
loading?: boolean;
selectedPreviewId?: string | null;
onSelectTreatment?: (treatment: PastTreatment) => void;
@@ -13,6 +15,7 @@ interface PastTreatmentsPanelProps {
export function PastTreatmentsPanel({
items,
treatmentCatalog,
loading,
selectedPreviewId,
onSelectTreatment,
@@ -78,6 +81,7 @@ export function PastTreatmentsPanel({
<TreatmentHistoryDetailLine
detail={detail}
detailNumber={idx + 1}
treatmentCatalog={treatmentCatalog}
/>
</div>
))}

View File

@@ -3,12 +3,15 @@
import { useTranslations } from 'next-intl';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
interface TreatmentDetailSummaryRowProps {
detail: PastTreatmentDetail;
detailNumber: number;
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
orgs?: LinkedOrganizationOption[];
compact?: boolean;
}
@@ -17,6 +20,7 @@ export function TreatmentDetailSummaryRow({
detail,
detailNumber,
labDependentCodes,
treatmentCatalog,
orgs,
compact = false,
}: TreatmentDetailSummaryRowProps) {
@@ -35,7 +39,10 @@ export function TreatmentDetailSummaryRow({
<span className={`text-text-muted tabular-nums ${compact ? 'text-[11px]' : 'text-xs'}`}>
{t('detailLabel', { n: detailNumber })}
</span>
<TreatmentTypeBadge type={detail.treatmentType} />
<TreatmentTypeBadge
type={detail.treatmentType}
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
</div>
<DetailLabSendBadge detail={detail} labDependentCodes={labDependentCodes} orgs={orgs} />
</div>

View File

@@ -10,7 +10,8 @@ import {
labSentBannerClass,
} from '@/components/ui/treatment/treatmentStatusStyles';
import type { TreatmentDetailDraft } from '@/types/treatment';
import { TREATMENT_TYPE_COLORS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
interface TreatmentDetailsEditorProps {
details: TreatmentDetailDraft[];
@@ -19,6 +20,7 @@ interface TreatmentDetailsEditorProps {
onDetailsChange: (details: TreatmentDetailDraft[]) => void;
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
disabled: boolean;
canEdit: boolean;
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
@@ -34,6 +36,7 @@ export function TreatmentDetailsEditor({
onDetailsChange,
isDetailLocked,
labDependentCodes,
treatmentCatalog,
disabled,
canEdit,
saveStatus,
@@ -49,7 +52,10 @@ export function TreatmentDetailsEditor({
const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked;
const treatmentTypeTextColor = TREATMENT_TYPE_COLORS[activeDetail.treatmentType];
const treatmentTypeTextColor = treatmentTypeColor(
activeDetail.treatmentType,
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
);
const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
const showPendingLabHint = isLabDependent && !locked && !readOnly;
@@ -125,14 +131,20 @@ export function TreatmentDetailsEditor({
);
}}
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>
{treatmentCatalog.map((entry, index) => (
<option
key={entry.code}
value={entry.code}
style={{
color: treatmentTypeColor(entry.code, index),
backgroundColor: '#14253d',
}}
>
{entry.label}
</option>
))}
</Dropdown>
</div>

View File

@@ -2,16 +2,20 @@
import { useTranslations } from 'next-intl';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { PastTreatmentDetail } from '@/types/treatment';
interface TreatmentHistoryDetailLineProps {
detail: PastTreatmentDetail;
detailNumber: number;
treatmentCatalog: TreatmentCatalogEntry[];
}
export function TreatmentHistoryDetailLine({
detail,
detailNumber,
treatmentCatalog,
}: TreatmentHistoryDetailLineProps) {
const t = useTranslations('treatment');
const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone');
@@ -20,7 +24,10 @@ export function TreatmentHistoryDetailLine({
return (
<div className="flex items-center gap-2 min-w-0 text-[11px] leading-tight">
<span className="text-text-muted tabular-nums shrink-0">{detailNumber}.</span>
<TreatmentTypeBadge type={detail.treatmentType} />
<TreatmentTypeBadge
type={detail.treatmentType}
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
<span className="text-text-secondary truncate min-w-0">{teeth}</span>
{attachmentCount > 0 && (
<span className="text-text-muted shrink-0 tabular-nums">

View File

@@ -4,10 +4,12 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface TreatmentPreviewCardProps {
treatment: PastTreatment | null;
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
orgs?: LinkedOrganizationOption[];
openDisabled?: boolean;
onOpen: () => void;
@@ -16,6 +18,7 @@ interface TreatmentPreviewCardProps {
export function TreatmentPreviewCard({
treatment,
labDependentCodes,
treatmentCatalog,
orgs,
openDisabled = false,
onOpen,
@@ -53,6 +56,7 @@ export function TreatmentPreviewCard({
detail={detail}
detailNumber={idx + 1}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
orgs={orgs}
compact
/>

View File

@@ -1,25 +1,22 @@
'use client';
import { useTranslations } from 'next-intl';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
import { formatCodeAsLabel } from '@/components/ui/treatment/treatmentTypeDisplay';
interface TreatmentTypeBadgeProps {
type: string;
label?: string;
className?: string;
}
export function TreatmentTypeBadge({ type, className = '' }: TreatmentTypeBadgeProps) {
const t = useTranslations('treatment');
const typeKey = treatmentTypeLabelKey(type);
const label =
type in TREATMENT_TYPE_KEYS ? t(typeKey as 'typeEndo') : type;
export function TreatmentTypeBadge({ type, label, className = '' }: TreatmentTypeBadgeProps) {
const display = label ?? formatCodeAsLabel(type);
return (
<span
className={`inline-flex items-center justify-center box-border rounded-md border min-h-[1.75rem] px-2.5 py-1 text-xs font-medium capitalize leading-none shrink-0 ${purposeStyle(type)} ${className}`.trim()}
className={`inline-flex items-center justify-center box-border rounded-md border min-h-[1.75rem] px-2.5 py-1 text-xs font-medium leading-none shrink-0 ${purposeStyle(type)} ${className}`.trim()}
>
{label}
{display}
</span>
);
}

View File

@@ -9,7 +9,7 @@ import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPan
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import { ToastStack } from '@/components/ui/shared/Toast';
import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import {
addCalendarDays,
compareLocalDayStart,
@@ -25,6 +25,7 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { Organization } from '@/types/organization';
import type { AppointmentRecord } from '@/types/appointment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type {
FdiToothId,
LabCaseDraft,
@@ -93,7 +94,7 @@ function newDetail(): TreatmentDetailDraft {
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: 'consultation',
treatmentType: 'restoration',
teeth: [],
comment: '',
attachmentMetas: [],
@@ -111,6 +112,7 @@ function newLabCaseDraft(): LabCaseDraft {
destinationOrganizationId: null,
labComment: '',
detailClientIds: [],
toothProsthesis: [],
sentAt: null,
sends: [],
};
@@ -145,12 +147,20 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
}
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
const detailClientById = new Map(lc.details.map((d) => [d.id, d.clientId]));
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId,
labComment: lc.labComment ?? '',
detailClientIds: lc.details.map((d) => d.clientId),
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
detailClientId:
detailClientById.get(tp.treatmentDetailId) ?? tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
sentAt: lc.sentAt ?? null,
sends: lc.sends ?? [],
};
@@ -231,6 +241,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
@@ -414,6 +425,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
]);
if (cancelled) return;
setOrgs(orgsResponse.data);
setTreatmentCatalog(catalogResponse.data);
setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
);
@@ -765,6 +777,17 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
treatmentDetailIds: lc.detailClientIds
.map((clientId) => detailIdByClientId.get(clientId))
.filter((id): id is string => Boolean(id)),
toothProsthesis: lc.toothProsthesis
.map((tp) => {
const detailId = detailIdByClientId.get(tp.detailClientId);
if (!detailId) return null;
return {
treatmentDetailId: detailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
};
})
.filter((row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } => row !== null),
}));
if (payload.length === 0) {
@@ -881,6 +904,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
appointments={appointments}
selectedAppointmentId={selectedAppointmentId}
onSelectAppointment={onPickAppointment}
treatmentCatalog={treatmentCatalog}
loading={apptsLoading}
/>
@@ -906,10 +930,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
</p>
<p className="text-[11px] text-text-secondary">
{t('purposeLabel')}{' '}
<span className="capitalize text-text-primary">
{t(
treatmentTypeLabelKey(selectedAppointment.purpose) as 'typeConsultation',
)}
<span className="text-text-primary">
{treatmentTypeLabelFromCatalog(selectedAppointment.purpose, treatmentCatalog)}
</span>
</p>
</div>
@@ -922,6 +944,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<TreatmentPreviewCard
treatment={previewTreatment}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
orgs={orgs}
openDisabled={isPreviewAlreadyOpen}
onOpen={handleOpenTreatment}
@@ -929,6 +952,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<PastTreatmentsPanel
items={historyPanelItems}
treatmentCatalog={treatmentCatalog}
loading={historyLoading}
selectedPreviewId={selectedPreviewId}
onSelectTreatment={handleSelectPreviewTreatment}
@@ -960,6 +984,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
onDetailsChange={setDetails}
isDetailLocked={isDetailLocked}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
saveStatus={saveStatus}
@@ -976,6 +1001,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
details={details}
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
activeLabCaseId={activeLabCaseId}
onActiveLabCaseChange={setActiveLabCaseId}
onLabCasesChange={setLabCaseDrafts}

View File

@@ -1,19 +1,41 @@
export const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
export const TREATMENT_TYPE_COLORS: Record<string, string> = {
consultation: '#ddd6fe',
filling: '#fed7aa',
const TREATMENT_TYPE_COLORS: Record<string, string> = {
restoration: '#fed7aa',
specialized_restoration: '#fdba74',
radiography: '#e2e8f0',
endo: '#fecaca',
visit: '#bae6fd',
hygiene: '#d9f99d',
surgery: '#fca5a5',
prosthesis: '#c4b5fd',
implant: '#a5b4fc',
orthodontics: '#93c5fd',
perio: '#86efac',
pediatrics: '#fde68a',
extraction: '#f87171',
clinic_visit: '#bae6fd',
};
export function treatmentTypeLabelKey(code: string): string {
return TREATMENT_TYPE_KEYS[code as keyof typeof TREATMENT_TYPE_KEYS] ?? code;
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d'];
export function treatmentTypeColor(code: string, index = 0): string {
return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
}
export function treatmentTypeLabelFromCatalog(
code: string,
catalog: TreatmentCatalogEntry[],
): string {
return catalog.find((e) => e.code === code)?.label ?? formatCodeAsLabel(code);
}
export function formatCodeAsLabel(code: string): string {
return code
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
/** @deprecated Use treatmentTypeLabelFromCatalog with API catalog */
export function treatmentTypeLabelKey(code: string): string {
return `type_${code}`;
}

View File

@@ -0,0 +1,13 @@
import { apiClient } from './client';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
export const prosthesisCatalogApi = {
list: async (
labOrganizationId?: string,
): Promise<{ success: boolean; data: ProsthesisCatalogEntry[] }> => {
const response = await apiClient.get('/prosthesis-catalog', {
params: labOrganizationId ? { labOrganizationId } : undefined,
});
return response.data;
},
};

View File

@@ -18,6 +18,9 @@ export interface LabCaseTask {
id: string;
tooth: string;
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
workflowStepCode?: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
@@ -31,6 +34,8 @@ export interface LabCaseTask {
export interface LabCaseTasksByTooth {
tooth: string;
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
tasks: LabCaseTask[];
}
@@ -100,6 +105,8 @@ export interface LabTaskListItem {
labCaseId: string;
tooth: string;
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;

View File

@@ -3,4 +3,11 @@ export interface TreatmentCatalogEntry {
code: string;
labDependent: boolean;
sortOrder: number;
label: string;
}
export interface ProsthesisCatalogEntry {
code: string;
sortOrder: number;
label: string;
}

View File

@@ -51,15 +51,14 @@ export interface TreatmentAttachmentMeta {
sizeBytes: number;
}
export const TREATMENT_TYPES = [
'consultation',
'filling',
'endo',
'visit',
'hygiene',
] as const;
export type TreatmentType = string;
export type TreatmentType = (typeof TREATMENT_TYPES)[number];
export interface LabCaseToothProsthesisDraft {
/** Treatment detail client id in the UI; mapped to UUID when saving. */
detailClientId: string;
tooth: string;
prosthesisTypeCode: string;
}
export interface LabCaseSendInfo {
organizationId: string;
@@ -99,6 +98,11 @@ export interface PastLabCase {
treatmentType: string;
teeth: FdiToothId[];
}>;
toothProsthesis?: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
}>;
sends?: LabCaseSendInfo[];
}
@@ -141,6 +145,7 @@ export interface LabCaseDraft {
destinationOrganizationId: string | null;
labComment: string;
detailClientIds: string[];
toothProsthesis: LabCaseToothProsthesisDraft[];
sentAt?: string | null;
sends?: LabCaseSendInfo[];
}
@@ -163,6 +168,11 @@ export interface SaveLabCasePayload {
destinationOrganizationId?: string;
labComment?: string;
treatmentDetailIds: string[];
toothProsthesis?: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
}>;
}
export interface SaveTreatmentPayload {
@@ -185,4 +195,5 @@ export interface LabCaseResponse {
teeth: string[];
}>;
sends: LabCaseSendInfo[];
toothProsthesis?: LabCaseToothProsthesisDraft[];
}