feature: localization's first implmentation done. all frontend hardcoded text is now localized.

This commit is contained in:
2026-06-20 14:51:43 +03:30
parent 284fbd08aa
commit b2f4dfa4ca
52 changed files with 3306 additions and 1041 deletions

View File

@@ -1,11 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
import { APPOINTMENT_PURPOSES } from '@/types/appointment';
import { getPurposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles';
import type { Patient } from '@/types/patient';
import {
combineLocalDateAndTime,
@@ -36,6 +38,14 @@ interface AppointmentBookingModalProps {
deleting?: boolean;
}
const PURPOSE_OPTION_COLORS: Record<AppointmentPurpose, string> = {
consultation: '#ddd6fe',
filling: '#fed7aa',
endo: '#fecaca',
visit: '#bae6fd',
hygiene: '#d9f99d',
};
export function AppointmentBookingModal({
open,
scheduleDate,
@@ -51,20 +61,15 @@ export function AppointmentBookingModal({
onDelete,
deleting = false,
}: AppointmentBookingModalProps) {
const t = useTranslations('appointments');
const tCommon = useTranslations('common');
const tPatients = useTranslations('patients');
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [purpose, setPurpose] = useState<AppointmentPurpose>('consultation');
const [error, setError] = useState('');
const purposeTextColor =
purpose === 'consultation'
? '#ddd6fe'
: purpose === 'filling'
? '#fed7aa'
: purpose === 'endo'
? '#fecaca'
: purpose === 'visit'
? '#bae6fd'
: '#d9f99d';
const purposeTextColor = PURPOSE_OPTION_COLORS[purpose];
useEffect(() => {
if (!open) {
@@ -116,7 +121,7 @@ export function AppointmentBookingModal({
return;
}
if (!editingAppointment && !patient) {
setError('Select a patient first.');
setError(t('errorSelectPatient'));
return;
}
@@ -124,26 +129,26 @@ export function AppointmentBookingModal({
const endAt = combineLocalDateAndTime(scheduleDate, endTime);
if (endAt <= startAt) {
setError('End time must be after start time.');
setError(t('errorEndAfterStart'));
return;
}
const now = new Date();
if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) {
setError('Cannot schedule in the past.');
setError(t('errorPastSchedule'));
return;
}
const today = new Date();
if (compareLocalDayStart(scheduleDate, today) < 0) {
setError('Past appointments are view-only.');
setError(t('errorPastViewOnly'));
return;
}
const effectivePatientId = editingAppointment?.patientId ?? patient?.id;
const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId;
if (!effectivePatientId || !effectiveProviderId) {
setError('Missing appointment details.');
setError(t('errorMissingDetails'));
return;
}
@@ -166,29 +171,34 @@ export function AppointmentBookingModal({
>
<div className="flex items-start justify-between gap-2">
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
{editingAppointment ? 'Edit appointment' : 'New appointment'}
{editingAppointment ? t('editTitle') : t('newTitle')}
</h2>
<DialogCloseButton onClick={onClose} />
</div>
<p className="text-sm text-text-secondary">
Provider: <span className="text-text-primary font-medium">{providerName}</span>
{t('providerLabel')}{' '}
<span className="text-text-primary font-medium">{providerName}</span>
</p>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Patient</label>
<label className="block text-sm font-medium text-text-secondary mb-1">
{t('patientLabel')}
</label>
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
{editingAppointment
? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}`
: patient
? `${patient.firstName} ${patient.lastName}`
: '—'}
: tPatients('emptyValue')}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Start</label>
<label className="block text-sm font-medium text-text-secondary mb-1">
{t('startLabel')}
</label>
<input
type="time"
step={60}
@@ -198,7 +208,9 @@ export function AppointmentBookingModal({
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">End</label>
<label className="block text-sm font-medium text-text-secondary mb-1">
{t('endLabel')}
</label>
<input
type="time"
step={60}
@@ -210,26 +222,20 @@ export function AppointmentBookingModal({
</div>
<Dropdown
label="Purpose"
label={t('purposeLabel')}
value={purpose}
onChange={(e) => setPurpose(e.target.value as AppointmentPurpose)}
style={{ color: purposeTextColor }}
>
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.consultation}
</option>
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.filling}
</option>
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.endo}
</option>
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.visit}
</option>
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.hygiene}
</option>
{APPOINTMENT_PURPOSES.map((purposeOption) => (
<option
key={purposeOption}
value={purposeOption}
style={{ color: PURPOSE_OPTION_COLORS[purposeOption], backgroundColor: '#14253d' }}
>
{getPurposeLabel(purposeOption, t)}
</option>
))}
</Dropdown>
{error && <p className="text-sm text-red-400">{error}</p>}
@@ -243,14 +249,14 @@ export function AppointmentBookingModal({
disabled={loading || deleting}
isLoading={deleting}
>
Delete
{tCommon('delete')}
</Button>
) : (
<span />
)}
<div className="flex gap-2 ml-auto">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading || deleting}>
Cancel
{tCommon('cancel')}
</Button>
<Button
type="button"
@@ -259,7 +265,7 @@ export function AppointmentBookingModal({
isLoading={loading}
disabled={deleting}
>
Save
{tCommon('save')}
</Button>
</div>
</div>

View File

@@ -1,12 +1,13 @@
'use client';
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
APPOINTMENT_PURPOSE_LABEL,
getPurposeLabel,
purposeStyle,
} from '@/components/ui/appointments/appointmentPurposeStyles';
import type { AppointmentRecord } from '@/types/appointment';
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
type AppointmentOverlapPopoverProps = {
appointments: AppointmentRecord[];
@@ -28,6 +29,7 @@ export function AppointmentOverlapPopover({
onSelect,
onClose,
}: AppointmentOverlapPopoverProps) {
const t = useTranslations('appointments');
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -75,13 +77,13 @@ export function AppointmentOverlapPopover({
>
<div className="flex items-start justify-between gap-2 mb-2">
<h3 id="overlap-popover-title" className="text-sm font-semibold text-text-primary pr-2">
Overlapping appointments ({sorted.length})
{t('overlappingTitle', { count: sorted.length })}
</h3>
<DialogCloseButton onClick={onClose} />
</div>
<ul className="space-y-1.5 max-h-[min(16rem,50vh)] overflow-y-auto">
{sorted.map((apt) => {
const purpose = apt.purpose as keyof typeof APPOINTMENT_PURPOSE_LABEL;
const purpose = apt.purpose as AppointmentPurpose;
return (
<li key={apt.id}>
<button
@@ -97,7 +99,7 @@ export function AppointmentOverlapPopover({
</p>
<p className="text-[11px] opacity-90 tabular-nums">{formatTimeRange(apt)}</p>
<p className="text-[10px] opacity-80 truncate">
{APPOINTMENT_PURPOSE_LABEL[purpose] ?? apt.purpose}
{getPurposeLabel(purpose, t) ?? apt.purpose}
</p>
</button>
</li>

View File

@@ -1,6 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import {
SCHEDULE_SLOT_MINUTES,
@@ -91,6 +92,7 @@ export function AppointmentScheduleGrid({
onAppointmentClick,
onAppointmentOutsideHours,
}: AppointmentScheduleGridProps) {
const t = useTranslations('appointments');
const [overlapPopover, setOverlapPopover] = useState<OverlapPopoverState | null>(null);
const visibleRange = useMemo(() => {
@@ -159,18 +161,13 @@ export function AppointmentScheduleGrid({
if (providers.length === 0) {
return (
<div className="surface-card p-6 text-sm text-text-muted">
No providers available. Add staff with treatment edit access to see columns here.
</div>
<div className="surface-card p-6 text-sm text-text-muted">{t('noProviders')}</div>
);
}
if (!snappedRange || slotStarts.length === 0) {
return (
<div className="surface-card p-6 text-sm text-text-muted">
No working hours are configured for this day. Set provider working hours in Staff
management.
</div>
<div className="surface-card p-6 text-sm text-text-muted">{t('noWorkingHours')}</div>
);
}
@@ -188,12 +185,12 @@ export function AppointmentScheduleGrid({
{p.name}
{!p.hasWorkingHours && (
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
No hours set
{t('noHoursSet')}
</span>
)}
{p.hasWorkingHours && p.dayBlocks.length === 0 && (
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
Off today
{t('offToday')}
</span>
)}
</div>
@@ -252,13 +249,15 @@ export function AppointmentScheduleGrid({
title={
columnFullyDisabled
? p.hasWorkingHours
? 'Provider is off today'
: 'Working hours not configured'
? t('slotOffToday')
: t('slotHoursNotConfigured')
: !slotActive
? 'Outside working hours'
? t('slotOutsideHours')
: slotDisabled
? 'You cannot create appointments'
: `Book ${formatMinuteLabel(slotStartMinute)}`
? t('slotCannotCreate')
: t('slotBookAt', {
time: formatMinuteLabel(slotStartMinute),
})
}
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
slotDisabled
@@ -297,8 +296,10 @@ export function AppointmentScheduleGrid({
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
const bannerTitle = [
patientName,
outsideHours ? 'Outside working hours — editing blocked' : null,
clusterSize > 1 ? `${clusterSize} overlapping — click to choose` : null,
outsideHours ? t('outsideHoursBlocked') : null,
clusterSize > 1
? t('overlappingChoose', { count: clusterSize })
: null,
!isUnderOneHour && apt.patient.phone ? apt.patient.phone : null,
]
.filter(Boolean)
@@ -345,7 +346,7 @@ export function AppointmentScheduleGrid({
)}
{!isUnderOneHour && clusterSize > 1 && (
<span className="block w-full truncate pointer-events-none text-[9px] leading-tight opacity-75">
{clusterSize} overlapping
{t('overlapping', { count: clusterSize })}
</span>
)}
</button>

View File

@@ -1,20 +1,25 @@
'use client';
import { useTranslations } from 'next-intl';
import {
APPOINTMENT_PURPOSE_LABEL,
APPOINTMENT_PURPOSE_LEGEND_SWATCH,
getPurposeLabel,
} from '@/components/ui/appointments/appointmentPurposeStyles';
import { APPOINTMENT_PURPOSES } from '@/types/appointment';
export function AppointmentScheduleLegend() {
const t = useTranslations('appointments');
return (
<div className="surface-panel px-4 py-3">
<p className="text-xs font-medium text-text-secondary mb-2">Legend</p>
<p className="text-xs font-medium text-text-secondary mb-2">{t('legend')}</p>
<div className="flex flex-wrap gap-3">
{APPOINTMENT_PURPOSES.map((p) => (
<div key={p} className="flex items-center gap-1.5 text-xs text-text-secondary">
<span
className={`inline-block h-3 w-3 rounded-sm border ${APPOINTMENT_PURPOSE_LEGEND_SWATCH[p]}`}
/>
{APPOINTMENT_PURPOSE_LABEL[p]}
{getPurposeLabel(p, t)}
</div>
))}
</div>

View File

@@ -1,6 +1,7 @@
'use client';
import { Search } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import type { Patient } from '@/types/patient';
@@ -26,6 +27,9 @@ export function AppointmentsPatientSearch({
canAddPatient,
onAddPatient,
}: AppointmentsPatientSearchProps) {
const t = useTranslations('appointments');
const tPatients = useTranslations('patients');
const trimmed = search.trim();
const showAddForEmptyResults =
trimmed.length > 0 && !loading && patients.length === 0;
@@ -35,7 +39,7 @@ export function AppointmentsPatientSearch({
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
<div className="flex-1">
<Input
placeholder="Search existing patients"
placeholder={t('searchPlaceholder')}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
icon={<Search className="h-4 w-4 icon-flat" />}
@@ -47,18 +51,18 @@ export function AppointmentsPatientSearch({
variant="primary"
disabled={!canAddPatient}
onClick={onAddPatient}
title={!canAddPatient ? 'You do not have permission to add patients.' : undefined}
title={!canAddPatient ? t('noPermissionAdd') : undefined}
>
New Patient
{tPatients('newPatient')}
</Button>
)}
</div>
<div className="space-y-2 max-h-72 overflow-y-auto">
{loading && <p className="text-sm text-text-muted">Searching</p>}
{loading && <p className="text-sm text-text-muted">{t('searching')}</p>}
{!loading && trimmed.length === 0 && (
<p className="text-sm text-text-muted">Type to search patients by name, phone, or email.</p>
<p className="text-sm text-text-muted">{t('searchHint')}</p>
)}
{patients.map((patient) => {
@@ -77,7 +81,9 @@ export function AppointmentsPatientSearch({
<p className="text-sm font-medium text-text-primary">
{patient.firstName} {patient.lastName}
</p>
<p className="text-xs text-text-muted">{patient.phone || patient.email || 'No contact'}</p>
<p className="text-xs text-text-muted">
{patient.phone || patient.email || tPatients('noContact')}
</p>
</button>
);
})}

View File

@@ -1,12 +1,25 @@
import type { AppointmentPurpose } from '@/types/appointment';
export const APPOINTMENT_PURPOSE_LABEL: Record<AppointmentPurpose, string> = {
consultation: 'Consultation',
filling: 'Filling',
endo: 'Endo',
visit: 'Visit',
hygiene: 'Hygiene',
};
export const APPOINTMENT_PURPOSE_LABEL_KEYS = {
consultation: 'purposeConsultation',
filling: 'purposeFilling',
endo: 'purposeEndo',
visit: 'purposeVisit',
hygiene: 'purposeHygiene',
} as const satisfies Record<AppointmentPurpose, string>;
export type AppointmentPurposeLabelKey =
(typeof APPOINTMENT_PURPOSE_LABEL_KEYS)[AppointmentPurpose];
export type AppointmentPurposeTranslate = (key: AppointmentPurposeLabelKey) => string;
export function getPurposeLabel(
purpose: AppointmentPurpose,
t: AppointmentPurposeTranslate,
): string {
const key = APPOINTMENT_PURPOSE_LABEL_KEYS[purpose];
return key ? t(key) : purpose;
}
/** Background + border for blocks / legend (matches reference palette). */
export const APPOINTMENT_PURPOSE_STYLES: Record<AppointmentPurpose, string> = {

View File

@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { Building2, Mail } from 'lucide-react';
import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { Input } from '@/components/ui/shared/Input';
@@ -23,26 +24,28 @@ export function OrganizationDetailsFields({
organizationType,
setValue,
}: OrganizationDetailsFieldsProps) {
const t = useTranslations('auth');
return (
<>
<Input
label="Organization name"
label={t('organizationName')}
{...register('organizationName')}
placeholder="Sunshine Dental Clinic"
placeholder={t('organizationNamePlaceholder')}
error={errors.organizationName?.message}
icon={<Building2 className="h-5 w-5 icon-flat" />}
/>
<Input
label="Organization email"
label={t('organizationEmail')}
{...register('organizationEmail')}
type="email"
placeholder="contact@sunshineclinic.com"
placeholder={t('organizationEmailPlaceholder')}
error={errors.organizationEmail?.message}
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Organization type
{t('organizationType')}
</label>
<input type="hidden" {...register('organizationType')} />
<div className="grid grid-cols-2 gap-4">
@@ -58,7 +61,7 @@ export function OrganizationDetailsFields({
}`}
>
<Building2 className="h-8 w-8 mx-auto mb-2 icon-flat" />
<span className="text-sm font-medium">Dental Clinic</span>
<span className="text-sm font-medium">{t('dentalClinic')}</span>
</button>
<button
type="button"
@@ -72,7 +75,7 @@ export function OrganizationDetailsFields({
}`}
>
<Building2 className="h-8 w-8 mx-auto mb-2 icon-flat" />
<span className="text-sm font-medium">Dental Lab</span>
<span className="text-sm font-medium">{t('dentalLab')}</span>
</button>
</div>
{errors.organizationType && (

View File

@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { ChevronRight } from 'lucide-react';
type RegistrationProgressStepsProps = {
@@ -10,9 +11,11 @@ type RegistrationProgressStepsProps = {
export function RegistrationProgressSteps({
step,
firstLabel = 'Account',
secondLabel = 'Organization',
firstLabel,
secondLabel,
}: RegistrationProgressStepsProps) {
const t = useTranslations('auth');
return (
<div className="mb-8">
<div className="flex items-center justify-between">
@@ -31,7 +34,7 @@ export function RegistrationProgressSteps({
step >= 1 ? 'text-primary' : 'text-text-muted'
}`}
>
{firstLabel}
{firstLabel ?? t('stepAccount')}
</div>
</div>
<ChevronRight className="h-5 w-5 text-text-muted icon-flat" />
@@ -50,7 +53,7 @@ export function RegistrationProgressSteps({
step >= 2 ? 'text-primary' : 'text-text-muted'
}`}
>
{secondLabel}
{secondLabel ?? t('stepOrganization')}
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { Check, Copy } from 'lucide-react';
import {
canShareOrganizationInviteLink,
@@ -19,6 +20,8 @@ export function CopyInvitationLinkButton({
copying,
onCopy,
}: CopyInvitationLinkButtonProps) {
const t = useTranslations('organizations');
if (!canShareOrganizationInviteLink(invitation)) {
return <span className="text-xs text-text-muted"></span>;
}
@@ -29,8 +32,8 @@ export function CopyInvitationLinkButton({
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
disabled={copying}
onClick={onCopy}
aria-label="Copy invitation link"
title="Copy invitation link (generates a new link if needed)"
aria-label={t('copyInvitationLink')}
title={t('copyInvitationLinkTitle')}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>

View File

@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast';
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
@@ -7,14 +8,6 @@ import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shar
import { Table } from '@/components/ui/shared/Table';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string {
if (status === 'PENDING') return 'Invitation pending';
if (status === 'ACTIVE') return 'Invitation accepted';
if (status === 'REJECTED') return 'Invitation rejected';
if (status === 'EXPIRED') return 'Invitation expired';
return status;
}
function formatTableDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '—';
@@ -43,6 +36,18 @@ export function InvitationHistoryDialog({
onCopy,
toastMessages,
}: InvitationHistoryDialogProps) {
const t = useTranslations('organizations');
function formatInvitationStatusLabel(
status: OrganizationInvitationHistoryItemDto['status'],
): string {
if (status === 'PENDING') return t('statusPending');
if (status === 'ACTIVE') return t('statusAccepted');
if (status === 'REJECTED') return t('statusRejected');
if (status === 'EXPIRED') return t('statusExpired');
return status;
}
if (!open) return null;
return (
@@ -55,7 +60,7 @@ export function InvitationHistoryDialog({
>
<div className="flex items-start justify-between gap-3">
<h2 id="invitation-history-title" className="text-lg font-semibold text-text-primary pr-2">
Invitation History
{t('historyTitle')}
</h2>
<DialogCloseButton onClick={onClose} />
</div>
@@ -63,27 +68,27 @@ export function InvitationHistoryDialog({
{toastMessages && <ToastStack {...toastMessages} />}
{loading ? (
<p className="text-sm text-text-secondary">Loading invitation history...</p>
<p className="text-sm text-text-secondary">{t('loadingHistory')}</p>
) : items.length === 0 ? (
<p className="text-sm text-text-secondary">No invitations yet.</p>
<p className="text-sm text-text-secondary">{t('historyEmpty')}</p>
) : (
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Organization
{t('tableOrganization')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Owner email
{t('tableOwnerEmail')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Date
{t('tableDate')}
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
Status
{t('tableStatus')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">
Invitation link
{t('tableInvitationLink')}
</th>
</tr>
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/hooks/useAuth';
import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions';
import { Building2, Beaker, Mail } from 'lucide-react';
@@ -8,6 +9,9 @@ import { Input } from '@/components/ui/shared/Input';
import { Button } from '@/components/ui/shared/Button';
export function OrganizationSelectorContent() {
const t = useTranslations('organizations');
const tAuth = useTranslations('auth');
const tCommon = useTranslations('common');
const {
organizations,
currentOrganization,
@@ -29,6 +33,9 @@ export function OrganizationSelectorContent() {
const getIcon = (type: string) =>
type === 'CLINIC' ? <Building2 className="h-8 w-8 icon-flat" /> : <Beaker className="h-8 w-8 icon-flat" />;
const getTypeLabel = (type: string) =>
type === 'CLINIC' ? tAuth('dentalClinic') : tAuth('dentalLab');
const handleCreateOrganization = async () => {
try {
clearError();
@@ -48,18 +55,16 @@ export function OrganizationSelectorContent() {
};
if (isLoading) {
return <p className="text-text-secondary">Loading...</p>;
return <p className="text-text-secondary">{tCommon('loadingEllipsis')}</p>;
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
<h1 className="text-3xl font-semibold text-text-primary">{t('selectorTitle')}</h1>
<p className="text-text-secondary mt-2">
{canCreateOrganization
? 'Select an organization to continue, or create a new one.'
: 'Select an organization to continue.'}
{canCreateOrganization ? t('selectorSubtitleWithCreate') : t('selectorSubtitleSelectOnly')}
</p>
</div>
{canCreateOrganization && (
@@ -71,7 +76,7 @@ export function OrganizationSelectorContent() {
setIsCreateOpen((prev) => !prev);
}}
>
{isCreateOpen ? 'Cancel' : 'Create Organization'}
{isCreateOpen ? tCommon('cancel') : t('createOrganization')}
</Button>
)}
</div>
@@ -79,23 +84,23 @@ export function OrganizationSelectorContent() {
{canCreateOrganization && isCreateOpen && (
<div className="surface-card p-6 space-y-4">
<Input
label="Organization name"
label={tAuth('organizationName')}
value={organizationName}
onChange={(event) => setOrganizationName(event.target.value)}
placeholder="Sunshine Dental Clinic"
placeholder={tAuth('organizationNamePlaceholder')}
icon={<Building2 className="h-5 w-5 icon-flat" />}
/>
<Input
label="Organization email"
label={tAuth('organizationEmail')}
value={organizationEmail}
onChange={(event) => setOrganizationEmail(event.target.value)}
placeholder="contact@sunshineclinic.com"
placeholder={tAuth('organizationEmailPlaceholder')}
type="email"
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Organization type
{tAuth('organizationType')}
</label>
<div className="grid grid-cols-2 gap-3">
<button
@@ -107,7 +112,7 @@ export function OrganizationSelectorContent() {
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
Dental Clinic
{tAuth('dentalClinic')}
</button>
<button
type="button"
@@ -118,7 +123,7 @@ export function OrganizationSelectorContent() {
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
Dental Lab
{tAuth('dentalLab')}
</button>
</div>
</div>
@@ -135,7 +140,7 @@ export function OrganizationSelectorContent() {
isLoading={isLoading}
disabled={!organizationName.trim() || !organizationEmail.trim()}
>
Create and Continue
{t('createAndContinue')}
</Button>
</div>
</div>
@@ -144,9 +149,7 @@ export function OrganizationSelectorContent() {
{!organizations.length ? (
<div className="surface-card p-8 text-center">
<p className="text-text-secondary">
{canCreateOrganization
? 'No organizations found. Create your first one to continue.'
: 'No organizations found. Ask an organization owner to invite you.'}
{canCreateOrganization ? t('emptyCanCreate') : t('emptyAskOwner')}
</p>
</div>
) : (
@@ -166,12 +169,12 @@ export function OrganizationSelectorContent() {
{org.name}
</h3>
<p className="text-sm text-text-secondary">
{org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'}
{getTypeLabel(org.type)}
</p>
</div>
<div className="text-primary text-sm">
Continue
{t('continueArrow')}
</div>
</button>
))}

View File

@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Input } from '@/components/ui/shared/Input';
@@ -31,26 +32,29 @@ function CreatePatientFormFields({
loading: boolean;
showCancel: boolean;
}) {
const t = useTranslations('patients');
const tCommon = useTranslations('common');
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
label="First name"
label={t('firstName')}
value={formData.firstName || ''}
onChange={(e) => onChange({ firstName: e.target.value })}
/>
<Input
label="Last name"
label={t('lastName')}
value={formData.lastName || ''}
onChange={(e) => onChange({ lastName: e.target.value })}
/>
<Input
label="Phone"
label={t('phone')}
value={formData.phone || ''}
onChange={(e) => onChange({ phone: e.target.value })}
/>
<Input
label="Email"
label={tCommon('email')}
type="email"
value={formData.email || ''}
onChange={(e) => onChange({ email: e.target.value })}
@@ -64,11 +68,11 @@ function CreatePatientFormFields({
isLoading={loading}
disabled={!formData.firstName || !formData.lastName}
>
Save Patient
{t('savePatient')}
</Button>
{showCancel && (
<Button variant="ghost" onClick={onClose}>
Cancel
{tCommon('cancel')}
</Button>
)}
</div>
@@ -85,6 +89,8 @@ export function CreatePatientModal({
loading = false,
variant = 'inline',
}: CreatePatientModalProps) {
const t = useTranslations('patients');
if (!isOpen) {
return null;
}
@@ -112,7 +118,7 @@ export function CreatePatientModal({
id="create-patient-dialog-title"
className="text-lg font-semibold text-text-primary pr-2"
>
New patient
{t('dialogTitle')}
</h2>
<DialogCloseButton onClick={onClose} />
</div>

View File

@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/shared/Input';
import { Patient } from '@/types/patient';
@@ -21,20 +22,22 @@ export function PatientSearchSelect({
onSelectPatient,
loading = false,
}: PatientSearchSelectProps) {
const t = useTranslations('patients');
return (
<div className="surface-card p-4 space-y-4">
<Input
placeholder="Search patients by name, phone, email"
placeholder={t('searchPlaceholder')}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
<div className="space-y-2 max-h-80 overflow-y-auto">
{loading && <p className="text-sm text-text-muted">Loading patients...</p>}
{loading && <p className="text-sm text-text-muted">{t('loadingPatients')}</p>}
{!loading && patients.length === 0 && (
<p className="text-sm text-text-muted">No patients found for this search.</p>
<p className="text-sm text-text-muted">{t('noResults')}</p>
)}
{patients.map((patient) => {
@@ -53,7 +56,7 @@ export function PatientSearchSelect({
<p className="text-sm font-medium text-text-primary">
{patient.firstName} {patient.lastName}
</p>
<p className="text-xs text-text-muted">{patient.phone || patient.email || 'No contact'}</p>
<p className="text-xs text-text-muted">{patient.phone || patient.email || t('noContact')}</p>
</button>
);
})}

View File

@@ -1,3 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { Patient } from '@/types/patient';
interface PatientSummaryCardProps {
@@ -5,10 +8,12 @@ interface PatientSummaryCardProps {
}
export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {
const t = useTranslations('patients');
if (!patient) {
return (
<div className="surface-card p-4">
<p className="text-sm text-text-muted">Select a patient to view details.</p>
<p className="text-sm text-text-muted">{t('selectPatient')}</p>
</div>
);
}
@@ -18,10 +23,15 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {
<h2 className="text-lg font-semibold text-text-primary">
{patient.firstName} {patient.lastName}
</h2>
<p className="text-sm text-text-secondary">Phone: {patient.phone || '-'}</p>
<p className="text-sm text-text-secondary">Email: {patient.email || '-'}</p>
<p className="text-sm text-text-secondary">
Status: {patient.isActive ? 'Active' : 'Inactive'}
{t('phoneLabel')} {patient.phone || t('emptyValue')}
</p>
<p className="text-sm text-text-secondary">
{t('emailLabel')} {patient.email || t('emptyValue')}
</p>
<p className="text-sm text-text-secondary">
{t('statusLabel')}{' '}
{patient.isActive ? t('statusActive') : t('statusInactive')}
</p>
</div>
);

View File

@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { X } from 'lucide-react';
type DialogCloseButtonProps = {
@@ -8,12 +9,14 @@ type DialogCloseButtonProps = {
};
export function DialogCloseButton({ onClick, className = '' }: DialogCloseButtonProps) {
const t = useTranslations('common');
return (
<button
type="button"
onClick={onClick}
className={`shrink-0 rounded-[var(--radius-sm)] p-1.5 text-text-muted hover:text-text-primary hover:bg-background-secondary/80 focus:outline-none focus:ring-2 focus:ring-primary/35 ${className}`}
aria-label="Close"
aria-label={t('close')}
>
<X className="h-5 w-5 icon-flat" />
</button>

View File

@@ -1,5 +1,7 @@
// src/components/ui/OrganizationCard.tsx
'use client';
import React from 'react';
import { useTranslations } from 'next-intl';
import { ChevronRight } from 'lucide-react';
import type { Organization } from '@/types/organization';
import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon';
@@ -13,8 +15,11 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
organization,
onSelect,
}) => {
const t = useTranslations('organizations');
const tAuth = useTranslations('auth');
const Icon = organizationTypeIcon(organization.type);
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
const typeText =
organization.type === 'CLINIC' ? tAuth('dentalClinic') : tAuth('dentalLab');
return (
<button
@@ -29,11 +34,14 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
<p className="text-sm text-text-secondary">{typeText}</p>
{organization.plan && (
<p className="text-xs text-text-muted mt-1">
Plan: {organization.plan.name} {organization.plan.maxUsers} users
{t('planLabel', {
name: organization.plan.name,
maxUsers: organization.plan.maxUsers,
})}
</p>
)}
</div>
<ChevronRight className="h-5 w-5 icon-flat text-text-muted group-hover:text-primary transition-colors" />
</button>
);
};
};

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime';
@@ -10,19 +11,19 @@ interface ScheduleDayPickerProps {
label?: string;
}
const MONTH_LABELS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
const MONTH_KEYS = [
'monthJanuary',
'monthFebruary',
'monthMarch',
'monthApril',
'monthMay',
'monthJune',
'monthJuly',
'monthAugust',
'monthSeptember',
'monthOctober',
'monthNovember',
'monthDecember',
] as const;
function daysInMonth(year: number, month: number): number {
@@ -55,12 +56,14 @@ const selectClassName = `
* Calendar day navigator (arrows + year/month/day panel).
* Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms.
*/
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
export function ScheduleDayPicker({ value, onChange, label }: ScheduleDayPickerProps) {
const t = useTranslations('schedule');
const panelId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [panelOpen, setPanelOpen] = useState(false);
const normalizedValue = startOfLocalDay(value);
const resolvedLabel = label ?? t('defaultLabel');
const labelText = normalizedValue.toLocaleDateString(undefined, {
weekday: 'short',
@@ -108,13 +111,13 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
return (
<div ref={rootRef} className="relative w-full max-w-md">
<p className="text-sm font-medium text-text-secondary mb-2">{label}</p>
<p className="text-sm font-medium text-text-secondary mb-2">{resolvedLabel}</p>
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
<button
type="button"
onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Previous day"
aria-label={t('previousDay')}
>
<ChevronLeft className="h-4 w-4 icon-flat" />
</button>
@@ -138,7 +141,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
type="button"
onClick={() => onChange(addCalendarDays(normalizedValue, 1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Next day"
aria-label={t('nextDay')}
>
<ChevronRight className="h-4 w-4 icon-flat" />
</button>
@@ -148,7 +151,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
<div
id={panelId}
role="dialog"
aria-label="Choose schedule date"
aria-label={t('chooseDate')}
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
>
<div className="grid grid-cols-3 gap-2">
@@ -157,7 +160,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
htmlFor={`${panelId}-year`}
className="mb-1 block text-xs font-medium text-text-muted"
>
Year
{t('year')}
</label>
<div className="relative">
<select
@@ -186,7 +189,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
htmlFor={`${panelId}-month`}
className="mb-1 block text-xs font-medium text-text-muted"
>
Month
{t('month')}
</label>
<div className="relative">
<select
@@ -197,9 +200,9 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
}
className={selectClassName}
>
{MONTH_LABELS.map((name, index) => (
<option key={name} value={index}>
{name}
{MONTH_KEYS.map((key, index) => (
<option key={key} value={index}>
{t(key)}
</option>
))}
</select>
@@ -215,7 +218,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
htmlFor={`${panelId}-day`}
className="mb-1 block text-xs font-medium text-text-muted"
>
Day
{t('day')}
</label>
<div className="relative">
<select

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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