feat: enhance internationalization support across various components and pages

This commit is contained in:
estifanos
2026-08-12 12:30:22 +00:00
parent 31c52c02e0
commit 2fa55965e1
9 changed files with 182 additions and 37 deletions

View File

@@ -71,9 +71,11 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
? t(`review.events.${history.event}`, {
defaultValue: history.event,
})
: `${history.fromStatus ? STATUS_LABELS[history.fromStatus] : '—'}${
STATUS_LABELS[history.toStatus]
}`,
: `${
history.fromStatus
? t(`queue.statusValues.${history.fromStatus}`, STATUS_LABELS[history.fromStatus])
: '—'
}${t(`queue.statusValues.${history.toStatus}`, STATUS_LABELS[history.toStatus])}`,
detail: history.remark ?? undefined,
color: STATUS_COLORS[history.toStatus],
});

View File

@@ -80,7 +80,7 @@ export function DecisionBar({
{/* Left: where the application stands, and who has it. */}
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
{STATUS_LABELS[status]}
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge>
{assigneeName && (

View File

@@ -116,6 +116,7 @@ export function DocumentsTab({
}
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
const requirementByKey = new Map(requirements.map((r) => [r.key, r]));
const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
const completeness = mandatory.length
@@ -174,7 +175,7 @@ export function DocumentsTab({
with its own wrap, the name truncates cleanly instead. */}
<Group gap={6} wrap="wrap" align="center">
<Text size="sm" fw={500} truncate style={{ maxWidth: '100%' }}>
{attachment.documentKey}
{localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey}
</Text>
{verdict && (
<Tooltip
@@ -396,7 +397,11 @@ export function DocumentsTab({
onClose={() => setPreview(null)}
position="right"
size="xl"
title={preview?.documentKey}
title={
preview
? localized(requirementByKey.get(preview.documentKey)?.name) || preview.documentKey
: ''
}
// Focus is trapped and returned so keyboard users are not dropped at
// the top of the page when the drawer closes.
trapFocus
@@ -406,13 +411,13 @@ export function DocumentsTab({
isPdf ? (
<iframe
src={previewFile.url}
title={preview?.documentKey ?? 'document'}
title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ width: '100%', height: '80vh', border: 'none' }}
/>
) : isImage ? (
<img
src={previewFile.url}
alt={preview?.documentKey ?? 'document'}
alt={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ maxWidth: '100%' }}
/>
) : (

View File

@@ -144,10 +144,15 @@ export interface EligibilityRule {
* pass/fail line means the rule, the figure it was checked against, and the
* outcome are all on screen.
*/
/** Loose enough to accept i18next's real `t` structurally — see sla.ts for
* why this can't just be typed as `TFunction`. */
type Translate = (key: string, options?: unknown) => string;
export function evaluateEligibility(
application: LicenseApplication,
licenseType: LicenseType | undefined,
locale: string,
t: Translate,
): EligibilityRule[] {
const rules: EligibilityRule[] = [];
@@ -172,11 +177,18 @@ export function evaluateEligibility(
rules.push({
id: 'capital-threshold',
label: `Paid-up capital ≥ ${format(threshold)}`,
label: t('review.eligibilityRule.capitalThreshold', {
amount: format(threshold),
defaultValue: 'Paid-up capital ≥ {{amount}}',
}),
actual:
effective === undefined
? 'Not recorded'
: `${format(effective)}${verified === undefined ? ' (declared)' : ' (verified)'}`,
? t('review.eligibilityRule.notRecorded', 'Not recorded')
: `${format(effective)}${
verified === undefined
? t('review.eligibilityRule.declaredSuffix', ' (declared)')
: t('review.eligibilityRule.verifiedSuffix', ' (verified)')
}`,
// An unverified declaration is not evidence, so it reads as unknown
// rather than as a pass the officer never actually made.
status:
@@ -200,8 +212,10 @@ export function evaluateEligibility(
].includes(application.status);
rules.push({
id: 'inspection',
label: 'Physical inspection completed',
actual: inspected ? 'Recorded' : 'Not yet recorded',
label: t('review.eligibilityRule.inspectionCompleted', 'Physical inspection completed'),
actual: inspected
? t('review.eligibilityRule.recorded', 'Recorded')
: t('review.eligibilityRule.notYetRecorded', 'Not yet recorded'),
status: inspected ? 'pass' : 'unknown',
});
}

View File

@@ -442,7 +442,8 @@ export function LicenseQueuePage() {
{
header: t("queue.sla", "Age / SLA"),
cell: ({ row }) => {
const sla = computeSla(row.original, undefined, i18n.language);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
const sla = computeSla(row.original, undefined, i18n.language, (key, options) => t(key, options as any) as string);
return (
// Colour is never the only signal — the label says the same thing.
<Tooltip label={sla.tooltip} withArrow>

View File

@@ -82,10 +82,10 @@ type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
* that fills it in.
*/
const INSPECTION_CHECKLIST_ITEMS = [
{ key: 'office_premises', label: 'Office premises' },
{ key: 'storage_facilities', label: 'Warehouse / storage facilities' },
{ key: 'vehicles_equipment', label: 'Vehicles / equipment' },
{ key: 'safety_compliance', label: 'Safety & regulatory compliance' },
{ key: 'office_premises', labelKey: 'review.checklist.officePremises', fallback: 'Office premises' },
{ key: 'storage_facilities', labelKey: 'review.checklist.storageFacilities', fallback: 'Warehouse / storage facilities' },
{ key: 'vehicles_equipment', labelKey: 'review.checklist.vehiclesEquipment', fallback: 'Vehicles / equipment' },
{ key: 'safety_compliance', labelKey: 'review.checklist.safetyCompliance', fallback: 'Safety & regulatory compliance' },
] as const;
function buildChecklist(
@@ -93,7 +93,9 @@ function buildChecklist(
) {
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
key: item.key,
label: item.label,
// Persisted as-is (stable English), not the officer's display language —
// this is an audit record, not UI text.
label: item.fallback,
// Untouched rows default to PASS — the segmented control shows exactly
// that, so what the officer saw is what gets recorded.
outcome: outcomes[item.key] ?? 'PASS',
@@ -127,6 +129,13 @@ export function LicenseReviewPage() {
{ idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' },
{ skip: !data?.application.licenseTypeId },
);
// Staff role names are already bilingual on the config — the wire data only
// carries the role key (e.g. 'CAPTAIN'), so this is what turns it back into
// the label an officer reads.
const roleNameByKey = useMemo(
() => new Map(requirements?.staffRoleRequirements.map((r) => [r.roleKey, r.name]) ?? []),
[requirements],
);
const [completeReview] = useCompleteReviewMutation();
const [requestAdjustment] = useRequestAdjustmentMutation();
@@ -205,7 +214,7 @@ export function LicenseReviewPage() {
return {
key,
label: member
? `${member.roleKey}${member.fullName}`
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey}${member.fullName}`
: t('review.staffMember', 'Staff member'),
};
}
@@ -215,7 +224,7 @@ export function LicenseReviewPage() {
return { key, label: key };
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[flags, data?.staff, t],
[flags, data?.staff, t, localized, roleNameByKey],
);
const actions = useMemo(() => {
@@ -272,8 +281,10 @@ export function LicenseReviewPage() {
const app = data.application;
const status = app.status;
const presentation = presentationFor(app.licenseType?.key);
const sla = computeSla(app, undefined, i18n.language);
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
const sla = computeSla(app, undefined, i18n.language, (key, options) => t(key, options as any) as string);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language, (key, options) => t(key, options as any) as string);
const rawThreshold = app.licenseType?.capitalThreshold;
const threshold =
@@ -504,7 +515,7 @@ export function LicenseReviewPage() {
{app.applicationNumber}
</Text>
<Badge color={STATUS_COLORS[status]} variant="light">
{STATUS_LABELS[status]}
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge>
{app.adjustmentRound > 0 && (
<Badge color="orange" variant="light" size="sm">
@@ -543,7 +554,7 @@ export function LicenseReviewPage() {
<Stack gap={6}>
<SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} />
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
<SummaryRow label={t('review.kind', 'Kind')} value={app.kind} />
<SummaryRow label={t('review.kind', 'Kind')} value={t(`review.kindValues.${app.kind}`, app.kind)} />
<SummaryRow
label={t('review.submitted', 'Submitted')}
value={showDate(app.submittedAt)}
@@ -605,7 +616,7 @@ export function LicenseReviewPage() {
key={entry.id}
title={
<Text size="xs" fw={600}>
{STATUS_LABELS[entry.toStatus] ?? entry.toStatus}
{t(`queue.statusValues.${entry.toStatus}`, STATUS_LABELS[entry.toStatus] ?? entry.toStatus)}
</Text>
}
>
@@ -797,7 +808,7 @@ export function LicenseReviewPage() {
{data.staff.map((member) => (
<Table.Tr key={member.id}>
<Table.Td>
<Text size="xs">{member.roleKey}</Text>
<Text size="xs">{localized(roleNameByKey.get(member.roleKey)) || member.roleKey}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{member.fullName}</Text>
@@ -878,7 +889,11 @@ export function LicenseReviewPage() {
variant="light"
color={inspection.result === 'FAILED' ? 'red' : 'teal'}
>
{inspection.result ?? inspection.status}
{inspection.result === 'PASSED'
? t('review.passed', 'Passed')
: inspection.result === 'FAILED'
? t('review.failed', 'Failed')
: t(`review.inspectionStatus.${inspection.status}`, inspection.status)}
</Badge>
</Group>
))}
@@ -984,7 +999,7 @@ export function LicenseReviewPage() {
<Stack gap={6}>
{INSPECTION_CHECKLIST_ITEMS.map((item) => (
<Group key={item.key} justify="space-between" wrap="nowrap">
<Text size="sm">{item.label}</Text>
<Text size="sm">{t(item.labelKey, item.fallback)}</Text>
<SegmentedControl
size="xs"
value={checklist[item.key] ?? 'PASS'}

View File

@@ -18,6 +18,19 @@ export interface SlaState {
ratio: number;
}
/** Loose enough to accept i18next's real `t` structurally; callers pass that
* one, and a plain function falls back to English by resolving
* `defaultValue` itself, so `computeSla` still works with no i18n context
* (the CSV export). */
export type SlaTranslate = (key: string, options?: unknown) => string;
function tr(t: SlaTranslate | undefined, key: string, options: Record<string, unknown> | string): string {
if (t) return t(key, options);
if (typeof options === 'string') return options;
const template = String(options['defaultValue'] ?? '');
return template.replace(/\{\{(\w+)\}\}/g, (_, name) => String(options[name] ?? ''));
}
function formatDuration(ms: number): string {
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
if (hours < 1) return '<1h';
@@ -37,6 +50,7 @@ export function computeSla(
application: LicenseApplication,
now: number = Date.now(),
language = 'en',
t?: SlaTranslate,
): SlaState {
const slaHours = application.licenseType?.slaHours;
const submittedAt = application.submittedAt;
@@ -46,7 +60,7 @@ export function computeSla(
state: 'untracked',
color: 'gray',
label: '—',
tooltip: 'No turnaround target is set for this licence type.',
tooltip: tr(t, 'review.sla.untracked', 'No turnaround target is set for this licence type.'),
ratio: 0,
};
}
@@ -56,15 +70,23 @@ export function computeSla(
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
const window = slaHours * HOUR_MS;
const ratio = Math.min(Math.max(elapsed / window, 0), 1);
const targetText = `Target ${slaHours}h from submission (${dateDisplayer(target, language)})`;
const targetText = tr(t, 'review.sla.target', {
hours: slaHours,
date: dateDisplayer(target, language),
defaultValue: 'Target {{hours}}h from submission ({{date}})',
});
if (application.decidedAt) {
const met = elapsed <= window;
return {
state: 'decided',
color: met ? 'teal' : 'gray',
label: met ? 'Met' : 'Missed',
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`,
label: tr(t, met ? 'review.sla.met' : 'review.sla.missed', met ? 'Met' : 'Missed'),
tooltip: tr(t, 'review.sla.decidedIn', {
duration: formatDuration(elapsed),
target: targetText,
defaultValue: 'Decided in {{duration}}. {{target}}',
}),
ratio,
};
}
@@ -74,8 +96,15 @@ export function computeSla(
return {
state: 'breached',
color: 'red',
label: `Overdue ${formatDuration(remaining)}`,
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`,
label: tr(t, 'review.sla.overdue', {
duration: formatDuration(remaining),
defaultValue: 'Overdue {{duration}}',
}),
tooltip: tr(t, 'review.sla.overdueBy', {
duration: formatDuration(remaining),
target: targetText,
defaultValue: 'Overdue by {{duration}}. {{target}}',
}),
ratio: 1,
};
}
@@ -84,8 +113,15 @@ export function computeSla(
return {
state: used >= WARNING_RATIO ? 'warning' : 'ok',
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
label: `${formatDuration(remaining)} left`,
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`,
label: tr(t, 'review.sla.left', {
duration: formatDuration(remaining),
defaultValue: '{{duration}} left',
}),
tooltip: tr(t, 'review.sla.remaining', {
duration: formatDuration(remaining),
target: targetText,
defaultValue: '{{duration}} remaining. {{target}}',
}),
ratio,
};
}

View File

@@ -1016,6 +1016,42 @@ export const am: Translations = {
noFile: "ፋይል የለም",
noFileUploaded: "እስካሁን ምንም አልተጫነም",
noInlinePreview: "ይህ የፋይል ዓይነት በአሳሹ ውስጥ ቅድመ እይታ አይደረግም።",
previewFallback: "ሰነድ",
},
inspectionStatus: {
SCHEDULED: "ተይዟል",
COMPLETED: "ተጠናቋል",
CANCELLED: "ተሰርዟል",
},
kindValues: {
NEW: "አዲስ",
RENEWAL: "እድሳት",
},
checklist: {
officePremises: "የቢሮ ግቢ",
storageFacilities: "መጋዘን / የማከማቻ ተቋማት",
vehiclesEquipment: "ተሽከርካሪዎች / መሳሪያዎች",
safetyCompliance: "ደህንነት እና ደንብ ማክበር",
},
eligibilityRule: {
capitalThreshold: "የተከፈለ ካፒታል ≥ {{amount}}",
notRecorded: "አልተመዘገበም",
declaredSuffix: " (የተገለጸ)",
verifiedSuffix: " (የተረጋገጠ)",
inspectionCompleted: "አካላዊ ምርመራ ተጠናቋል",
recorded: "ተመዝግቧል",
notYetRecorded: "ገና አልተመዘገበም",
},
sla: {
untracked: "ለዚህ የፈቃድ ዓይነት የማጠናቀቂያ ኢላማ አልተቀመጠም።",
target: "ኢላማ ከቀረበ በኋላ {{hours}} ሰዓት ({{date}})",
met: "ተሟልቷል",
missed: "አልተሟላም",
decidedIn: "በ{{duration}} ውስጥ ተወስኗል። {{target}}",
overdue: "{{duration}} ዘግይቷል",
overdueBy: "በ{{duration}} ዘግይቷል። {{target}}",
left: "{{duration}} ቀርቷል",
remaining: "{{duration}} ቀርቷል። {{target}}",
},
done: {
completeReview: "ግምገማ ተጠናቋል",

View File

@@ -1012,6 +1012,42 @@ export const en = {
noFile: 'No file',
noFileUploaded: 'Nothing uploaded yet',
noInlinePreview: 'This file type cannot be previewed in the browser.',
previewFallback: 'document',
},
inspectionStatus: {
SCHEDULED: 'Scheduled',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
},
kindValues: {
NEW: 'New',
RENEWAL: 'Renewal',
},
checklist: {
officePremises: 'Office premises',
storageFacilities: 'Warehouse / storage facilities',
vehiclesEquipment: 'Vehicles / equipment',
safetyCompliance: 'Safety & regulatory compliance',
},
eligibilityRule: {
capitalThreshold: 'Paid-up capital ≥ {{amount}}',
notRecorded: 'Not recorded',
declaredSuffix: ' (declared)',
verifiedSuffix: ' (verified)',
inspectionCompleted: 'Physical inspection completed',
recorded: 'Recorded',
notYetRecorded: 'Not yet recorded',
},
sla: {
untracked: 'No turnaround target is set for this licence type.',
target: 'Target {{hours}}h from submission ({{date}})',
met: 'Met',
missed: 'Missed',
decidedIn: 'Decided in {{duration}}. {{target}}',
overdue: 'Overdue {{duration}}',
overdueBy: 'Overdue by {{duration}}. {{target}}',
left: '{{duration}} left',
remaining: '{{duration}} remaining. {{target}}',
},
done: {
completeReview: 'Review completed',