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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import {
firstAccessibleDashboardPath,
@@ -16,7 +17,7 @@ import {
resolveStaffFeatureLabel,
formatAccessSummary,
type FeaturePermState,
} from '../../../components/staff/staff-permission-form';
} from '@/components/staff/staff-permission-form';
import {
StaffWorkingHoursStep,
createDefaultWorkingHoursState,
@@ -90,6 +91,9 @@ function PermissionGrid({
disabled?: boolean;
organizationType?: 'CLINIC' | 'LAB';
}) {
const t = useTranslations('staff');
const tFeatures = useTranslations('staff.features');
const setRead = (editKey: string, read: boolean) => {
const cur = state[editKey] ?? { read: false, edit: false };
onChange({
@@ -116,19 +120,19 @@ function PermissionGrid({
className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3"
>
<span className="text-sm font-medium text-text-primary">
{resolveStaffFeatureLabel(g, organizationType)}
{resolveStaffFeatureLabel(g, organizationType, tFeatures)}
</span>
<div className="flex flex-col gap-2.5 pl-0.5">
<Checkbox
checked={cell.read}
disabled={disabled}
label="View"
label={t('permissionView')}
onChange={(v) => setRead(g.edit, v)}
/>
<Checkbox
checked={cell.edit}
disabled={disabled}
label="Edit"
label={t('permissionEdit')}
onChange={(v) => setEdit(g.edit, v)}
/>
</div>
@@ -141,6 +145,10 @@ function PermissionGrid({
export default function StaffPage() {
const router = useRouter();
const t = useTranslations('staff');
const tCommon = useTranslations('common');
const tFeatures = useTranslations('staff.features');
const tWorkingHours = useTranslations('staff.workingHours');
const { currentOrganization, user } = useAuth();
const [members, setMembers] = useState<StaffMemberDto[]>([]);
const [seats, setSeats] = useState<{
@@ -216,11 +224,11 @@ export default function StaffPage() {
setMembers(res.data.members);
setSeats(res.data.seats);
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to load staff.'));
toast.showError(formatApiErrorMessage(e, t('errorLoadStaff')));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
if (!currentOrganization?.id) return;
@@ -291,7 +299,7 @@ export default function StaffPage() {
await load();
}
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.'));
toast.showError(formatApiErrorMessage(e, t('errorCopyInvite')));
} finally {
setCopyingInviteMembershipId(null);
}
@@ -312,7 +320,7 @@ export default function StaffPage() {
if (!includeHours || !inviteHasTreatmentEdit) {
return;
}
const validationError = validateEditorDays(inviteWorkingHoursDays);
const validationError = validateEditorDays(inviteWorkingHoursDays, tWorkingHours);
if (validationError) {
throw new Error(validationError);
}
@@ -333,7 +341,7 @@ export default function StaffPage() {
const displayEmail = inviteEmail.trim();
try {
if (includeWorkingHours && inviteHasTreatmentEdit) {
const validationError = validateEditorDays(inviteWorkingHoursDays);
const validationError = validateEditorDays(inviteWorkingHoursDays, tWorkingHours);
if (validationError) {
toast.showError(validationError);
return;
@@ -374,7 +382,7 @@ export default function StaffPage() {
resetInviteForm();
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.'));
toast.showError(formatApiErrorMessage(e, t('errorSendInvite')));
} finally {
setInviteLoading(false);
}
@@ -397,7 +405,7 @@ export default function StaffPage() {
setEditWorkingHoursDays(state.days);
setEditAutoRepeatWeekly(state.autoRepeatWeekly);
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to load working hours.'));
toast.showError(formatApiErrorMessage(e, t('errorLoadWorkingHours')));
} finally {
setEditLoadingWorkingHours(false);
}
@@ -406,7 +414,7 @@ export default function StaffPage() {
async function submitEdit() {
if (!editing) return;
if (editHasTreatmentEdit) {
const validationError = validateEditorDays(editWorkingHoursDays);
const validationError = validateEditorDays(editWorkingHoursDays, tWorkingHours);
if (validationError) {
toast.showError(validationError);
return;
@@ -431,19 +439,19 @@ export default function StaffPage() {
permissionNames: permissionNamesFromFeatureState(editPerms),
});
toast.showSuccess('Member updated.');
toast.showSuccess(t('successMemberUpdated'));
setEditing(null);
setEditStep(1);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to update member.'));
toast.showError(formatApiErrorMessage(e, t('errorUpdateMember')));
} finally {
setEditLoading(false);
}
}
function handleDeleteMember() {
toast.showError('Delete is not implemented yet.');
toast.showError(t('errorDeleteNotImplemented'));
}
async function confirmDisableMember() {
@@ -453,11 +461,11 @@ export default function StaffPage() {
toast.setError('');
try {
await staffApi.disableMember(disableTarget.id);
toast.showSuccess(`${disableTarget.name} was disabled. A seat is now available.`);
toast.showSuccess(t('successMemberDisabled', { name: disableTarget.name }));
setDisableTarget(null);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to disable member.'));
toast.showError(formatApiErrorMessage(e, t('errorDisableMember')));
} finally {
setDisablingMembershipId(null);
}
@@ -470,11 +478,11 @@ export default function StaffPage() {
toast.setError('');
try {
await staffApi.enableMember(enableTarget.id);
toast.showSuccess(`${enableTarget.name} was enabled and can sign in again.`);
toast.showSuccess(t('successMemberEnabled', { name: enableTarget.name }));
setEnableTarget(null);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to enable member.'));
toast.showError(formatApiErrorMessage(e, t('errorEnableMember')));
} finally {
setEnablingMembershipId(null);
}
@@ -482,7 +490,7 @@ export default function StaffPage() {
if (!currentOrganization || !canViewStaff(currentOrganization)) {
return (
<p className="text-sm text-text-secondary">Redirecting</p>
<p className="text-sm text-text-secondary">{t('redirecting')}</p>
);
}
@@ -490,10 +498,8 @@ export default function StaffPage() {
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">Staff Management</h1>
<p className="text-sm text-text-secondary mt-1">
Invite teammates, set tab access, and stay within your plan seat limit.
</p>
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary mt-1">{t('subtitle')}</p>
</div>
<Button
size="sm"
@@ -505,9 +511,9 @@ export default function StaffPage() {
}}
disabled={!canEdit || atSeatLimit}
className="shrink-0"
title={!canEdit ? 'Read-only access for this organization.' : undefined}
title={!canEdit ? tCommon('readOnlyAccess') : undefined}
>
Invite member
{t('inviteMember')}
</Button>
</div>
@@ -515,16 +521,14 @@ export default function StaffPage() {
{seats && (
<p className="text-sm text-text-secondary">
Seats:{' '}
{t('seatsLabel')}{' '}
<span className="text-text-primary font-medium">
{seats.used}
{seats.unlimited ? ' (unlimited plan)' : ` / ${seats.limit}`}
{seats.unlimited ? ` ${t('unlimitedPlan')}` : ` / ${seats.limit}`}
</span>
{!seats.unlimited && atSeatLimit && (
<span className="text-amber-600 dark:text-amber-400 ml-2">
{hasActivePlan
? 'Plan seat limit reached for this organization.'
: 'No active plan selected for this organization. Choose a subscription plan to invite members.'}
{hasActivePlan ? t('seatLimitReached') : t('noActivePlan')}
</span>
)}
</p>
@@ -535,7 +539,7 @@ export default function StaffPage() {
<button
type="button"
className="absolute right-2 top-2 p-1.5 rounded-[var(--radius-sm)] text-text-muted hover:text-text-primary hover:bg-background-card/80"
aria-label="Dismiss"
aria-label={tCommon('dismiss')}
onClick={() => {
setLastInviteInfo(null);
}}
@@ -543,15 +547,15 @@ export default function StaffPage() {
<X className="w-4 h-4" />
</button>
<p className="text-sm text-text-primary pr-6">
<span className="font-medium">{lastInviteInfo.name}</span> ({lastInviteInfo.email}) was invited.
{t('successInvited', { name: lastInviteInfo.name, email: lastInviteInfo.email })}
{lastInviteInfo.invitationStatus === 'PENDING'
? ' Invitation is pending until they open the link, set a password, and log in.'
: ' Invitation was accepted immediately.'}
? ` ${t('invitedPending')}`
: ` ${t('invitedAccepted')}`}
</p>
{lastInviteInfo.invitationStatus === 'PENDING' && (
<div className="space-y-2 pt-1 border-t border-border/60">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wide">
Invite link
{t('inviteLinkHeading')}
</p>
{lastInviteInfo.invitationUrl && (
<code className="block text-sm px-2 py-1.5 rounded-[var(--radius-sm)] bg-background-card border border-border font-mono break-all">
@@ -591,36 +595,36 @@ export default function StaffPage() {
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.'));
toast.showError(formatApiErrorMessage(e, t('errorCopyInvite')));
} finally {
setCopyingInviteMembershipId(null);
}
})();
}}
>
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
{copiedInviteMembershipId === lastInviteInfo.membershipId
? tCommon('copied')
: tCommon('copyLink')}
</Button>
<p className="text-xs text-text-muted">
Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.
</p>
<p className="text-xs text-text-muted">{t('shareLinkHint')}</p>
</div>
)}
</div>
)}
{loading ? (
<p className="text-sm text-text-secondary">Loading team</p>
<p className="text-sm text-text-secondary">{t('loadingTeam')}</p>
) : (
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Role</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Access</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableName')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableEmail')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableRole')}</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableStatus')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableAccess')}</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider w-36">
Action
{t('tableAction')}
</th>
</tr>
}
@@ -632,28 +636,28 @@ export default function StaffPage() {
<td className="px-6 py-1.5 text-sm text-text-secondary">{m.email}</td>
<td className="px-6 py-1.5 text-sm">
{m.isOwner ? (
<span className="text-primary font-medium">Owner</span>
<span className="text-primary font-medium">{t('roleOwner')}</span>
) : (
<span className="text-text-secondary">Staff</span>
<span className="text-text-secondary">{t('roleStaff')}</span>
)}
</td>
<td className="px-6 py-1.5 align-middle text-center">
{m.isOwner || m.invitationStatus === 'ACTIVE' ? (
<Badge variant="success">Active</Badge>
<Badge variant="success">{t('statusActive')}</Badge>
) : m.invitationStatus === 'PENDING' ? (
<Badge variant="warning">Pending</Badge>
<Badge variant="warning">{t('statusPending')}</Badge>
) : m.invitationStatus === 'DISABLED' ? (
<Badge variant="default">Disabled</Badge>
<Badge variant="default">{t('statusDisabled')}</Badge>
) : (
<Badge variant="danger">Expired</Badge>
<Badge variant="danger">{t('statusExpired')}</Badge>
)}
</td>
<td className="px-6 py-1.5 text-sm text-text-secondary max-w-md">
{m.isOwner ? (
<span className="text-text-muted">All features</span>
<span className="text-text-muted">{t('allFeatures')}</span>
) : (
<span className="line-clamp-3 text-sm leading-relaxed">
{formatAccessSummary(m.permissions, currentOrganization?.type)}
{formatAccessSummary(m.permissions, currentOrganization?.type, tFeatures)}
</span>
)}
</td>
@@ -666,8 +670,8 @@ export default function StaffPage() {
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
disabled={copyingInviteMembershipId === m.id}
onClick={() => void copyStaffInviteLink(m)}
aria-label="Copy invitation link"
title="Copy invitation link (generates a new link if needed)"
aria-label={t('copyInviteLink')}
title={t('copyInviteLinkTitle')}
>
{copiedInviteMembershipId === m.id ? (
<Check className="w-4 h-4" />
@@ -684,9 +688,9 @@ export default function StaffPage() {
? 'text-text-secondary hover:bg-background-card/80 hover:text-primary'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Enable member"
aria-label={t('enableMemberAria')}
disabled={!canEdit || enablingMembershipId === m.id}
title="Enable member (uses a seat)"
title={t('enableMemberTitle')}
onClick={() => {
if (!canEdit) return;
setEnableTarget(m);
@@ -703,9 +707,9 @@ export default function StaffPage() {
? 'text-text-secondary hover:bg-background-card/80 hover:text-amber-600'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Disable member"
aria-label={t('disableMemberAria')}
disabled={!canEdit || disablingMembershipId === m.id}
title="Disable member (frees a seat)"
title={t('disableMemberTitle')}
onClick={() => {
if (!canEdit) return;
setDisableTarget(m);
@@ -721,7 +725,7 @@ export default function StaffPage() {
? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Edit member"
aria-label={t('editMemberAria')}
disabled={!canEdit}
onClick={() => {
if (!canEdit) return;
@@ -737,9 +741,9 @@ export default function StaffPage() {
? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Delete member"
aria-label={t('deleteMemberAria')}
disabled={!canEdit}
title="Delete member (not implemented)"
title={t('deleteMemberTitle')}
onClick={() => {
if (!canEdit) return;
handleDeleteMember();
@@ -768,10 +772,10 @@ export default function StaffPage() {
<div className="flex items-start justify-between gap-3">
<div>
<h2 id="invite-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Invite team member
{t('inviteModalTitle')}
</h2>
{inviteHasTreatmentEdit && (
<p className="text-xs text-text-muted mt-1">Step {inviteStep} of 2</p>
<p className="text-xs text-text-muted mt-1">{t('stepOf', { step: inviteStep })}</p>
)}
</div>
<DialogCloseButton
@@ -785,19 +789,19 @@ export default function StaffPage() {
{inviteStep === 1 ? (
<>
<Input
label="Email"
label={t('labelEmail')}
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
autoComplete="off"
/>
<Input
label="Display name"
label={t('labelDisplayName')}
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<p className="text-sm font-medium text-text-secondary mb-2">{t('tabAccess')}</p>
<PermissionGrid
state={invitePerms}
onChange={setInvitePerms}
@@ -829,7 +833,7 @@ export default function StaffPage() {
resetInviteForm();
}}
>
{inviteStep === 2 ? 'Back' : 'Cancel'}
{inviteStep === 2 ? tCommon('back') : tCommon('cancel')}
</Button>
{inviteStep === 1 ? (
inviteHasTreatmentEdit ? (
@@ -838,7 +842,7 @@ export default function StaffPage() {
disabled={!inviteEmail.trim() || !inviteName.trim()}
onClick={() => setInviteStep(2)}
>
Next
{tCommon('next')}
</Button>
) : (
<Button
@@ -847,7 +851,7 @@ export default function StaffPage() {
disabled={!inviteEmail.trim() || !inviteName.trim()}
onClick={() => void submitInvite(false)}
>
Send invite
{t('sendInvite')}
</Button>
)
) : (
@@ -858,7 +862,7 @@ export default function StaffPage() {
isLoading={inviteLoading}
onClick={() => void submitInvite(false)}
>
Skip for now
{t('skipForNow')}
</Button>
<Button
type="button"
@@ -866,7 +870,7 @@ export default function StaffPage() {
disabled={Boolean(inviteHoursValidationError)}
onClick={() => void submitInvite(true)}
>
Send invite
{t('sendInvite')}
</Button>
</>
)}
@@ -885,7 +889,7 @@ export default function StaffPage() {
>
<div className="flex items-start justify-between gap-2">
<h2 id="enable-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Enable team member
{t('enableModalTitle')}
</h2>
<DialogCloseButton
onClick={() => {
@@ -895,22 +899,15 @@ export default function StaffPage() {
/>
</div>
<p className="text-sm text-text-secondary">
Enable <span className="font-medium text-text-primary">{enableTarget.name}</span> (
{enableTarget.email})?
{t('enableConfirm', { name: enableTarget.name, email: enableTarget.email })}
</p>
<ul className="text-sm text-text-secondary space-y-2 list-disc pl-5">
<li>They can sign in to this organization again with their existing account.</li>
<li>No new invitation is sent and no data was removed while they were disabled.</li>
<li>
Enabling uses <span className="text-text-primary font-medium">one seat</span> on your
plan.
</li>
<li>{t('enableBullet1')}</li>
<li>{t('enableBullet2')}</li>
<li>{t('enableBullet3')}</li>
</ul>
{!hasAvailableSeat && (
<p className="text-sm text-amber-600 dark:text-amber-400">
No seats are available. Disable another member or upgrade your plan before enabling
this person.
</p>
<p className="text-sm text-amber-600 dark:text-amber-400">{t('noSeatsAvailable')}</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button
@@ -919,7 +916,7 @@ export default function StaffPage() {
disabled={Boolean(enablingMembershipId)}
onClick={() => setEnableTarget(null)}
>
Cancel
{tCommon('cancel')}
</Button>
<Button
type="button"
@@ -928,7 +925,7 @@ export default function StaffPage() {
disabled={Boolean(enablingMembershipId) || !hasAvailableSeat}
onClick={() => void confirmEnableMember()}
>
Enable member
{t('enableMemberButton')}
</Button>
</div>
</div>
@@ -945,7 +942,7 @@ export default function StaffPage() {
>
<div className="flex items-start justify-between gap-2">
<h2 id="disable-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Disable team member
{t('disableModalTitle')}
</h2>
<DialogCloseButton
onClick={() => {
@@ -955,16 +952,12 @@ export default function StaffPage() {
/>
</div>
<p className="text-sm text-text-secondary">
Disable <span className="font-medium text-text-primary">{disableTarget.name}</span> (
{disableTarget.email})?
{t('disableConfirm', { name: disableTarget.name, email: disableTarget.email })}
</p>
<ul className="text-sm text-text-secondary space-y-2 list-disc pl-5">
<li>They will not be able to sign in to this organization.</li>
<li>No data will be removed.</li>
<li>
Disabling frees <span className="text-text-primary font-medium">one seat</span> on your
plan so you can invite someone else.
</li>
<li>{t('disableBullet1')}</li>
<li>{t('disableBullet2')}</li>
<li>{t('disableBullet3')}</li>
</ul>
<div className="flex justify-end gap-2 pt-1">
<Button
@@ -973,7 +966,7 @@ export default function StaffPage() {
disabled={Boolean(disablingMembershipId)}
onClick={() => setDisableTarget(null)}
>
Cancel
{tCommon('cancel')}
</Button>
<Button
type="button"
@@ -982,7 +975,7 @@ export default function StaffPage() {
disabled={Boolean(disablingMembershipId)}
onClick={() => void confirmDisableMember()}
>
Disable member
{t('disableMemberButton')}
</Button>
</div>
</div>
@@ -998,9 +991,9 @@ export default function StaffPage() {
>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-text-primary pr-2">Edit member</h2>
<h2 className="text-lg font-semibold text-text-primary pr-2">{t('editModalTitle')}</h2>
{editHasTreatmentEdit && (
<p className="text-xs text-text-muted mt-1">Step {editStep} of 2</p>
<p className="text-xs text-text-muted mt-1">{t('stepOf', { step: editStep })}</p>
)}
</div>
<DialogCloseButton
@@ -1015,12 +1008,12 @@ export default function StaffPage() {
{editStep === 1 ? (
<>
<Input
label="Display name"
label={t('labelDisplayName')}
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<p className="text-sm font-medium text-text-secondary mb-2">{t('tabAccess')}</p>
<PermissionGrid
state={editPerms}
onChange={setEditPerms}
@@ -1029,7 +1022,7 @@ export default function StaffPage() {
</div>
</>
) : editLoadingWorkingHours ? (
<p className="text-sm text-text-secondary">Loading working hours</p>
<p className="text-sm text-text-secondary">{t('loadingWorkingHours')}</p>
) : (
<StaffWorkingHoursStep
days={editWorkingHoursDays}
@@ -1054,16 +1047,16 @@ export default function StaffPage() {
setEditStep(1);
}}
>
{editStep === 2 ? 'Back' : 'Cancel'}
{editStep === 2 ? tCommon('back') : tCommon('cancel')}
</Button>
{editStep === 1 ? (
editHasTreatmentEdit ? (
<Button type="button" onClick={() => setEditStep(2)}>
Next
{tCommon('next')}
</Button>
) : (
<Button type="button" isLoading={editLoading} onClick={() => void submitEdit()}>
Save
{tCommon('save')}
</Button>
)
) : (
@@ -1073,7 +1066,7 @@ export default function StaffPage() {
disabled={Boolean(editHoursValidationError)}
onClick={() => void submitEdit()}
>
Save
{tCommon('save')}
</Button>
)}
</div>