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> = {