From 73f7127ac89dde275d3fc3a92a1614a04183a6f7 Mon Sep 17 00:00:00 2001 From: estifanos Date: Mon, 17 Aug 2026 07:52:09 +0000 Subject: [PATCH] Localization on Portal --- .../src/app/components/ErrorBoundary.tsx | 15 +- .../pages/BasicSafetyTrainingPage.tsx | 7 +- .../pages/CertificatesPage/columns.tsx | 34 +- .../pages/CertificatesPage/index.tsx | 67 +- .../dashboard/pages/DashboardPage/columns.tsx | 16 +- .../dashboard/pages/DashboardPage/index.tsx | 101 +-- .../documents/pages/DocumentVaultPage.tsx | 7 +- .../endorsement/pages/EndorsementPage.tsx | 118 ++- .../features/endorsement/pages/columns.tsx | 71 ++ .../exams/pages/ExamsPage/columns.tsx | 76 +- .../features/exams/pages/ExamsPage/index.tsx | 72 +- .../components/ConfigDrivenSection.tsx | 4 +- .../licensing/components/DocumentSlots.tsx | 24 +- .../licensing/components/LicenseCard.tsx | 22 +- .../licensing/components/LicenseCatalogue.tsx | 50 +- .../licensing/components/StaffEvidence.tsx | 10 +- .../pages/LicenseApplicationPage.tsx | 104 +-- .../medical/pages/MedicalCertificatePage.tsx | 7 +- .../notifications/pages/NotificationsPage.tsx | 33 +- .../components/RequireOperations.tsx | 6 +- .../pages/OperationsOnboardingPage.tsx | 8 +- .../payments/pages/PaymentCheckPage.tsx | 23 +- .../payments/pages/PaymentFailurePage.tsx | 12 +- .../payments/pages/PaymentSuccessPage.tsx | 17 +- .../profile/components/AddressFormContent.tsx | 116 +-- .../components/OperationsFormContent.tsx | 43 +- .../features/profile/pages/ProfilePage.tsx | 2 +- .../pages/MySeaRecordsPage/actions.tsx | 55 +- .../pages/MySeaRecordsPage/columns.tsx | 59 +- .../seafarer/pages/MySeaRecordsPage/index.tsx | 118 +-- .../pages/SeafarerRegistrationPage.tsx | 83 +-- .../pages/SeamanBookApplicationPage.tsx | 7 +- .../seaman-book/pages/SeamanBookPage.tsx | 7 +- .../pages/VesselRegistrationPage/columns.tsx | 58 +- .../pages/VesselRegistrationPage/index.tsx | 61 +- .../pages/VesselRegistrationStatusPage.tsx | 38 +- .../pages/VesselTransferPage.tsx | 51 +- apps/portal/src/app/i18n/locales/am.ts | 683 +++++++++++++++++ apps/portal/src/app/i18n/locales/en.ts | 685 ++++++++++++++++++ 39 files changed, 2270 insertions(+), 700 deletions(-) create mode 100644 apps/portal/src/app/features/endorsement/pages/columns.tsx diff --git a/apps/portal/src/app/components/ErrorBoundary.tsx b/apps/portal/src/app/components/ErrorBoundary.tsx index d8b892333..6c0b2aa4f 100644 --- a/apps/portal/src/app/components/ErrorBoundary.tsx +++ b/apps/portal/src/app/components/ErrorBoundary.tsx @@ -1,8 +1,10 @@ import { Component } from 'react'; import type { ReactNode, ErrorInfo } from 'react'; import { Center, Paper, Title, Text, Button } from '@mantine/core'; +import { withTranslation } from 'react-i18next'; +import type { WithTranslation } from 'react-i18next'; -interface Props { +interface Props extends WithTranslation { children: ReactNode; } @@ -11,7 +13,7 @@ interface State { error: Error | null; } -export class ErrorBoundary extends Component { +class ErrorBoundaryBase extends Component { state: State = { hasError: false, error: null }; static getDerivedStateFromError(error: Error): State { @@ -24,12 +26,13 @@ export class ErrorBoundary extends Component { render() { if (this.state.hasError) { + const { t } = this.props; return (
- Something went wrong + {t('errorBoundary.title')} - {this.state.error?.message || 'An unexpected error occurred.'} + {this.state.error?.message || t('errorBoundary.message')}
@@ -48,3 +51,5 @@ export class ErrorBoundary extends Component { return this.props.children; } } + +export const ErrorBoundary = withTranslation()(ErrorBoundaryBase); diff --git a/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx b/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx index a23be0288..304b45036 100644 --- a/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx +++ b/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx @@ -1,5 +1,6 @@ import { Container } from '@mantine/core'; import { FeatureUnavailable } from '@ema-platform/ui'; +import { useTranslation } from 'react-i18next'; /** * Placeholder until this feature has a backend. @@ -8,11 +9,13 @@ import { FeatureUnavailable } from '@ema-platform/ui'; * indistinguishable from real ones. */ export function BasicSafetyTrainingPage() { + const { t } = useTranslation(); + return ( ); diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx index 6a8df144d..1d861d9d0 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx @@ -1,19 +1,23 @@ import { Badge, Button, Text } from '@mantine/core'; import { IconCertificate } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { Bilingual, IssuedLicense } from '@ema-platform/api'; import { PORTAL_PERMISSIONS } from '@ema-platform/auth'; -export function certificateColumns(deps: { - /** Permission check from usePermissions() — hooks can't run in a cell. */ - can: (required?: string[]) => boolean; - localized: (value: Bilingual | undefined) => string; - showDate: (value: string | null | undefined) => string; - onDownload: (license: IssuedLicense) => void; -}): AdvancedColumn[] { +export function certificateColumns( + t: TFunction, + deps: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; + localized: (value: Bilingual | undefined) => string; + showDate: (value: string | null | undefined) => string; + onDownload: (license: IssuedLicense) => void; + }, +): AdvancedColumn[] { return [ { - header: 'Certificate №', + header: t('certificates.columns.certificateNumber', 'Certificate №'), cell: ({ row }) => ( {row.original.certificateNumber} @@ -21,32 +25,32 @@ export function certificateColumns(deps: { ), }, { - header: 'Type', + header: t('certificates.columns.type', 'Type'), cell: ({ row }) => deps.localized(row.original.licenseType?.name), }, { - header: 'Issued', + header: t('certificates.columns.issued', 'Issued'), cell: ({ row }) => deps.showDate(row.original.issueDate), }, { - header: 'Expires', + header: t('certificates.columns.expires', 'Expires'), cell: ({ row }) => deps.showDate(row.original.expiryDate), }, { - header: 'Status', + header: t('common.status'), cell: ({ row }) => ( - {row.original.status} + {t(`certificates.columns.licenseStatus.${row.original.status}`, row.original.status)} ), }, { header: '', - label: 'Actions', + label: t('common.actions'), align: 'right', cell: ({ row }) => deps.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) ? ( @@ -56,7 +60,7 @@ export function certificateColumns(deps: { leftSection={} onClick={() => deps.onDownload(row.original)} > - Download + {t('common.download')} ) : null, }, diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx index bdba090ea..2806e20fb 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx @@ -5,7 +5,6 @@ import { Card, Group, List, - Loader, Stack, Text, ThemeIcon, @@ -18,6 +17,7 @@ import { IconInfoCircle, } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { STATUS_COLORS, STATUS_LABELS, @@ -70,6 +70,7 @@ function EligibilityItem({ ok, label }: { ok: boolean; label: string }) { * issued certificates. The wizard itself is the config-driven licensing flow. */ export function CertificatesPage() { + const { t } = useTranslation(); const navigate = useNavigate(); const { profile, isLoading: loadingProfile } = useCurrentProfile(); const { data: seaTime } = useGetMySeaTimeQuery(); @@ -107,46 +108,63 @@ export function CertificatesPage() { const result = await getCertificateUrl(licenseId).unwrap(); window.open(result.url, '_blank', 'noopener'); } catch (error) { - notify.error(extractErrorMessage(error, 'Could not fetch certificate')); + notify.error( + extractErrorMessage(error, t('certificates.fetchFailed', 'Could not fetch certificate')), + ); } } if (loadingProfile || loadingApplications) { - return ; + return ; } const pagedIssued = issuedTable.paginate(issued); return ( - My Certificates + {t('certificates.title', 'My Certificates')}
- Eligibility + {t('certificates.eligibility.title', 'Eligibility')} 0} - label={`Verified sea time: ${verifiedDays} days (CoC needs 360, CoP 90)`} + label={t('certificates.eligibility.seaTime', { + defaultValue: 'Verified sea time: {{days}} days (CoC needs 360, CoP 90)', + days: verifiedDays, + })} />
@@ -157,7 +175,7 @@ export function CertificatesPage() { navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply') } > - Apply for CoC + {t('certificates.applyCoc', 'Apply for CoC')}
{!registered && ( }> - Complete your{' '} + {t('certificates.registrationNotice.prefix', 'Complete your')}{' '} navigate('/seafarer-registration')} > - seafarer registration + {t('certificates.registrationNotice.link', 'seafarer registration')} {' '} - first — certificate applications are refused without it. + {t( + 'certificates.registrationNotice.suffix', + 'first — certificate applications are refused without it.', + )} )} {inFlight.length > 0 && ( - Applications in progress + {t('certificates.inProgress', 'Applications in progress')} {inFlight.map((app) => ( @@ -200,7 +221,7 @@ export function CertificatesPage() { - {STATUS_LABELS[app.status]} + {t(`applications.status.${app.status}`, STATUS_LABELS[app.status])} @@ -223,10 +244,10 @@ export function CertificatesPage() { )} - Issued certificates + {t('certificates.issuedCertificates', 'Issued certificates')} diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx index 8b7158139..67727442d 100644 --- a/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx @@ -1,4 +1,5 @@ import { Badge, Progress, Text } from '@mantine/core'; +import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import { STATUS_COLORS, @@ -8,10 +9,12 @@ import { } from '@ema-platform/api'; import type { LicenseApplication } from '@ema-platform/api'; -export const dashboardApplicationColumns: AdvancedColumn[] = - [ +export function dashboardApplicationColumns( + t: TFunction, +): AdvancedColumn[] { + return [ { - header: 'Application', + header: t('dashboard.table.application'), cell: ({ row }) => ( <> @@ -24,13 +27,13 @@ export const dashboardApplicationColumns: AdvancedColumn[] = ), }, { - header: 'Licence', + header: t('applications.table.licence'), cell: ({ row }) => ( {localized(row.original.licenseType?.name) || '—'} ), }, { - header: 'Status', + header: t('common.status'), cell: ({ row }) => ( {STATUS_LABELS[row.original.status]} @@ -38,7 +41,7 @@ export const dashboardApplicationColumns: AdvancedColumn[] = ), }, { - header: 'Progress', + header: t('applications.table.progress'), size: 180, cell: ({ row }) => ( [] = ), }, ]; +} diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx index 17f44e7c4..ff01b68dd 100644 --- a/apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx @@ -1,6 +1,8 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { Alert, Anchor, @@ -10,7 +12,6 @@ import { Center, Container, Group, - Loader, Paper, SimpleGrid, Stack, @@ -64,15 +65,20 @@ function daysUntil(date: string): number { return Math.ceil(ms / 86_400_000); } -function formatMoney(amount: string | number | null, currency: string): string { - if (amount === null || amount === '') return 'No fee'; +function formatMoney( + amount: string | number | null, + currency: string, + t: TFunction, +): string { + if (amount === null || amount === '') return t('dashboard.noFee'); const value = Number(amount); - if (!Number.isFinite(value)) return 'No fee'; + if (!Number.isFinite(value)) return t('dashboard.noFee'); return `${value.toLocaleString('en-US')} ${currency}`; } export function DashboardPage() { const navigate = useNavigate(); + const { t } = useTranslation(); const displayName = useSelector( (state: { auth: { user?: { name?: { en?: string }; username?: string } } }) => state.auth.user?.name?.en || state.auth.user?.username || '', @@ -108,7 +114,7 @@ export function DashboardPage() { } if (isLoading) { - return ; + return ; } return ( @@ -133,17 +139,15 @@ export function DashboardPage() { color="orange" radius="md" icon={} - title={ - expiringSoon.length === 1 - ? 'A licence is expiring soon' - : `${expiringSoon.length} licences are expiring soon` - } + title={t('applications.notice.expiringSoon', { count: expiringSoon.length })} > {expiringSoon - .map( - (l) => - `${l.certificateNumber} expires in ${daysUntil(l.expiryDate)} days`, + .map((l) => + t('dashboard.expiringSoon.detail', { + certificateNumber: l.certificateNumber, + days: daysUntil(l.expiryDate), + }), ) .join(' · ')} @@ -161,9 +165,9 @@ export function DashboardPage() { ) : ( <> -
+
{heldLicenses.length === 0 ? ( - + ) : ( {heldLicenses.map((license) => ( @@ -181,20 +185,20 @@ export function DashboardPage() {
0 ? ( navigate('/licensing/applications')} > - View all + {t('common.viewAll')} ) : undefined } > {items.length === 0 ? ( - + ) : (
@@ -228,10 +232,14 @@ function Hero({ applicationCount: number; licenseCount: number; }) { + const { t } = useTranslation(); const summary = applicationCount === 0 && licenseCount === 0 - ? 'Apply for a maritime or logistics licence and track it through to issue.' - : `You have ${applicationCount} application${applicationCount === 1 ? '' : 's'} and ${licenseCount} active licence${licenseCount === 1 ? '' : 's'}.`; + ? t('dashboard.hero.summaryEmpty') + : t('dashboard.hero.summary', { + applications: t('dashboard.hero.applicationsCount', { count: applicationCount }), + licences: t('dashboard.hero.licencesCount', { count: licenseCount }), + }); return ( - Ethiopian Maritime Authority + {t('app.authority')} - {displayName ? `Welcome back, ${displayName}` : 'Welcome back'} + {displayName ? t('dashboard.welcomeName', { name: displayName }) : t('dashboard.welcome')} {summary} @@ -276,6 +284,7 @@ function ActionRequired({ applications: LicenseApplication[]; navigate: (path: string) => void; }) { + const { t } = useTranslation(); return ( - Waiting on you + {t('dashboard.waitingOnYou')} {applications.map((app) => { - const detail = detailFor(app); + const detail = detailFor(app, t); return ( @@ -335,7 +344,10 @@ function ActionRequired({ } /** What the applicant has to do next, and where that happens. */ -function detailFor(app: LicenseApplication): { +function detailFor( + app: LicenseApplication, + t: TFunction, +): { message: string; cta: string; color: string; @@ -349,22 +361,24 @@ function detailFor(app: LicenseApplication): { switch (app.status) { case 'RESUBMIT_REQUIRED': return { - message: 'A reviewer asked for corrections before this can proceed.', - cta: 'Fix now', + message: t('dashboard.actionRequired.messages.resubmit'), + cta: t('dashboard.actionRequired.cta.fixNow'), color: 'orange', path: wizard, }; case 'PAYMENT_PENDING': return { - message: `Approved — ${formatMoney(app.feeAmount, app.feeCurrency ?? 'ETB')} due before the certificate is issued.`, - cta: 'Pay now', + message: t('dashboard.actionRequired.messages.paymentPending', { + amount: formatMoney(app.feeAmount, app.feeCurrency ?? 'ETB', t), + }), + cta: t('dashboard.actionRequired.cta.payNow'), color: 'yellow', path: '/licensing/applications', }; default: return { - message: 'This application is still a draft and has not been filed.', - cta: 'Continue', + message: t('dashboard.actionRequired.messages.draft'), + cta: t('common.continue'), color: 'blue', path: wizard, }; @@ -382,11 +396,12 @@ function StatRow({ activeLicenses: number; expiringSoon: number; }) { + const { t } = useTranslation(); const stats = [ - { label: 'In progress', value: inProgress, icon: IconClockHour4, color: 'blue' }, - { label: 'Waiting on you', value: needsMe, icon: IconAlertTriangle, color: 'orange' }, - { label: 'Active licences', value: activeLicenses, icon: IconCertificate, color: 'teal' }, - { label: 'Expiring soon', value: expiringSoon, icon: IconClockHour4, color: 'grape' }, + { label: t('applications.stats.inProgress'), value: inProgress, icon: IconClockHour4, color: 'blue' }, + { label: t('dashboard.waitingOnYou'), value: needsMe, icon: IconAlertTriangle, color: 'orange' }, + { label: t('applications.stats.activeLicences'), value: activeLicenses, icon: IconCertificate, color: 'teal' }, + { label: t('dashboard.stats.expiringSoon'), value: expiringSoon, icon: IconClockHour4, color: 'grape' }, ]; return ( @@ -452,12 +467,13 @@ function ApplicationTable({ navigate: (path: string) => void; onRefresh: () => void; }) { + const { t } = useTranslation(); const table = useServerTable(); const paged = table.paginate(applications); return ( @@ -489,11 +506,9 @@ function GetStartedPanel() { - Get started + {t('dashboard.getStarted.title')} - You have not filed an application yet. Choose the licence that - matches what your company does — your applications and the licences - issued to you will appear here as you go. + {t('dashboard.getStarted.body')} diff --git a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx index a521c7e69..172419552 100644 --- a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx +++ b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx @@ -1,5 +1,6 @@ import { Container } from '@mantine/core'; import { FeatureUnavailable } from '@ema-platform/ui'; +import { useTranslation } from 'react-i18next'; /** * Placeholder until this feature has a backend. @@ -8,11 +9,13 @@ import { FeatureUnavailable } from '@ema-platform/ui'; * indistinguishable from real ones. */ export function DocumentVaultPage() { + const { t } = useTranslation(); + return ( ); diff --git a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx index fa33be9d1..40c4e109f 100644 --- a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx +++ b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx @@ -5,21 +5,19 @@ import { Card, Group, List, - Loader, Stack, - Table, Text, ThemeIcon, Title, } from '@mantine/core'; import { IconArrowRight, - IconCertificate, IconCircleCheck, IconCircleX, IconInfoCircle, } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { STATUS_COLORS, STATUS_LABELS, @@ -31,8 +29,9 @@ import { useGetMyLicensesQuery, } from '@ema-platform/api'; import { useCurrentProfile } from '@ema-platform/auth'; -import { PageLoader, notify } from '@ema-platform/ui'; +import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; +import { endorsementColumns } from './columns'; const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC']; @@ -62,6 +61,7 @@ function EligibilityItem({ ok, label }: { ok: boolean; label: string }) { * config-driven licensing flow. */ export function EndorsementPage() { + const { t } = useTranslation(); const navigate = useNavigate(); const { profile, isLoading: loadingProfile } = useCurrentProfile(); const { data: applications, isLoading: loadingApplications } = @@ -70,6 +70,7 @@ export function EndorsementPage() { const showDate = useDateDisplayer(); const localized = useLocalized(); const [getCertificateUrl] = useGetCertificateUrlMutation(); + const issuedTable = useServerTable(); const registered = Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE'; @@ -83,37 +84,46 @@ export function EndorsementPage() { const issued = (licenses?.items ?? []).filter((license) => ENDORSEMENT_TYPE_KEYS.includes(license.licenseType?.key ?? ''), ); + const issuedPage = issuedTable.paginate(issued); async function download(licenseId: string) { try { const result = await getCertificateUrl(licenseId).unwrap(); window.open(result.url, '_blank', 'noopener'); } catch (error) { - notify.error(extractErrorMessage(error, 'Could not fetch endorsement')); + notify.error( + extractErrorMessage(error, t('endorsement.fetchFailed', 'Could not fetch endorsement')), + ); } } if (loadingProfile || loadingApplications) { - return ; + return ; } return ( - My Endorsements + {t('endorsement.title', 'My Endorsements')}
- Eligibility + {t('endorsement.eligibility.title', 'Eligibility')} @@ -123,36 +133,39 @@ export function EndorsementPage() { rightSection={} onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')} > - Endorse a CoC + {t('endorsement.endorseCoc', 'Endorse a CoC')} {!registered && ( }> - Complete your{' '} + {t('endorsement.registrationNotice.prefix', 'Complete your')}{' '} navigate('/seafarer-registration')} > - seafarer registration + {t('endorsement.registrationNotice.link', 'seafarer registration')} {' '} - first — endorsement applications are refused without it. + {t( + 'endorsement.registrationNotice.suffix', + 'first — endorsement applications are refused without it.', + )} )} {inFlight.length > 0 && ( - Applications in progress + {t('endorsement.inProgress', 'Applications in progress')} {inFlight.map((app) => ( @@ -164,7 +177,7 @@ export function EndorsementPage() {
- {STATUS_LABELS[app.status]} + {t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
@@ -187,62 +200,17 @@ export function EndorsementPage() { )} - Issued endorsements - {issued.length === 0 ? ( - - - No endorsements issued yet. - - - ) : ( - - - - - Certificate № - Type - Issued - Expires - Status - - - - - {issued.map((license) => ( - - - - {license.certificateNumber} - - - {localized(license.licenseType?.name)} - {showDate(license.issueDate)} - {showDate(license.expiryDate)} - - - {license.status} - - - - - - - ))} - -
-
- )} + {t('endorsement.issuedEndorsements', 'Issued endorsements')} +
); diff --git a/apps/portal/src/app/features/endorsement/pages/columns.tsx b/apps/portal/src/app/features/endorsement/pages/columns.tsx new file mode 100644 index 000000000..eb4b8fe38 --- /dev/null +++ b/apps/portal/src/app/features/endorsement/pages/columns.tsx @@ -0,0 +1,71 @@ +import { Badge, Button, Text } from '@mantine/core'; +import { IconCertificate } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Bilingual, IssuedLicense } from '@ema-platform/api'; + +interface EndorsementColumnsArgs { + t: TFunction; + showDate: (date: string) => string; + localized: (value: Bilingual | undefined) => string; + onDownload: (licenseId: string) => void; +} + +export function endorsementColumns({ + t, + showDate, + localized, + onDownload, +}: EndorsementColumnsArgs): AdvancedColumn[] { + return [ + { + header: t('endorsement.columns.certificateNumber', 'Certificate №'), + accessorKey: 'certificateNumber', + cell: ({ row }) => ( + + {row.original.certificateNumber} + + ), + }, + { + header: t('endorsement.columns.type', 'Type'), + cell: ({ row }) => localized(row.original.licenseType?.name), + }, + { + header: t('endorsement.columns.issued', 'Issued'), + cell: ({ row }) => showDate(row.original.issueDate), + }, + { + header: t('endorsement.columns.expires', 'Expires'), + cell: ({ row }) => showDate(row.original.expiryDate), + }, + { + header: t('common.status'), + cell: ({ row }) => ( + + {t( + `endorsement.columns.licenseStatus.${row.original.status}`, + row.original.status, + )} + + ), + }, + { + header: '', + cell: ({ row }) => ( + + ), + }, + ]; +} diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx index f58e39a7f..61f2be723 100644 --- a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx +++ b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx @@ -1,5 +1,6 @@ import { Badge, Button, Text } from '@mantine/core'; import { IconFileText, IconGavel } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { Bilingual } from '@ema-platform/api'; import { PORTAL_PERMISSIONS } from '@ema-platform/auth'; @@ -19,16 +20,19 @@ const ATTENDANCE_COLOR: Record = { DISQUALIFIED: 'red', }; -export function registrationColumns(deps: { - /** Permission check from usePermissions() — hooks can't run in a cell. */ - can: (required?: string[]) => boolean; - localized: (value: Bilingual | undefined) => string; - showDate: (value: string | null | undefined) => string; - onDownloadSlip: (registration: MyRegistration) => void; -}): AdvancedColumn[] { +export function registrationColumns( + t: TFunction, + deps: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; + localized: (value: Bilingual | undefined) => string; + showDate: (value: string | null | undefined) => string; + onDownloadSlip: (registration: MyRegistration) => void; + }, +): AdvancedColumn[] { return [ { - header: 'Admission №', + header: t('exams.columns.admission'), cell: ({ row }) => ( {row.original.admissionNumber} @@ -36,19 +40,19 @@ export function registrationColumns(deps: { ), }, { - header: 'Examination', + header: t('exams.columns.examination'), cell: ({ row }) => deps.localized(row.original.exam?.title) || '—', }, { - header: 'Date', + header: t('exams.columns.date'), cell: ({ row }) => deps.showDate(row.original.exam?.date), }, { - header: 'Venue', + header: t('exams.columns.venue'), cell: ({ row }) => row.original.exam?.venue ?? '—', }, { - header: 'Attempt', + header: t('exams.columns.attempt'), cell: ({ row }) => ( {row.original.kind === 'RETAKE' - ? `Retake · ${row.original.attemptNumber}` - : 'First sitting'} + ? t('exams.columns.retake', { n: row.original.attemptNumber }) + : t('exams.columns.firstSitting')} ), }, { - header: 'Attendance', + header: t('exams.columns.attendance'), cell: ({ row }) => ( - {row.original.attendanceStatus} + {t(`exams.columns.attendanceStatus.${row.original.attendanceStatus}`)} ), }, { - header: 'Slip', + header: t('exams.columns.slip'), cell: ({ row }) => deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? ( ) : null, }, ]; } -export function resultColumns(deps: { - /** Permission check from usePermissions() — hooks can't run in a cell. */ - can: (required?: string[]) => boolean; - localized: (value: Bilingual | undefined) => string; - showDate: (value: string | null | undefined) => string; - appeals: MyAppeal[]; - onAppeal: (result: MyResult) => void; -}): AdvancedColumn[] { +export function resultColumns( + t: TFunction, + deps: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; + localized: (value: Bilingual | undefined) => string; + showDate: (value: string | null | undefined) => string; + appeals: MyAppeal[]; + onAppeal: (result: MyResult) => void; + }, +): AdvancedColumn[] { return [ { - header: 'Examination', + header: t('exams.columns.examination'), cell: ({ row }) => deps.localized(row.original.exam?.title) || '—', }, { - header: 'Published', + header: t('exams.columns.published'), cell: ({ row }) => deps.showDate(row.original.publishedAt), }, { - header: 'Score', + header: t('exams.columns.score'), cell: ({ row }) => ( {row.original.totalScore} @@ -116,23 +123,24 @@ export function resultColumns(deps: { ), }, { - header: 'Outcome', + header: t('exams.columns.outcome'), cell: ({ row }) => ( - {row.original.status} + {t(`exams.columns.outcomeStatus.${row.original.status}`)} ), }, { - header: 'Appeal', + header: t('exams.columns.appeal'), cell: ({ row }) => { const appeal = deps.appeals.find((a) => a.resultId === row.original.id); return appeal ? ( - {appeal.appealNumber} · {appeal.status} + {appeal.appealNumber} ·{' '} + {t(`exams.columns.appealStatus.${appeal.status}`)} ) : deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? ( ) : null; }, diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx index 17b42f336..f2b74bb5a 100644 --- a/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx +++ b/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx @@ -4,7 +4,6 @@ import { Button, Card, Group, - Loader, Modal, Stack, Text, @@ -12,6 +11,7 @@ import { Title, } from '@mantine/core'; import { IconClipboardList } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { @@ -79,6 +79,7 @@ export interface MyAppeal { * when a mark looks wrong. */ export function ExamsPage() { + const { t } = useTranslation(); const showDate = useDateDisplayer(); const localized = useLocalized(); const [appealFor, setAppealFor] = useState(null); @@ -123,18 +124,21 @@ export function ExamsPage() { method: 'POST', }).unwrap()) as { admissionNumber?: string }; notify.success( - `Registered — admission number ${result.admissionNumber ?? 'issued'}`, + t('exams.notify.registered', { + admissionNumber: + result.admissionNumber ?? t('exams.notify.admissionNumberPending'), + }), ); refetch(); } catch (error) { - const key = extractErrorMessage(error, 'Could not register'); + const key = extractErrorMessage(error, t('exams.notify.registerFailed')); notify.error( key === 'seafarer_registration_required' - ? 'An active seafarer registration is required to sit examinations.' + ? t('exams.notify.seafarerRequired') : key === 'already_registered_for_exam' - ? 'You are already registered for this session.' + ? t('exams.notify.alreadyRegistered') : key === 'subject_already_passed' - ? 'You have already passed this subject — a resit is not needed.' + ? t('exams.notify.alreadyPassed') : key, ); } @@ -148,7 +152,7 @@ export function ExamsPage() { ); } catch (error) { notify.error( - extractErrorMessage(error, 'Could not generate the admission slip'), + extractErrorMessage(error, t('exams.notify.slipFailed')), ); } }; @@ -161,24 +165,26 @@ export function ExamsPage() { method: 'POST', body: { reason: appealReason.trim() }, }).unwrap()) as { appealNumber?: string }; - notify.success(`Appeal ${appeal.appealNumber ?? ''} submitted`); + notify.success( + t('exams.notify.appealSubmitted', { appealNumber: appeal.appealNumber ?? '' }), + ); setAppealFor(null); setAppealReason(''); refetchAppeals(); } catch (error) { - const key = extractErrorMessage(error, 'Could not submit the appeal'); + const key = extractErrorMessage(error, t('exams.notify.appealFailed')); notify.error( key.startsWith('appeal_window_closed') - ? `The appeal window (${key.split(':')[1] ?? ''} days from publication) has closed.` + ? t('exams.notify.appealWindowClosed', { days: key.split(':')[1] ?? '' }) : key === 'appeal_already_open' - ? 'An appeal on this result is already being considered.' + ? t('exams.notify.appealAlreadyOpen') : key, ); } }; if (loadingOpen || loadingMine || loadingResults) { - return ; + return ; } const pagedRegistrations = registrationTable.paginate(mine ?? []); @@ -186,14 +192,14 @@ export function ExamsPage() { return ( - Examinations + {t('exams.title')} - Open sessions + {t('exams.openSessions')} {(open ?? []).length === 0 ? ( - No upcoming sessions are open for registration. + {t('exams.noOpenSessions')} ) : ( @@ -210,7 +216,7 @@ export function ExamsPage() { {registeredExamIds.has(exam.id) ? ( - Registered + {t('exams.registered')} ) : ( @@ -220,7 +226,7 @@ export function ExamsPage() { leftSection={} onClick={() => register(exam)} > - Register + {t('exams.register')} )} @@ -231,10 +237,10 @@ export function ExamsPage() { - My registrations + {t('exams.myRegistrations')} - tableName="My registrations" - columns={registrationColumns({ + tableName={t('exams.myRegistrations')} + columns={registrationColumns(t, { can, localized, showDate, @@ -246,15 +252,15 @@ export function ExamsPage() { onPageChange={registrationTable.setPageIndex} pageSize={registrationTable.pageSize} refresh={refetch} - emptyText="No exam registrations yet." + emptyText={t('exams.noRegistrations')} /> - My results + {t('exams.myResults')} - tableName="My results" - columns={resultColumns({ + tableName={t('exams.myResults')} + columns={resultColumns(t, { can, localized, showDate, @@ -267,40 +273,42 @@ export function ExamsPage() { onPageChange={resultTable.setPageIndex} pageSize={resultTable.pageSize} refresh={refetchResults} - emptyText="No results have been published yet. Marks appear here once the authority approves and publishes them." + emptyText={t('exams.noResults')} /> setAppealFor(null)} - title="Request a review of this result" + title={t('exams.appealModal.title')} radius="lg" > - Explain what you believe went wrong with the marking or the - administration of {localized(appealFor?.exam?.title) || 'this examination'}. - Appeals must be lodged within 14 days of publication. + {t('exams.appealModal.body', { + examTitle: + localized(appealFor?.exam?.title) || + t('exams.appealModal.defaultExamTitle'), + })}