feature: localization's first implmentation done. all frontend hardcoded text is now localized.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CalendarDays } from 'lucide-react';
|
||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
@@ -7,6 +8,14 @@ import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
||||
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
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;
|
||||
@@ -28,16 +37,18 @@ export function AppointmentsStrip({
|
||||
onSelectAppointment,
|
||||
loading = false,
|
||||
}: AppointmentsStripProps) {
|
||||
const t = useTranslations('treatment');
|
||||
|
||||
if (stripHidden) {
|
||||
return (
|
||||
<Card className="flex items-center justify-between gap-3 flex-wrap" padding="sm">
|
||||
<p className="text-sm text-text-secondary">Appointments are hidden.</p>
|
||||
<p className="text-sm text-text-secondary">{t('hiddenMessage')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleStripHidden}
|
||||
className="text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded-[var(--radius-sm)]"
|
||||
>
|
||||
Show appointments
|
||||
{t('showAppointments')}
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
@@ -48,14 +59,14 @@ export function AppointmentsStrip({
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CalendarDays className="w-5 h-5 text-text-muted shrink-0 icon-flat" aria-hidden />
|
||||
<h2 className="text-sm font-semibold text-text-primary truncate">My appointments</h2>
|
||||
<h2 className="text-sm font-semibold text-text-primary truncate">{t('appointmentsTitle')}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleStripHidden}
|
||||
className="text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded-[var(--radius-sm)]"
|
||||
>
|
||||
Hide appointments
|
||||
{t('hideAppointments')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -65,13 +76,13 @@ export function AppointmentsStrip({
|
||||
onChange={(d) => onSelectDay(startOfLocalDay(d))}
|
||||
/>
|
||||
{loading && (
|
||||
<p className="text-sm text-text-muted pb-2">Loading appointments…</p>
|
||||
<p className="text-sm text-text-muted pb-2">{t('loadingAppointments')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{appointments.length === 0 && (
|
||||
<p className="text-sm text-text-muted">No appointments assigned to you on this day.</p>
|
||||
<p className="text-sm text-text-muted">{t('emptyDay')}</p>
|
||||
)}
|
||||
{appointments.map((a) => {
|
||||
const sel = a.id === selectedAppointmentId;
|
||||
@@ -85,6 +96,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];
|
||||
return (
|
||||
<Card
|
||||
as="button"
|
||||
@@ -103,7 +115,9 @@ 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">{a.purpose}</p>
|
||||
<p className="text-[11px] opacity-90 capitalize mt-0.5">
|
||||
{purposeKey ? t(purposeKey) : a.purpose}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { formatCaseSentLines } from '@/components/treatment/caseSendLabel';
|
||||
import type { LinkedOrganizationOption, PastTreatmentCase, TreatmentCaseDraft } from '@/types/treatment';
|
||||
|
||||
@@ -11,11 +14,12 @@ interface CaseSentLabelProps {
|
||||
}
|
||||
|
||||
export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const lines = formatCaseSentLines(treatmentCase.sends, {
|
||||
organizationIds: treatmentCase.sendToOrganizationIds ?? [],
|
||||
sentAt: treatmentCase.sentAt ?? null,
|
||||
orgs,
|
||||
});
|
||||
}, t);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useId } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
|
||||
import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
@@ -25,6 +26,7 @@ interface FdiToothChartProps {
|
||||
}
|
||||
|
||||
export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const archPeak = 10;
|
||||
|
||||
@@ -127,7 +129,11 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
|
||||
`}
|
||||
aria-pressed={isSel}
|
||||
aria-label={`FDI tooth ${fdi}${isSel ? ', selected' : ''}`}
|
||||
aria-label={
|
||||
isSel
|
||||
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
|
||||
: t('toothAria', { fdi })
|
||||
}
|
||||
>
|
||||
<ToothGlyph
|
||||
fdi={fdi}
|
||||
@@ -151,17 +157,17 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
<div className="surface-card p-3 space-y-3">
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('toothChartTitle')}</h3>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">
|
||||
Tap teeth to multi-select. Applies to the active case.
|
||||
{t('toothChartHint')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
|
||||
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
|
||||
{t('selectedLabel')} {selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
|
||||
</p>
|
||||
</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">{t('upperArch')}</p>
|
||||
|
||||
<div className="overflow-x-auto py-1 -mx-1 px-1">
|
||||
<div className="relative isolate min-w-max mx-auto w-fit">
|
||||
@@ -215,7 +221,7 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">Lower arch</p>
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">{t('lowerArch')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { PastTreatment } from '@/types/treatment';
|
||||
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
interface PastTreatmentsPanelProps {
|
||||
items: PastTreatment[];
|
||||
loading?: boolean;
|
||||
@@ -15,43 +24,50 @@ export function PastTreatmentsPanel({
|
||||
loading,
|
||||
onReviewTreatment,
|
||||
}: PastTreatmentsPanelProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tCommon = useTranslations('common');
|
||||
|
||||
return (
|
||||
<div className="surface-card p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">Previous treatments</h3>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('historyTitle')}</h3>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">
|
||||
Completed treatments for this patient. Each case is listed separately.
|
||||
{t('historySubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-text-muted">Loading history…</p>}
|
||||
{loading && <p className="text-sm text-text-muted">{t('loadingHistory')}</p>}
|
||||
|
||||
{!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">{t('historyEmpty')}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 max-h-[min(420px,50vh)] overflow-y-auto pr-1">
|
||||
{items.map((t) => (
|
||||
{items.map((treatment) => (
|
||||
<article
|
||||
key={t.id}
|
||||
key={treatment.id}
|
||||
className="border border-border/70 rounded-[var(--radius-md)] p-2.5 bg-background-secondary/40 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<p className="text-sm font-medium text-text-primary truncate">{treatment.title}</p>
|
||||
<p className="text-[11px] text-text-secondary capitalize mt-0.5">
|
||||
{t('statusLabel')} {treatment.status}
|
||||
</p>
|
||||
</div>
|
||||
<time
|
||||
className="text-[11px] text-text-muted tabular-nums shrink-0"
|
||||
dateTime={t.treatmentAt}
|
||||
dateTime={treatment.treatmentAt}
|
||||
>
|
||||
{new Date(t.treatmentAt).toLocaleDateString()}
|
||||
{new Date(treatment.treatmentAt).toLocaleDateString()}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{t.cases.map((c, idx) => {
|
||||
{treatment.cases.map((c, idx) => {
|
||||
const attachments = c.attachmentMetas ?? [];
|
||||
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
|
||||
const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
@@ -59,7 +75,7 @@ export function PastTreatmentsPanel({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium text-text-primary capitalize">
|
||||
Case {idx + 1} · {c.treatmentType}
|
||||
{t('historyCaseLabel', { n: idx + 1, type: typeLabel })}
|
||||
</p>
|
||||
{c.sentAt && (
|
||||
<CaseSentLabel
|
||||
@@ -69,17 +85,17 @@ export function PastTreatmentsPanel({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
Teeth: {c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
|
||||
{t('teethLabel')} {c.teeth.length ? [...c.teeth].sort().join(', ') : t('teethNone')}
|
||||
</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
|
||||
{t('attachments')}
|
||||
</p>
|
||||
{attachments.length === 0 ? (
|
||||
<p className="text-[11px] text-text-muted">None</p>
|
||||
<p className="text-[11px] text-text-muted">{tCommon('none')}</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{attachments.map((doc) => (
|
||||
@@ -106,10 +122,10 @@ export function PastTreatmentsPanel({
|
||||
<div className="pt-1 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReviewTreatment(t)}
|
||||
onClick={() => onReviewTreatment(treatment)}
|
||||
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
|
||||
{t('reviewDetails')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||
@@ -54,6 +55,8 @@ export function TreatmentCasesEditor({
|
||||
onSendCase,
|
||||
onUploadFiles,
|
||||
}: TreatmentCasesEditorProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tCommon = useTranslations('common');
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
const activeCase = cases.find((c) => c.clientId === activeCaseId) ?? cases[0];
|
||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||
@@ -84,17 +87,17 @@ export function TreatmentCasesEditor({
|
||||
<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>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('casesTitle')}</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Each case has its own teeth, notes, attachments, and destinations for send.
|
||||
{t('casesSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="secondary" disabled={!canEdit || disabled} onClick={onPreview}>
|
||||
Preview
|
||||
{tCommon('preview')}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" disabled={!canEdit || disabled} onClick={onAddCase}>
|
||||
Add case
|
||||
{t('addCase')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +108,7 @@ export function TreatmentCasesEditor({
|
||||
organizationIds: c.sendToOrganizationIds ?? [],
|
||||
sentAt: c.sentAt ?? null,
|
||||
orgs,
|
||||
});
|
||||
}, t);
|
||||
return (
|
||||
<button
|
||||
key={c.clientId}
|
||||
@@ -121,7 +124,7 @@ export function TreatmentCasesEditor({
|
||||
}
|
||||
`}
|
||||
>
|
||||
Case {idx + 1}
|
||||
{t('caseLabel', { n: idx + 1 })}
|
||||
{sentSummary ? ` · ${sentSummary}` : ''}
|
||||
</button>
|
||||
);
|
||||
@@ -130,7 +133,7 @@ export function TreatmentCasesEditor({
|
||||
|
||||
<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
|
||||
{t('comments')}
|
||||
<textarea
|
||||
value={activeCase.comment}
|
||||
onChange={(e) => {
|
||||
@@ -139,7 +142,7 @@ export function TreatmentCasesEditor({
|
||||
cases.map((c) => (c.clientId === activeCaseId ? { ...c, comment: v } : c)),
|
||||
);
|
||||
}}
|
||||
placeholder="Write clinical notes for this case…"
|
||||
placeholder={t('commentsPlaceholder')}
|
||||
rows={5}
|
||||
disabled={disabled || Boolean(activeCase.sentAt)}
|
||||
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
|
||||
@@ -148,7 +151,7 @@ export function TreatmentCasesEditor({
|
||||
|
||||
<div>
|
||||
<Dropdown
|
||||
label="Treatment type"
|
||||
label={t('treatmentType')}
|
||||
value={activeCase.treatmentType}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value as TreatmentCaseDraft['treatmentType'];
|
||||
@@ -162,16 +165,16 @@ export function TreatmentCasesEditor({
|
||||
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>
|
||||
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">{t('typeConsultation')}</option>
|
||||
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">{t('typeFilling')}</option>
|
||||
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">{t('typeEndo')}</option>
|
||||
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">{t('typeVisit')}</option>
|
||||
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">{t('typeHygiene')}</option>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">Attachments</p>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
id="treatment-case-attachments"
|
||||
@@ -183,7 +186,7 @@ export function TreatmentCasesEditor({
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="sr-only"
|
||||
aria-label="Attach files for this treatment case"
|
||||
aria-label={t('attachFiles')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -193,7 +196,7 @@ export function TreatmentCasesEditor({
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
aria-controls="treatment-case-attachments"
|
||||
>
|
||||
Choose files
|
||||
{t('chooseFiles')}
|
||||
</Button>
|
||||
{activeCase.attachmentMetas.length > 0 && (
|
||||
<ul className="mt-2 space-y-1 text-xs text-text-muted">
|
||||
@@ -208,17 +211,17 @@ export function TreatmentCasesEditor({
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
Send this case to linked organizations
|
||||
{t('sendToOrgs')}
|
||||
</p>
|
||||
<div className="space-y-2 mb-2">
|
||||
<SearchBar
|
||||
value={organizationSearch}
|
||||
onChange={onOrganizationSearchChange}
|
||||
placeholder="Search active organizations..."
|
||||
placeholder={t('searchOrgsPlaceholder')}
|
||||
/>
|
||||
{recentOrganizations.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-text-muted">Recent:</span>
|
||||
<span className="text-xs text-text-muted">{t('recent')}</span>
|
||||
{recentOrganizations.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
@@ -254,7 +257,7 @@ export function TreatmentCasesEditor({
|
||||
/>
|
||||
))}
|
||||
{filteredOrganizations.length === 0 && (
|
||||
<p className="text-xs text-text-muted">No active organization matches your search.</p>
|
||||
<p className="text-xs text-text-muted">{t('noOrgMatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,7 +270,7 @@ export function TreatmentCasesEditor({
|
||||
isLoading={sendBusyId === activeCase.clientId}
|
||||
onClick={() => onSendCase(activeCase)}
|
||||
>
|
||||
Send this case
|
||||
{t('sendThisCase')}
|
||||
</Button>
|
||||
{activeCase.sentAt && (
|
||||
<CaseSentLabel treatmentCase={activeCase} orgs={orgs} />
|
||||
@@ -284,10 +287,10 @@ export function TreatmentCasesEditor({
|
||||
isLoading={saveBusy}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save treatment draft
|
||||
{t('saveDraft')}
|
||||
</Button>
|
||||
<p className="text-xs text-text-muted self-center">
|
||||
{isDirty ? 'Unsaved changes' : 'Draft saved'}. Sending is per case and saves first automatically.
|
||||
{isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('sendSavesFirst')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { treatmentsApi } from '@/lib/api/treatments';
|
||||
import type { TreatmentAttachmentMeta } from '@/types/treatment';
|
||||
@@ -22,6 +23,7 @@ export function TreatmentLatestAttachmentPreview({
|
||||
attachment,
|
||||
className = '',
|
||||
}: TreatmentLatestAttachmentPreviewProps) {
|
||||
const tCommon = useTranslations('common');
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -72,11 +74,11 @@ export function TreatmentLatestAttachmentPreview({
|
||||
>
|
||||
{!attachment ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
|
||||
None
|
||||
{tCommon('none')}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
|
||||
…
|
||||
{tCommon('loadingEllipsis')}
|
||||
</div>
|
||||
) : loadFailed || !canRenderPreview || !previewUrl ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-1.5 text-center">
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import type { PastTreatment } from '@/types/treatment';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
interface TreatmentPreviewCardProps {
|
||||
draft: PastTreatment | null;
|
||||
disabled?: boolean;
|
||||
@@ -10,16 +19,22 @@ interface TreatmentPreviewCardProps {
|
||||
}
|
||||
|
||||
export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPreviewCardProps) {
|
||||
const t = useTranslations('treatment');
|
||||
|
||||
const attachmentCount = draft
|
||||
? draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0)
|
||||
: 0;
|
||||
|
||||
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>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('previewTitle')}</h3>
|
||||
<Button type="button" variant="primary" disabled={disabled || !draft} onClick={onPreview}>
|
||||
Preview current draft
|
||||
{t('previewDraft')}
|
||||
</Button>
|
||||
</div>
|
||||
{!draft ? (
|
||||
<p className="text-sm text-text-muted">Select an appointment to preview its draft.</p>
|
||||
<p className="text-sm text-text-muted">{t('selectAppointment')}</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">
|
||||
@@ -27,26 +42,31 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr
|
||||
<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'}
|
||||
{t('caseCount', { n: draft.cases.length })} ·{' '}
|
||||
{t('attachmentCount', { n: attachmentCount })}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{draft.cases.slice(0, 2).map((c, idx) => (
|
||||
{draft.cases.slice(0, 2).map((c, idx) => {
|
||||
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
|
||||
const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
|
||||
return (
|
||||
<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}
|
||||
{t('caseSummary', { n: idx + 1, type: typeLabel })}
|
||||
</span>
|
||||
{c.teeth.length > 0 && (
|
||||
<span className="ml-1 tabular-nums">· Teeth {[...c.teeth].sort().join(', ')}</span>
|
||||
<span className="ml-1 tabular-nums">
|
||||
{t('teethPrefix')} {[...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>
|
||||
<p className="text-xs text-text-muted">{t('moreCases', { n: draft.cases.length - 2 })}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Loader2, Paperclip, Send } from 'lucide-react';
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
@@ -32,6 +33,14 @@ function caseKey(c: PastTreatmentCase): string {
|
||||
const caseActionIconClass =
|
||||
'inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1.5 text-text-secondary transition-colors hover:bg-background-card/80 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
export function TreatmentPreviewDialog({
|
||||
open,
|
||||
onClose,
|
||||
@@ -45,6 +54,7 @@ export function TreatmentPreviewDialog({
|
||||
getCaseOrgIds,
|
||||
onToggleCaseOrg,
|
||||
}: TreatmentPreviewDialogProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const [expandedSendCaseId, setExpandedSendCaseId] = useState<string | null>(null);
|
||||
const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
@@ -64,10 +74,10 @@ export function TreatmentPreviewDialog({
|
||||
<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
|
||||
{t('previewDialogTitle')}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Review cases, attachments, and send destinations.
|
||||
{t('previewDialogSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<DialogCloseButton onClick={onClose} />
|
||||
@@ -80,10 +90,12 @@ export function TreatmentPreviewDialog({
|
||||
{new Date(treatment.treatmentAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary capitalize">Status: {treatment.status}</p>
|
||||
<p className="text-xs text-text-secondary capitalize">
|
||||
{t('statusLabel')} {treatment.status}
|
||||
</p>
|
||||
|
||||
{treatment.cases.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">No cases in this treatment.</p>
|
||||
<p className="text-sm text-text-muted">{t('noCases')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{treatment.cases.map((c, idx) => {
|
||||
@@ -98,6 +110,8 @@ export function TreatmentPreviewDialog({
|
||||
const comment = c.notes?.trim() ?? '';
|
||||
const attachBusy = uploadBusyCaseId === key;
|
||||
const sendBusy = sendBusyCaseId === key;
|
||||
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
|
||||
const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -107,7 +121,9 @@ export function TreatmentPreviewDialog({
|
||||
<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>
|
||||
<p className="text-xs font-medium text-text-primary">
|
||||
{t('caseLabel', { n: idx + 1 })}
|
||||
</p>
|
||||
{actionsEnabled && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input
|
||||
@@ -129,8 +145,8 @@ export function TreatmentPreviewDialog({
|
||||
type="button"
|
||||
className={caseActionIconClass}
|
||||
disabled={attachBusy}
|
||||
aria-label="Attach files"
|
||||
title="Attach files"
|
||||
aria-label={t('attachFilesShort')}
|
||||
title={t('attachFilesShort')}
|
||||
onClick={() => fileInputsRef.current[key]?.click()}
|
||||
>
|
||||
{attachBusy ? (
|
||||
@@ -145,8 +161,8 @@ export function TreatmentPreviewDialog({
|
||||
sendExpanded ? 'bg-primary-soft text-primary' : ''
|
||||
}`}
|
||||
disabled={sendBusy}
|
||||
aria-label="Send this case"
|
||||
title="Send this case"
|
||||
aria-label={t('sendCase')}
|
||||
title={t('sendCase')}
|
||||
aria-expanded={sendExpanded}
|
||||
onClick={() =>
|
||||
setExpandedSendCaseId((prev) => (prev === key ? null : key))
|
||||
@@ -162,18 +178,18 @@ export function TreatmentPreviewDialog({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-secondary capitalize">
|
||||
Type: {c.treatmentType}
|
||||
{t('typeLabel')} {typeLabel}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
Teeth:{' '}
|
||||
{c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
|
||||
{t('teethLabel')}{' '}
|
||||
{c.teeth.length ? [...c.teeth].sort().join(', ') : t('teethNone')}
|
||||
</p>
|
||||
{comment ? (
|
||||
<p className="text-[11px] text-text-muted line-clamp-2" title={comment}>
|
||||
Comments: {comment}
|
||||
{t('commentsLabel')} {comment}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-text-muted">Comments: —</p>
|
||||
<p className="text-[11px] text-text-muted">{t('commentsEmpty')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -186,7 +202,7 @@ export function TreatmentPreviewDialog({
|
||||
/>
|
||||
)}
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||
Attachments
|
||||
{t('attachments')}
|
||||
</p>
|
||||
<TreatmentLatestAttachmentPreview attachment={latestAttachment} />
|
||||
</div>
|
||||
@@ -195,10 +211,10 @@ export function TreatmentPreviewDialog({
|
||||
{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
|
||||
{t('sendToLinkedOrgs')}
|
||||
</p>
|
||||
{activeOrgs.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">No active linked organizations.</p>
|
||||
<p className="text-xs text-text-muted">{t('noActiveOrgs')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{activeOrgs.map((o) => (
|
||||
@@ -219,7 +235,7 @@ export function TreatmentPreviewDialog({
|
||||
isLoading={sendBusy}
|
||||
onClick={() => void onSend?.(key, selectedOrgIds)}
|
||||
>
|
||||
Confirm send
|
||||
{t('confirmSend')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
||||
@@ -34,6 +35,14 @@ import type {
|
||||
TreatmentCaseDraft,
|
||||
} from '@/types/treatment';
|
||||
|
||||
const TREATMENT_TYPE_KEYS = {
|
||||
consultation: 'typeConsultation',
|
||||
filling: 'typeFilling',
|
||||
endo: 'typeEndo',
|
||||
visit: 'typeVisit',
|
||||
hygiene: 'typeHygiene',
|
||||
} as const;
|
||||
|
||||
function newCase(): TreatmentCaseDraft {
|
||||
return {
|
||||
clientId:
|
||||
@@ -121,6 +130,7 @@ interface TreatmentWorkspaceProps {
|
||||
}
|
||||
|
||||
export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const { showError, showSuccess, messages: toastMessages } = useToast();
|
||||
const canView = canViewTreatment(currentOrganization);
|
||||
const canEdit = canEditTreatment(currentOrganization);
|
||||
@@ -185,12 +195,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
|
||||
if (!selectedAppointment) return null;
|
||||
return casesToPreviewTreatment(cases, {
|
||||
title: `Draft · ${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
||||
title: t('draftTitle', {
|
||||
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
||||
}),
|
||||
patientId: selectedAppointment.patientId,
|
||||
treatmentAt: new Date().toISOString(),
|
||||
status: 'draft',
|
||||
});
|
||||
}, [cases, selectedAppointment]);
|
||||
}, [cases, selectedAppointment, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionLocked(false);
|
||||
@@ -217,7 +229,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to load appointments.'));
|
||||
showError(formatApiErrorMessage(error, t('errorLoadAppointments')));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setApptsLoading(false);
|
||||
@@ -226,7 +238,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, selectedDay, showError]);
|
||||
}, [userId, selectedDay, showError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const today = startOfLocalDay(new Date());
|
||||
@@ -250,14 +262,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
if (!cancelled) setOrgs(list.data);
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to load linked organizations.'));
|
||||
showError(formatApiErrorMessage(error, t('errorLoadOrgs')));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showError]);
|
||||
}, [showError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAppointment) {
|
||||
@@ -272,7 +284,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
if (!cancelled) setHistory(response.data);
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to load treatment history.'));
|
||||
showError(formatApiErrorMessage(error, t('errorLoadHistory')));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setHistoryLoading(false);
|
||||
@@ -281,7 +293,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedAppointment?.patientId, showError]);
|
||||
}, [selectedAppointment?.patientId, showError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const appointmentId = selectedAppointment?.id;
|
||||
@@ -310,19 +322,19 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
setOrganizationSearch('');
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to load treatment draft.'));
|
||||
showError(formatApiErrorMessage(error, t('errorLoadDraft')));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedAppointment?.id, showError]);
|
||||
}, [selectedAppointment?.id, showError, t]);
|
||||
|
||||
const confirmDiscardIfDirty = useCallback(() => {
|
||||
if (!isDirty) return true;
|
||||
return window.confirm('You have unsaved changes. Discard them and continue?');
|
||||
}, [isDirty]);
|
||||
return window.confirm(t('confirmDiscard'));
|
||||
}, [isDirty, t]);
|
||||
|
||||
const onPickAppointment = useCallback(
|
||||
(id: string) => {
|
||||
@@ -361,16 +373,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
: c,
|
||||
),
|
||||
);
|
||||
showSuccess(
|
||||
`${uploaded.data.length} file${uploaded.data.length === 1 ? '' : 's'} uploaded successfully.`,
|
||||
);
|
||||
showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to upload attachments.'));
|
||||
showError(formatApiErrorMessage(error, t('errorUpload')));
|
||||
} finally {
|
||||
setUploadBusyCaseId(null);
|
||||
}
|
||||
},
|
||||
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError],
|
||||
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t],
|
||||
);
|
||||
|
||||
const persistDraft = useCallback(async () => {
|
||||
@@ -401,13 +411,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
setSaveBusy(true);
|
||||
try {
|
||||
await persistDraft();
|
||||
showSuccess('Treatment draft saved.');
|
||||
showSuccess(t('successDraftSaved'));
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to save treatment draft.'));
|
||||
showError(formatApiErrorMessage(error, t('errorSaveDraft')));
|
||||
} finally {
|
||||
setSaveBusy(false);
|
||||
}
|
||||
}, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError]);
|
||||
}, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError, t]);
|
||||
|
||||
const handleSendCase = useCallback(
|
||||
async (treatmentCase: TreatmentCaseDraft) => {
|
||||
@@ -416,14 +426,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
orgs.some((o) => o.id === id && o.active),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
showError('Choose at least one active organization to send this case.');
|
||||
showError(t('errorChooseOrg'));
|
||||
return;
|
||||
}
|
||||
setSendBusyId(treatmentCase.clientId);
|
||||
try {
|
||||
const saved = await persistDraft();
|
||||
const serverCase = saved.cases.find((c) => c.clientId === treatmentCase.clientId);
|
||||
if (!serverCase?.id) throw new Error('Case must be saved before sending.');
|
||||
if (!serverCase?.id) throw new Error(t('errorCaseMustSave'));
|
||||
|
||||
const response = await treatmentsApi.sendCase(serverCase.id, { organizationIds: targets });
|
||||
setCases((prev) => {
|
||||
@@ -445,14 +455,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
const next = [...targets.filter((id) => !prev.includes(id)), ...prev];
|
||||
return next.slice(0, 10);
|
||||
});
|
||||
showSuccess('Case sent to selected organizations.');
|
||||
showSuccess(t('successCaseSent'));
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, 'Failed to send case.'));
|
||||
showError(formatApiErrorMessage(error, t('errorSendCase')));
|
||||
} finally {
|
||||
setSendBusyId(null);
|
||||
}
|
||||
},
|
||||
[canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, showSuccess, showError],
|
||||
[canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, showSuccess, showError, t],
|
||||
);
|
||||
|
||||
const openPreview = useCallback(
|
||||
@@ -489,9 +499,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="surface-card p-6 max-w-xl">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Treatment workspace</h2>
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
|
||||
<p className="text-sm text-text-secondary mt-2">
|
||||
You do not have permission to view the Treatment tab for this organization.
|
||||
{t('noPermissionBody')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -500,11 +510,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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">{t('title')}</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{canEdit
|
||||
? 'Document cases for your appointments, save drafts, and send work to linked organizations.'
|
||||
: 'View-only access — you can review appointments and treatment history but cannot edit.'}
|
||||
{canEdit ? t('subtitleEdit') : t('subtitleReadOnly')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -523,8 +531,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
{isViewingPastDay && (
|
||||
<p className="text-sm text-text-secondary rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/50 px-3 py-2">
|
||||
Past days are view-only. You can review appointments and history, but treatment cases
|
||||
cannot be added or changed.
|
||||
{t('pastDayNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -532,18 +539,20 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
|
||||
{selectedAppointment ? (
|
||||
<div className="surface-card p-3 space-y-0.5">
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">Selected patient</p>
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('selectedPatient')}</p>
|
||||
<p className="text-base font-semibold text-text-primary">
|
||||
{selectedAppointment.patientFirstName} {selectedAppointment.patientLastName}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
Purpose:{' '}
|
||||
<span className="capitalize text-text-primary">{selectedAppointment.purpose}</span>
|
||||
{t('purposeLabel')}{' '}
|
||||
<span className="capitalize text-text-primary">
|
||||
{t(TREATMENT_TYPE_KEYS[selectedAppointment.purpose as keyof typeof TREATMENT_TYPE_KEYS] ?? selectedAppointment.purpose)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="surface-card p-3 text-sm text-text-muted">
|
||||
{apptsLoading ? 'Loading appointments…' : 'Select a day with at least one appointment.'}
|
||||
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user