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,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor';
import {
blocksFromEditorDays,
@@ -27,18 +28,17 @@ export function StaffWorkingHoursStep({
onValidationChange,
disabled,
}: StaffWorkingHoursStepProps) {
const t = useTranslations('staff.workingHours');
useEffect(() => {
onValidationChange?.(validateEditorDays(days));
}, [days, onValidationChange]);
onValidationChange?.(validateEditorDays(days, t));
}, [days, onValidationChange, t]);
return (
<div className="space-y-3">
<div className="rounded-[var(--radius-md)] border border-primary/25 bg-primary/5 px-3 py-3">
<p className="text-sm text-text-primary font-medium">Working hours recommended</p>
<p className="text-sm text-text-secondary mt-1">
Staff with treatment edit access appear as provider columns in Appointments. Set their
weekly hours so the schedule grid shows the right bookable times.
</p>
<p className="text-sm text-text-primary font-medium">{t('recommendedTitle')}</p>
<p className="text-sm text-text-secondary mt-1">{t('recommendedBody')}</p>
</div>
<WorkingHoursEditor
days={days}

View File

@@ -1,11 +1,12 @@
'use client';
import { Plus, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import {
MINUTES_PER_DAY,
WEEKDAY_LABELS,
WEEKDAY_KEYS,
minutesToTimeInput,
timeInputToMinutes,
type WorkingHoursEditorDay,
@@ -29,6 +30,8 @@ export function WorkingHoursEditor({
onAutoRepeatWeeklyChange,
disabled = false,
}: WorkingHoursEditorProps) {
const t = useTranslations('staff.workingHours');
function updateDay(dayOfWeek: number, patch: Partial<WorkingHoursEditorDay>) {
onDaysChange(
days.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)),
@@ -71,10 +74,7 @@ export function WorkingHoursEditor({
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">
Set weekly working hours for this provider. The appointments grid uses these hours to show
bookable time slots.
</p>
<p className="text-sm text-text-secondary">{t('intro')}</p>
<div className="space-y-3">
{days.map((day) => (
@@ -84,12 +84,12 @@ export function WorkingHoursEditor({
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-text-primary w-10">
{WEEKDAY_LABELS[day.dayOfWeek]}
{t(WEEKDAY_KEYS[day.dayOfWeek])}
</span>
<Checkbox
checked={day.isWorking}
disabled={disabled}
label="Working day"
label={t('workingDay')}
onChange={(checked) => {
updateDay(day.dayOfWeek, {
isWorking: checked,
@@ -108,7 +108,7 @@ export function WorkingHoursEditor({
{day.shifts.map((shift, shiftIndex) => (
<div key={shiftIndex} className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-xs text-text-muted mb-1">Start</label>
<label className="block text-xs text-text-muted mb-1">{t('start')}</label>
<input
type="time"
step={300}
@@ -121,7 +121,7 @@ export function WorkingHoursEditor({
/>
</div>
<div className="flex-1">
<label className="block text-xs text-text-muted mb-1">End</label>
<label className="block text-xs text-text-muted mb-1">{t('end')}</label>
<input
type="time"
step={300}
@@ -137,7 +137,7 @@ export function WorkingHoursEditor({
type="button"
disabled={disabled || day.shifts.length <= 1}
className="p-2 rounded-md text-text-muted hover:text-red-500 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Remove shift"
aria-label={t('removeShift')}
onClick={() => removeShift(day.dayOfWeek, shiftIndex)}
>
<Trash2 className="w-4 h-4" />
@@ -152,7 +152,7 @@ export function WorkingHoursEditor({
onClick={() => addShift(day.dayOfWeek)}
>
<Plus className="w-3.5 h-3.5 mr-1" />
Add shift
{t('addShift')}
</Button>
</div>
)}
@@ -163,7 +163,7 @@ export function WorkingHoursEditor({
<Checkbox
checked={autoRepeatWeekly}
disabled={disabled}
label="Repeat these hours at the start of each week (copy forward on Monday)"
label={t('autoRepeatWeekly')}
onChange={onAutoRepeatWeeklyChange}
/>
</div>

View File

@@ -4,27 +4,30 @@
*/
export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Organizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' },
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
{ labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' },
{ labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
{ labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
] as const;
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
export type OrgType = 'CLINIC' | 'LAB' | null | undefined;
type StaffFeaturesTranslate = (key: string) => string;
export function resolveStaffFeatureLabel(
group: (typeof STAFF_FEATURE_GROUPS)[number],
organizationType: OrgType,
t: StaffFeaturesTranslate,
): string {
if (group.read === 'TAB_ORGANIZATIONS_READ') {
return organizationType === 'LAB' ? 'Clinics' : 'Labs';
return organizationType === 'LAB' ? t('featureClinics') : t('featureLabs');
}
return group.label;
return t(group.labelKey);
}
export function emptyFeaturePermissionState(): FeaturePermState {
@@ -64,17 +67,18 @@ export function featureStateHasTreatmentEdit(state: FeaturePermState): boolean {
/** Human-readable access for the team table — feature name, or "Feature (Read only)" */
export function formatAccessSummary(
permissionNames: string[] | null | undefined,
organizationType?: OrgType,
organizationType: OrgType,
t: StaffFeaturesTranslate,
): string {
if (!permissionNames?.length) return 'No tab access';
if (!permissionNames?.length) return t('noTabAccess');
const set = new Set(permissionNames);
const parts: string[] = [];
for (const g of STAFF_FEATURE_GROUPS) {
const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit;
if (!hasRead) continue;
const label = resolveStaffFeatureLabel(g, organizationType);
parts.push(hasEdit ? label : `${label} (Read only)`);
const label = resolveStaffFeatureLabel(g, organizationType, t);
parts.push(hasEdit ? label : `${label} ${t('readOnlySuffix')}`);
}
return parts.length ? parts.join(' · ') : 'No tab access';
return parts.length ? parts.join(' · ') : t('noTabAccess');
}

View File

@@ -1,7 +1,17 @@
export const MINUTES_PER_DAY = 24 * 60;
export const SCHEDULE_SLOT_MINUTES = 15;
export const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const;
export const WEEKDAY_KEYS = [
'weekdayMon',
'weekdayTue',
'weekdayWed',
'weekdayThu',
'weekdayFri',
'weekdaySat',
'weekdaySun',
] as const;
type WorkingHoursTranslate = (key: string, values?: { day: string }) => string;
export type WorkingHoursBlock = {
dayOfWeek: number;
@@ -46,7 +56,7 @@ export function formatMinuteLabel(minute: number): string {
}
export function emptyWorkingHoursEditorDays(): WorkingHoursEditorDay[] {
return WEEKDAY_LABELS.map((_, dayOfWeek) => ({
return WEEKDAY_KEYS.map((_, dayOfWeek) => ({
dayOfWeek,
isWorking: false,
shifts: [{ startMinute: 9 * 60, endMinute: 17 * 60 }],
@@ -61,7 +71,7 @@ export function editorDaysFromBlocks(blocks: WorkingHoursBlock[]): WorkingHoursE
byDay.set(block.dayOfWeek, list);
}
return WEEKDAY_LABELS.map((_, dayOfWeek) => {
return WEEKDAY_KEYS.map((_, dayOfWeek) => {
const shifts = (byDay.get(dayOfWeek) ?? []).sort(
(a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute,
);
@@ -89,21 +99,25 @@ export function blocksFromEditorDays(days: WorkingHoursEditorDay[]): WorkingHour
return blocks;
}
export function validateEditorDays(days: WorkingHoursEditorDay[]): string | null {
export function validateEditorDays(
days: WorkingHoursEditorDay[],
t: WorkingHoursTranslate,
): string | null {
for (const day of days) {
if (!day.isWorking) continue;
const dayLabel = t(WEEKDAY_KEYS[day.dayOfWeek]);
if (day.shifts.length === 0) {
return `${WEEKDAY_LABELS[day.dayOfWeek]} needs at least one shift or should be marked off.`;
return t('validationNeedsShift', { day: dayLabel });
}
const sorted = [...day.shifts].sort((a, b) => a.startMinute - b.startMinute);
for (const shift of sorted) {
if (shift.endMinute <= shift.startMinute) {
return `${WEEKDAY_LABELS[day.dayOfWeek]} shift end time must be after start time.`;
return t('validationEndAfterStart', { day: dayLabel });
}
}
for (let i = 1; i < sorted.length; i += 1) {
if (sorted[i].startMinute < sorted[i - 1].endMinute) {
return `${WEEKDAY_LABELS[day.dayOfWeek]} shifts cannot overlap.`;
return t('validationOverlap', { day: dayLabel });
}
}
}