TreatmentType and ProsthesisType database shcema and data updated. Lab dispatch wired through new data.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user