feature: localization's first implmentation done. all frontend hardcoded text is now localized.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user