mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: enhance internationalization support across various components and pages
This commit is contained in:
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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%' }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: "ግምገማ ተጠናቋል",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user