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}`, { ? t(`review.events.${history.event}`, {
defaultValue: 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, detail: history.remark ?? undefined,
color: STATUS_COLORS[history.toStatus], color: STATUS_COLORS[history.toStatus],
}); });

View File

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

View File

@@ -116,6 +116,7 @@ export function DocumentsTab({
} }
const uploadedKeys = new Set(attachments.map((a) => a.documentKey)); 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 mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key)); const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
const completeness = mandatory.length const completeness = mandatory.length
@@ -174,7 +175,7 @@ export function DocumentsTab({
with its own wrap, the name truncates cleanly instead. */} with its own wrap, the name truncates cleanly instead. */}
<Group gap={6} wrap="wrap" align="center"> <Group gap={6} wrap="wrap" align="center">
<Text size="sm" fw={500} truncate style={{ maxWidth: '100%' }}> <Text size="sm" fw={500} truncate style={{ maxWidth: '100%' }}>
{attachment.documentKey} {localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey}
</Text> </Text>
{verdict && ( {verdict && (
<Tooltip <Tooltip
@@ -396,7 +397,11 @@ export function DocumentsTab({
onClose={() => setPreview(null)} onClose={() => setPreview(null)}
position="right" position="right"
size="xl" 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 // Focus is trapped and returned so keyboard users are not dropped at
// the top of the page when the drawer closes. // the top of the page when the drawer closes.
trapFocus trapFocus
@@ -406,13 +411,13 @@ export function DocumentsTab({
isPdf ? ( isPdf ? (
<iframe <iframe
src={previewFile.url} src={previewFile.url}
title={preview?.documentKey ?? 'document'} title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ width: '100%', height: '80vh', border: 'none' }} style={{ width: '100%', height: '80vh', border: 'none' }}
/> />
) : isImage ? ( ) : isImage ? (
<img <img
src={previewFile.url} src={previewFile.url}
alt={preview?.documentKey ?? 'document'} alt={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ maxWidth: '100%' }} 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 * pass/fail line means the rule, the figure it was checked against, and the
* outcome are all on screen. * 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( export function evaluateEligibility(
application: LicenseApplication, application: LicenseApplication,
licenseType: LicenseType | undefined, licenseType: LicenseType | undefined,
locale: string, locale: string,
t: Translate,
): EligibilityRule[] { ): EligibilityRule[] {
const rules: EligibilityRule[] = []; const rules: EligibilityRule[] = [];
@@ -172,11 +177,18 @@ export function evaluateEligibility(
rules.push({ rules.push({
id: 'capital-threshold', id: 'capital-threshold',
label: `Paid-up capital ≥ ${format(threshold)}`, label: t('review.eligibilityRule.capitalThreshold', {
amount: format(threshold),
defaultValue: 'Paid-up capital ≥ {{amount}}',
}),
actual: actual:
effective === undefined effective === undefined
? 'Not recorded' ? t('review.eligibilityRule.notRecorded', 'Not recorded')
: `${format(effective)}${verified === undefined ? ' (declared)' : ' (verified)'}`, : `${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 // An unverified declaration is not evidence, so it reads as unknown
// rather than as a pass the officer never actually made. // rather than as a pass the officer never actually made.
status: status:
@@ -200,8 +212,10 @@ export function evaluateEligibility(
].includes(application.status); ].includes(application.status);
rules.push({ rules.push({
id: 'inspection', id: 'inspection',
label: 'Physical inspection completed', label: t('review.eligibilityRule.inspectionCompleted', 'Physical inspection completed'),
actual: inspected ? 'Recorded' : 'Not yet recorded', actual: inspected
? t('review.eligibilityRule.recorded', 'Recorded')
: t('review.eligibilityRule.notYetRecorded', 'Not yet recorded'),
status: inspected ? 'pass' : 'unknown', status: inspected ? 'pass' : 'unknown',
}); });
} }

View File

@@ -442,7 +442,8 @@ export function LicenseQueuePage() {
{ {
header: t("queue.sla", "Age / SLA"), header: t("queue.sla", "Age / SLA"),
cell: ({ row }) => { 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 ( return (
// Colour is never the only signal — the label says the same thing. // Colour is never the only signal — the label says the same thing.
<Tooltip label={sla.tooltip} withArrow> <Tooltip label={sla.tooltip} withArrow>

View File

@@ -82,10 +82,10 @@ type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
* that fills it in. * that fills it in.
*/ */
const INSPECTION_CHECKLIST_ITEMS = [ const INSPECTION_CHECKLIST_ITEMS = [
{ key: 'office_premises', label: 'Office premises' }, { key: 'office_premises', labelKey: 'review.checklist.officePremises', fallback: 'Office premises' },
{ key: 'storage_facilities', label: 'Warehouse / storage facilities' }, { key: 'storage_facilities', labelKey: 'review.checklist.storageFacilities', fallback: 'Warehouse / storage facilities' },
{ key: 'vehicles_equipment', label: 'Vehicles / equipment' }, { key: 'vehicles_equipment', labelKey: 'review.checklist.vehiclesEquipment', fallback: 'Vehicles / equipment' },
{ key: 'safety_compliance', label: 'Safety & regulatory compliance' }, { key: 'safety_compliance', labelKey: 'review.checklist.safetyCompliance', fallback: 'Safety & regulatory compliance' },
] as const; ] as const;
function buildChecklist( function buildChecklist(
@@ -93,7 +93,9 @@ function buildChecklist(
) { ) {
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({ return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
key: item.key, 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 // Untouched rows default to PASS — the segmented control shows exactly
// that, so what the officer saw is what gets recorded. // that, so what the officer saw is what gets recorded.
outcome: outcomes[item.key] ?? 'PASS', outcome: outcomes[item.key] ?? 'PASS',
@@ -127,6 +129,13 @@ export function LicenseReviewPage() {
{ idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' }, { idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' },
{ skip: !data?.application.licenseTypeId }, { 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 [completeReview] = useCompleteReviewMutation();
const [requestAdjustment] = useRequestAdjustmentMutation(); const [requestAdjustment] = useRequestAdjustmentMutation();
@@ -205,7 +214,7 @@ export function LicenseReviewPage() {
return { return {
key, key,
label: member label: member
? `${member.roleKey}${member.fullName}` ? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey}${member.fullName}`
: t('review.staffMember', 'Staff member'), : t('review.staffMember', 'Staff member'),
}; };
} }
@@ -215,7 +224,7 @@ export function LicenseReviewPage() {
return { key, label: key }; return { key, label: key };
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[flags, data?.staff, t], [flags, data?.staff, t, localized, roleNameByKey],
); );
const actions = useMemo(() => { const actions = useMemo(() => {
@@ -272,8 +281,10 @@ export function LicenseReviewPage() {
const app = data.application; const app = data.application;
const status = app.status; const status = app.status;
const presentation = presentationFor(app.licenseType?.key); const presentation = presentationFor(app.licenseType?.key);
const sla = computeSla(app, 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 eligibility = evaluateEligibility(app, app.licenseType, i18n.language); 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 rawThreshold = app.licenseType?.capitalThreshold;
const threshold = const threshold =
@@ -504,7 +515,7 @@ export function LicenseReviewPage() {
{app.applicationNumber} {app.applicationNumber}
</Text> </Text>
<Badge color={STATUS_COLORS[status]} variant="light"> <Badge color={STATUS_COLORS[status]} variant="light">
{STATUS_LABELS[status]} {t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge> </Badge>
{app.adjustmentRound > 0 && ( {app.adjustmentRound > 0 && (
<Badge color="orange" variant="light" size="sm"> <Badge color="orange" variant="light" size="sm">
@@ -543,7 +554,7 @@ export function LicenseReviewPage() {
<Stack gap={6}> <Stack gap={6}>
<SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} /> <SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} />
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} /> <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 <SummaryRow
label={t('review.submitted', 'Submitted')} label={t('review.submitted', 'Submitted')}
value={showDate(app.submittedAt)} value={showDate(app.submittedAt)}
@@ -605,7 +616,7 @@ export function LicenseReviewPage() {
key={entry.id} key={entry.id}
title={ title={
<Text size="xs" fw={600}> <Text size="xs" fw={600}>
{STATUS_LABELS[entry.toStatus] ?? entry.toStatus} {t(`queue.statusValues.${entry.toStatus}`, STATUS_LABELS[entry.toStatus] ?? entry.toStatus)}
</Text> </Text>
} }
> >
@@ -797,7 +808,7 @@ export function LicenseReviewPage() {
{data.staff.map((member) => ( {data.staff.map((member) => (
<Table.Tr key={member.id}> <Table.Tr key={member.id}>
<Table.Td> <Table.Td>
<Text size="xs">{member.roleKey}</Text> <Text size="xs">{localized(roleNameByKey.get(member.roleKey)) || member.roleKey}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm">{member.fullName}</Text> <Text size="sm">{member.fullName}</Text>
@@ -878,7 +889,11 @@ export function LicenseReviewPage() {
variant="light" variant="light"
color={inspection.result === 'FAILED' ? 'red' : 'teal'} 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> </Badge>
</Group> </Group>
))} ))}
@@ -984,7 +999,7 @@ export function LicenseReviewPage() {
<Stack gap={6}> <Stack gap={6}>
{INSPECTION_CHECKLIST_ITEMS.map((item) => ( {INSPECTION_CHECKLIST_ITEMS.map((item) => (
<Group key={item.key} justify="space-between" wrap="nowrap"> <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 <SegmentedControl
size="xs" size="xs"
value={checklist[item.key] ?? 'PASS'} value={checklist[item.key] ?? 'PASS'}

View File

@@ -18,6 +18,19 @@ export interface SlaState {
ratio: number; 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 { function formatDuration(ms: number): string {
const hours = Math.floor(Math.abs(ms) / HOUR_MS); const hours = Math.floor(Math.abs(ms) / HOUR_MS);
if (hours < 1) return '<1h'; if (hours < 1) return '<1h';
@@ -37,6 +50,7 @@ export function computeSla(
application: LicenseApplication, application: LicenseApplication,
now: number = Date.now(), now: number = Date.now(),
language = 'en', language = 'en',
t?: SlaTranslate,
): SlaState { ): SlaState {
const slaHours = application.licenseType?.slaHours; const slaHours = application.licenseType?.slaHours;
const submittedAt = application.submittedAt; const submittedAt = application.submittedAt;
@@ -46,7 +60,7 @@ export function computeSla(
state: 'untracked', state: 'untracked',
color: 'gray', color: 'gray',
label: '—', 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, ratio: 0,
}; };
} }
@@ -56,15 +70,23 @@ export function computeSla(
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted; const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
const window = slaHours * HOUR_MS; const window = slaHours * HOUR_MS;
const ratio = Math.min(Math.max(elapsed / window, 0), 1); 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) { if (application.decidedAt) {
const met = elapsed <= window; const met = elapsed <= window;
return { return {
state: 'decided', state: 'decided',
color: met ? 'teal' : 'gray', color: met ? 'teal' : 'gray',
label: met ? 'Met' : 'Missed', label: tr(t, met ? 'review.sla.met' : 'review.sla.missed', met ? 'Met' : 'Missed'),
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`, tooltip: tr(t, 'review.sla.decidedIn', {
duration: formatDuration(elapsed),
target: targetText,
defaultValue: 'Decided in {{duration}}. {{target}}',
}),
ratio, ratio,
}; };
} }
@@ -74,8 +96,15 @@ export function computeSla(
return { return {
state: 'breached', state: 'breached',
color: 'red', color: 'red',
label: `Overdue ${formatDuration(remaining)}`, label: tr(t, 'review.sla.overdue', {
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`, duration: formatDuration(remaining),
defaultValue: 'Overdue {{duration}}',
}),
tooltip: tr(t, 'review.sla.overdueBy', {
duration: formatDuration(remaining),
target: targetText,
defaultValue: 'Overdue by {{duration}}. {{target}}',
}),
ratio: 1, ratio: 1,
}; };
} }
@@ -84,8 +113,15 @@ export function computeSla(
return { return {
state: used >= WARNING_RATIO ? 'warning' : 'ok', state: used >= WARNING_RATIO ? 'warning' : 'ok',
color: used >= WARNING_RATIO ? 'yellow' : 'teal', color: used >= WARNING_RATIO ? 'yellow' : 'teal',
label: `${formatDuration(remaining)} left`, label: tr(t, 'review.sla.left', {
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`, duration: formatDuration(remaining),
defaultValue: '{{duration}} left',
}),
tooltip: tr(t, 'review.sla.remaining', {
duration: formatDuration(remaining),
target: targetText,
defaultValue: '{{duration}} remaining. {{target}}',
}),
ratio, ratio,
}; };
} }

View File

@@ -1016,6 +1016,42 @@ export const am: Translations = {
noFile: "ፋይል የለም", noFile: "ፋይል የለም",
noFileUploaded: "እስካሁን ምንም አልተጫነም", noFileUploaded: "እስካሁን ምንም አልተጫነም",
noInlinePreview: "ይህ የፋይል ዓይነት በአሳሹ ውስጥ ቅድመ እይታ አይደረግም።", 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: { done: {
completeReview: "ግምገማ ተጠናቋል", completeReview: "ግምገማ ተጠናቋል",

View File

@@ -1012,6 +1012,42 @@ export const en = {
noFile: 'No file', noFile: 'No file',
noFileUploaded: 'Nothing uploaded yet', noFileUploaded: 'Nothing uploaded yet',
noInlinePreview: 'This file type cannot be previewed in the browser.', 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: { done: {
completeReview: 'Review completed', completeReview: 'Review completed',