mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: introduce useLocalized hook for bilingual value retrieval.(localization round 2)
- Added useLocalized hook to provide a stable function for retrieving bilingual values based on the current language. - Updated various components across the portal and backoffice to utilize the new useLocalized hook for consistent bilingual label rendering. - Refactored localized function in licensing.helpers to handle empty Amharic strings correctly. - Enhanced localization handling in LicenseApplicationPage, ProfilePage, and other components to ensure proper language switching.
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
useGetLicenseTemplatesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetTemplateVariablesQuery,
|
||||
useLocalized,
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
@@ -70,6 +71,7 @@ const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
*/
|
||||
export function CertificateDesignerPage() {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
||||
@@ -226,7 +228,7 @@ export function CertificateDesignerPage() {
|
||||
label={t('designer.licenceType', 'Licence type')}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: type.name?.en ?? type.key,
|
||||
label: localized(type.name) || type.key,
|
||||
}))}
|
||||
value={typeId}
|
||||
onChange={(value) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicensesQuery,
|
||||
useReinstateLicenseMutation,
|
||||
useRevokeLicenseMutation,
|
||||
@@ -168,6 +168,7 @@ export function LicenseRegisterPage() {
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useClearDocumentReviewMutation,
|
||||
useGetDocumentReviewsQuery,
|
||||
useLocalized,
|
||||
useReviewDocumentMutation,
|
||||
type Attachment,
|
||||
type DocumentRequirement,
|
||||
@@ -62,6 +63,7 @@ export function DocumentsTab({
|
||||
onFlagRemark,
|
||||
}: DocumentsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const [preview, setPreview] = useState<Attachment | null>(null);
|
||||
const [rejecting, setRejecting] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -148,7 +150,7 @@ export function DocumentsTab({
|
||||
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
|
||||
<Text size="sm">
|
||||
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
|
||||
{missing.map((r) => r.name.en ?? r.key).join(', ')}
|
||||
{missing.map((r) => localized(r.name) || r.key).join(', ')}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { STATUS_LABELS, type LicenseApplication } from '@ema-platform/api';
|
||||
import { STATUS_LABELS, localized, type LicenseApplication } from '@ema-platform/api';
|
||||
import { dateDisplayer } from '@ema-platform/shared';
|
||||
import { computeSla } from './sla';
|
||||
|
||||
@@ -22,7 +22,7 @@ const COLUMNS: Array<{
|
||||
{ header: 'Company', value: (a) => a.companyName },
|
||||
{ header: 'Trade name', value: (a) => a.tradeName },
|
||||
{ header: 'TIN', value: (a) => a.tinNumber },
|
||||
{ header: 'Licence type', value: (a) => a.licenseType?.name?.en ?? a.licenseTypeId },
|
||||
{ header: 'Licence type', value: (a, locale) => localized(a.licenseType?.name, locale) || a.licenseTypeId },
|
||||
{ header: 'Status', value: (a) => STATUS_LABELS[a.status] },
|
||||
{ header: 'Kind', value: (a) => a.kind },
|
||||
{ header: 'Assigned officer', value: (a) => a.assignedOfficerId },
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useApproveDocumentsMutation,
|
||||
useAssignApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
@@ -111,6 +112,7 @@ function buildChecklist(
|
||||
export function LicenseReviewPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const { id = '' } = useParams();
|
||||
const { can } = usePermissions();
|
||||
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? '';
|
||||
@@ -485,6 +487,12 @@ export function LicenseReviewPage() {
|
||||
|
||||
const sections = presentation.detailSections;
|
||||
const formSections = Object.entries(app.formData ?? {});
|
||||
// The bilingual section/field labels the applicant's wizard renders — this
|
||||
// page already fetches them (`requirements` above) but used to fall back to
|
||||
// the raw formData keys, so an officer saw `vesselId` instead of a label in
|
||||
// either language.
|
||||
const configSections = requirements?.licenseType.formSchema.sections ?? [];
|
||||
const sectionsByKey = new Map(configSections.map((s) => [s.key, s]));
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -533,7 +541,7 @@ export function LicenseReviewPage() {
|
||||
{t('review.summary', 'Summary')}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
<SummaryRow label={t('review.type', 'Type')} value={app.licenseType?.name?.en} />
|
||||
<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
|
||||
@@ -639,11 +647,18 @@ export function LicenseReviewPage() {
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<Stack>
|
||||
{formSections.map(([sectionKey, values]) => (
|
||||
{formSections.map(([sectionKey, values]) => {
|
||||
const sectionConfig = sectionsByKey.get(sectionKey);
|
||||
const fieldsByKey = new Map(
|
||||
(sectionConfig?.fields ?? []).map((f) => [f.key, f]),
|
||||
);
|
||||
return (
|
||||
<Card withBorder key={sectionKey} padding="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm" tt="capitalize">
|
||||
{sectionKey.replace(/([A-Z])/g, ' $1')}
|
||||
{sectionConfig
|
||||
? localized(sectionConfig.title)
|
||||
: sectionKey.replace(/([A-Z])/g, ' $1')}
|
||||
</Text>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
@@ -654,18 +669,21 @@ export function LicenseReviewPage() {
|
||||
</Group>
|
||||
<Table withTableBorder>
|
||||
<Table.Tbody>
|
||||
{Object.entries(values ?? {}).map(([k, v]) => (
|
||||
{Object.entries(values ?? {}).map(([k, v]) => {
|
||||
const fieldConfig = fieldsByKey.get(k);
|
||||
return (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{k}
|
||||
{fieldConfig ? localized(fieldConfig.label) : k}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{v === null ? '—' : String(v)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{flags[sectionKey] && (
|
||||
@@ -702,7 +720,8 @@ export function LicenseReviewPage() {
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useGetLocationsQuery } from '../api/location-api';
|
||||
import type { Location } from '../types/location';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
|
||||
interface LocationTreeProps {
|
||||
selectedId: string | null;
|
||||
@@ -51,6 +52,7 @@ function TreeNode({
|
||||
onSelect: (location: Location) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const [opened, setOpened] = useState(depth < 1);
|
||||
const isSelected = selectedId === location.id;
|
||||
const hasChildren =
|
||||
@@ -134,7 +136,7 @@ function TreeNode({
|
||||
style={{ flexShrink: 0, opacity: 0.6 }}
|
||||
/>
|
||||
<Text size="sm" truncate style={{ flex: 1 }}>
|
||||
{location.names.en}
|
||||
{localized(location.names)}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
{hasChildren && (
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
@@ -61,6 +61,7 @@ function feeText(amount: string | number | null, currency: string): string {
|
||||
|
||||
export function PaymentConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { data, isLoading, isFetching, error, refetch } =
|
||||
useGetLicenseTypesQuery();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
@@ -292,6 +293,7 @@ function FeeEditModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const [updateFees, { isLoading }] = useUpdateLicenseFeesMutation();
|
||||
const [newFee, setNewFee] = useState<number | ''>('');
|
||||
const [renewalFee, setRenewalFee] = useState<number | ''>('');
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
||||
import {
|
||||
useGetResultsQuery,
|
||||
useLazyGetResultQuery,
|
||||
@@ -117,6 +117,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
|
||||
export function ResultPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const showDate = useDateDisplayer();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
@@ -152,7 +153,7 @@ export function ResultPage() {
|
||||
const [qcRemark, setQcRemark] = useState('');
|
||||
const [qcAdjustment, setQcAdjustment] = useState(0);
|
||||
|
||||
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
|
||||
const examOptions = exams.map((e) => ({ value: e.id, label: `${localized(e.title)} (${e.date})` }));
|
||||
|
||||
const startRecord = () => {
|
||||
const ex = exams.find((e) => e.id === pickerExamId);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
@@ -74,6 +75,7 @@ export function CertificatesPage() {
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
|
||||
const registered =
|
||||
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
||||
@@ -189,7 +191,7 @@ export function CertificatesPage() {
|
||||
<div>
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{app.licenseType?.name?.en}
|
||||
{localized(app.licenseType?.name)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
@@ -245,7 +247,7 @@ export function CertificatesPage() {
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{license.licenseType?.name?.en}</Table.Td>
|
||||
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
|
||||
<Table.Td>{showDate(license.issueDate)}</Table.Td>
|
||||
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
|
||||
<Table.Td>
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
STATUS_LABELS,
|
||||
STATUS_PROGRESS,
|
||||
TERMINAL_STATUSES,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
@@ -457,6 +457,7 @@ function ApplicationTable({
|
||||
applications: LicenseApplication[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
return (
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
@@ -68,6 +68,7 @@ export function EndorsementPage() {
|
||||
useGetMyApplicationsQuery();
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
|
||||
const registered =
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useApiMutation,
|
||||
extractErrorMessage,
|
||||
openAuthedDocument,
|
||||
useLocalized,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
interface OpenExam {
|
||||
@@ -83,6 +84,7 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
*/
|
||||
export function ExamsPage() {
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
||||
const [appealReason, setAppealReason] = useState('');
|
||||
|
||||
@@ -197,9 +199,9 @@ export function ExamsPage() {
|
||||
<Card key={exam.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{exam.title?.en}</Text>
|
||||
<Text fw={600}>{localized(exam.title)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{exam.certification?.name?.en ?? ''} ·{' '}
|
||||
{localized(exam.certification?.name)} ·{' '}
|
||||
{showDate(exam.date)}
|
||||
{exam.venue ? ` · ${exam.venue}` : ''}
|
||||
</Text>
|
||||
@@ -254,7 +256,7 @@ export function ExamsPage() {
|
||||
{registration.admissionNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{registration.exam?.title?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{localized(registration.exam?.title) || '—'}</Table.Td>
|
||||
<Table.Td>{showDate(registration.exam?.date)}</Table.Td>
|
||||
<Table.Td>{registration.exam?.venue ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -326,7 +328,7 @@ export function ExamsPage() {
|
||||
);
|
||||
return (
|
||||
<Table.Tr key={result.id}>
|
||||
<Table.Td>{result.exam?.title?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{localized(result.exam?.title) || '—'}</Table.Td>
|
||||
<Table.Td>{showDate(result.publishedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
@@ -376,7 +378,7 @@ export function ExamsPage() {
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Explain what you believe went wrong with the marking or the
|
||||
administration of {appealFor?.exam?.title?.en ?? 'this examination'}.
|
||||
administration of {localized(appealFor?.exam?.title) || 'this examination'}.
|
||||
Appeals must be lodged within 14 days of publication.
|
||||
</Text>
|
||||
<Textarea
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
conditionHolds,
|
||||
localized,
|
||||
useLocalized,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
type Vessel,
|
||||
@@ -72,7 +72,8 @@ export function fillFromVessel(
|
||||
onChange: (key: string, value: unknown) => void,
|
||||
) {
|
||||
for (const f of fields) {
|
||||
const label = localized(f.label).toLowerCase();
|
||||
// English-pinned: matched against the English strings in VESSEL_FIELD_FILLERS.
|
||||
const label = (f.label.en ?? '').toLowerCase();
|
||||
const filler = VESSEL_FIELD_FILLERS.find((m) => m.matches(label, f.key));
|
||||
if (filler) onChange(f.key, filler.value(vessel) ?? '');
|
||||
}
|
||||
@@ -95,6 +96,7 @@ export function ConfigDrivenSection({
|
||||
vessels = [],
|
||||
onVesselSelected,
|
||||
}: Props) {
|
||||
const localized = useLocalized();
|
||||
const fields = [...(section.fields ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
@@ -105,6 +107,8 @@ export function ConfigDrivenSection({
|
||||
if (!conditionHolds(field.showWhen, formData)) return null;
|
||||
|
||||
const label = localized(field.label);
|
||||
// English-pinned: the picker overrides below match English labels.
|
||||
const labelEn = (field.label.en ?? '').toLowerCase();
|
||||
const value = values?.[field.key];
|
||||
const error = errors[`${section.key}.${field.key}`];
|
||||
const common = {
|
||||
@@ -118,11 +122,11 @@ export function ConfigDrivenSection({
|
||||
const span = field.type === 'TEXTAREA' ? 12 : 6;
|
||||
// Same field the profile Address tab collects — give it the same
|
||||
// searchable, flag-labeled picker instead of a plain option list.
|
||||
const isNationality = field.key === 'nationality' || label.toLowerCase().includes('nationality');
|
||||
const isNationality = field.key === 'nationality' || labelEn.includes('nationality');
|
||||
// Options here can't be seeded statically — they're the applicant's
|
||||
// own vessel register, so this overrides whatever type the backend
|
||||
// configured, the same way nationality overrides SELECT above.
|
||||
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(label.trim());
|
||||
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(labelEn.trim());
|
||||
|
||||
return (
|
||||
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
conditionHolds,
|
||||
localized,
|
||||
useLocalized,
|
||||
uploadDocument,
|
||||
type Attachment,
|
||||
type DocumentRequirement,
|
||||
@@ -58,6 +58,7 @@ export function DocumentSlots({
|
||||
onUploaded,
|
||||
readOnly,
|
||||
}: Props) {
|
||||
const localized = useLocalized();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resetRefs = useRef<Record<string, () => void>>({});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { IconDownload, IconRefresh } from '@tabler/icons-react';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useCreateApplicationMutation,
|
||||
type IssuedLicense,
|
||||
} from '@ema-platform/api';
|
||||
@@ -76,6 +76,7 @@ export function LicenseCard({
|
||||
const expired = license.status === 'EXPIRED' || days < 0;
|
||||
const renewable = license.renewable ?? false;
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" className="ema-hover-lift">
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
IconTrendingUp,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetMyOperatorTypesQuery,
|
||||
@@ -55,6 +55,7 @@ function formatFee(amount: string | number | null, currency: string): string {
|
||||
|
||||
export function LicenseCatalogue() {
|
||||
const navigate = useNavigate();
|
||||
const localized = useLocalized();
|
||||
const { data: types } = useGetLicenseTypesQuery();
|
||||
const { data: categories } = useGetLicenseCategoriesQuery();
|
||||
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
|
||||
@@ -253,6 +254,7 @@ function LicenseTypeCard({
|
||||
canApply: boolean;
|
||||
onSelect: (type: LicenseType) => void;
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
localized,
|
||||
useLocalized,
|
||||
uploadDocument,
|
||||
useGetAttachmentsQuery,
|
||||
type StaffEvidenceRequirement,
|
||||
@@ -30,6 +30,7 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
|
||||
ownerId: staffId,
|
||||
});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const localized = useLocalized();
|
||||
|
||||
if (!evidence?.length) return null;
|
||||
|
||||
|
||||
@@ -29,12 +29,13 @@ import {
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
buildWizardSteps,
|
||||
conditionHolds,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
localized,
|
||||
useLocalized,
|
||||
validateSections,
|
||||
useAddStaffMutation,
|
||||
useCreateApplicationMutation,
|
||||
@@ -66,6 +67,8 @@ import { StaffEvidence } from '../components/StaffEvidence';
|
||||
export function LicenseApplicationPage() {
|
||||
const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const { data: config, isLoading: loadingConfig } =
|
||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||
@@ -136,7 +139,8 @@ export function LicenseApplicationPage() {
|
||||
toFieldValue: (field: FormFieldConfig) => unknown,
|
||||
) => {
|
||||
for (const section of config.licenseType.formSchema.sections) {
|
||||
const field = section.fields.find((f) => matchField(localized(f.label).toLowerCase(), f.key));
|
||||
// English-pinned: matched against English substrings below ('nationality', 'fayda').
|
||||
const field = section.fields.find((f) => matchField((f.label.en ?? '').toLowerCase(), f.key));
|
||||
if (!field) continue;
|
||||
if (next[section.key]?.[field.key]) return; // already set — leave it
|
||||
next = { ...next, [section.key]: { ...next[section.key], [field.key]: toFieldValue(field) } };
|
||||
@@ -193,8 +197,9 @@ export function LicenseApplicationPage() {
|
||||
() =>
|
||||
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
|
||||
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
|
||||
language: i18n.language,
|
||||
}),
|
||||
[config, draft],
|
||||
[config, draft, i18n.language],
|
||||
);
|
||||
const sections = useMemo(
|
||||
() => steps.flatMap((step) => step.sections),
|
||||
@@ -234,7 +239,8 @@ export function LicenseApplicationPage() {
|
||||
// the profile Address endpoint, stores the full country name.
|
||||
const nationalityField = config?.licenseType.formSchema.sections
|
||||
.find((s) => s.key === sectionKey)
|
||||
?.fields.find((f) => f.key === 'nationality' || localized(f.label).toLowerCase().includes('nationality'));
|
||||
// English-pinned: same reasoning as the fill() matcher above.
|
||||
?.fields.find((f) => f.key === 'nationality' || (f.label.en ?? '').toLowerCase().includes('nationality'));
|
||||
if (nationalityField && values[nationalityField.key]) {
|
||||
values[nationalityField.key] = getCountryName(values[nationalityField.key] as string) || values[nationalityField.key];
|
||||
}
|
||||
@@ -256,7 +262,7 @@ export function LicenseApplicationPage() {
|
||||
async function handleSubmit() {
|
||||
setIssues([]);
|
||||
if (!readOnly && currentStep?.sections?.length) {
|
||||
const errors = validateSections(currentStep.sections, draft);
|
||||
const errors = validateSections(currentStep.sections, draft, i18n.language);
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length) {
|
||||
notifications.show({
|
||||
@@ -316,7 +322,7 @@ export function LicenseApplicationPage() {
|
||||
if (!currentStep || !config) return true;
|
||||
|
||||
if (currentStep.kind === 'sections') {
|
||||
const errors = validateSections(currentStep.sections, draft);
|
||||
const errors = validateSections(currentStep.sections, draft, i18n.language);
|
||||
setFieldErrors(errors);
|
||||
const count = Object.keys(errors).length;
|
||||
if (count > 0) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { Stack, Select, Group, Text, Loader, Center, Badge } from '@mantine/core';
|
||||
import { ErrorState } from '@ema-platform/ui';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
import { useGetLocationTypesQuery, useGetLocationsQuery } from '../api/location-api';
|
||||
import type { Location, LocationType } from '../types/location';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -16,6 +17,7 @@ interface LocationPickerProps {
|
||||
|
||||
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth }: LocationPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { data: typesRes, isLoading: typesLoading, isError: typesError, refetch: refetchTypes } = useGetLocationTypesQuery();
|
||||
const { data: locsRes, isLoading: locsLoading, isError: locsError, refetch: refetchLocs } = useGetLocationsQuery({ take: 10000 });
|
||||
|
||||
@@ -74,10 +76,10 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
|
||||
if (currentLevelChildren.length === 0) return '';
|
||||
const typeIds = [...new Set(currentLevelChildren.map((c) => c.locationTypeId))];
|
||||
const names = typeIds
|
||||
.map((id) => typeMap.get(id)?.names.en)
|
||||
.filter(Boolean) as string[];
|
||||
.map((id) => localized(typeMap.get(id)?.names))
|
||||
.filter(Boolean);
|
||||
return names.join(' / ');
|
||||
}, [currentLevelChildren, typeMap]);
|
||||
}, [currentLevelChildren, typeMap, localized]);
|
||||
|
||||
const depth = selectedChain.length;
|
||||
|
||||
@@ -116,7 +118,7 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
|
||||
if (levelIdx === 0) {
|
||||
const roots = childrenByParentId.get('__root__') ?? [];
|
||||
return roots
|
||||
.map((loc) => ({ value: loc.id, label: loc.names.en }))
|
||||
.map((loc) => ({ value: loc.id, label: localized(loc.names) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
@@ -124,7 +126,7 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
|
||||
if (!parent) return [];
|
||||
const children = childrenByParentId.get(parent.id) ?? [];
|
||||
return children
|
||||
.map((loc) => ({ value: loc.id, label: loc.names.en }))
|
||||
.map((loc) => ({ value: loc.id, label: localized(loc.names) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
};
|
||||
|
||||
@@ -134,8 +136,8 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
|
||||
if (roots.length === 0) return '';
|
||||
const typeIds = [...new Set(roots.map((r) => r.locationTypeId))];
|
||||
const names = typeIds
|
||||
.map((id) => typeMap.get(id)?.names.en)
|
||||
.filter(Boolean) as string[];
|
||||
.map((id) => localized(typeMap.get(id)?.names))
|
||||
.filter(Boolean);
|
||||
return names.join(' / ');
|
||||
}
|
||||
|
||||
@@ -144,16 +146,16 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
|
||||
const children = childrenByParentId.get(parent.id) ?? [];
|
||||
const typeIds = [...new Set(children.map((c) => c.locationTypeId))];
|
||||
const names = typeIds
|
||||
.map((id) => typeMap.get(id)?.names.en)
|
||||
.filter(Boolean) as string[];
|
||||
.map((id) => localized(typeMap.get(id)?.names))
|
||||
.filter(Boolean);
|
||||
return names.join(' / ');
|
||||
};
|
||||
|
||||
const selectedPath = useMemo(() => {
|
||||
return selectedChain
|
||||
.map((loc) => loc.names.en)
|
||||
.map((loc) => localized(loc.names))
|
||||
.join(' → ');
|
||||
}, [selectedChain]);
|
||||
}, [selectedChain, localized]);
|
||||
|
||||
if (typesLoading || locsLoading) {
|
||||
return (
|
||||
@@ -236,8 +238,8 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
|
||||
color="blue"
|
||||
style={{ textTransform: 'none' }}
|
||||
>
|
||||
{typeInfo ? `${typeInfo.names.en}: ` : ''}
|
||||
{loc.names.en}
|
||||
{typeInfo ? `${localized(typeInfo.names)}: ` : ''}
|
||||
{localized(loc.names)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconBellOff, IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetNotificationsQuery,
|
||||
useGetUnseenNotificationsQuery,
|
||||
useMarkNotificationReadMutation,
|
||||
@@ -39,6 +39,7 @@ const EMPTY_COPY: Record<Tab, string> = {
|
||||
export function NotificationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [tab, setTab] = useState<Tab>('all');
|
||||
const all = useGetNotificationsQuery(undefined, { skip: tab === 'unseen' });
|
||||
const unseen = useGetUnseenNotificationsQuery(undefined, { skip: tab !== 'unseen' });
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetMyOperatorTypesQuery,
|
||||
useUpdateMyOperatorTypesMutation,
|
||||
@@ -41,6 +41,7 @@ export function OperationsFormContent({
|
||||
const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery();
|
||||
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
|
||||
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const declaredIds = useMemo(
|
||||
() => (mine?.items ?? []).map((o) => o.licenseTypeId),
|
||||
|
||||
@@ -48,7 +48,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { useApiMutation, useLocalized } from '@ema-platform/api';
|
||||
import { setUser, useCurrentProfile } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
@@ -119,7 +119,8 @@ export function ProfilePage() {
|
||||
|
||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||
const [passwordTrigger] = useApiMutation<unknown>();
|
||||
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
|
||||
const localized = useLocalized();
|
||||
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string; am?: string } }> }>();
|
||||
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
@@ -129,7 +130,7 @@ export function ProfilePage() {
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
|
||||
// ---- Profession list (for Profile tab) ----
|
||||
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
|
||||
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string; am?: string } }>>([]);
|
||||
const [professionsLoading, setProfessionsLoading] = useState(true);
|
||||
const professionsFetched = useRef(false);
|
||||
|
||||
@@ -144,8 +145,8 @@ export function ProfilePage() {
|
||||
}, [fetchProfessions]);
|
||||
|
||||
const professionOptions = useMemo(
|
||||
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
|
||||
[professions],
|
||||
() => professions.map((p) => ({ value: p.id, label: localized(p.name) })),
|
||||
[professions, localized],
|
||||
);
|
||||
|
||||
// ---- Profile data ----
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
@@ -48,6 +48,7 @@ export function WaiverPage() {
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
|
||||
const waiverApplications = (applications?.items ?? []).filter((app) =>
|
||||
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './licensing.types';
|
||||
export * from './licensing-api';
|
||||
export * from './licensing.helpers';
|
||||
export * from './use-localized';
|
||||
|
||||
@@ -155,7 +155,9 @@ export function applicantOrCompanyName(app: LicenseApplication): string | undefi
|
||||
/** Reads a bilingual value for the active language, falling back to English. */
|
||||
export function localized(value: Bilingual | undefined, language = 'en'): string {
|
||||
if (!value) return '';
|
||||
return (language === 'am' ? value.am : value.en) ?? value.en ?? value.am ?? '';
|
||||
// `||` not `??`: an empty Amharic string is "not translated", not a value —
|
||||
// editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
|
||||
return (language === 'am' ? value.am : value.en) || value.en || value.am || '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,6 +248,9 @@ export function buildWizardSteps(
|
||||
* instead of showing an empty page.
|
||||
*/
|
||||
hasStaff?: boolean;
|
||||
/** Active UI language. Components get this from `useLocalized`; this is a
|
||||
* pure function, so the caller passes `i18n.language` through. */
|
||||
language?: string;
|
||||
},
|
||||
): WizardStep[] {
|
||||
const visible = [...sections]
|
||||
@@ -260,7 +265,7 @@ export function buildWizardSteps(
|
||||
if (!group) {
|
||||
steps.push({
|
||||
key: `section:${section.key}`,
|
||||
label: localized(section.title),
|
||||
label: localized(section.title, options?.language),
|
||||
kind: 'sections',
|
||||
sections: [section],
|
||||
});
|
||||
@@ -315,6 +320,7 @@ export type FieldErrors = Record<string, string>;
|
||||
export function validateSections(
|
||||
sections: FormSectionConfig[],
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
language = 'en',
|
||||
): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
|
||||
@@ -338,8 +344,8 @@ export function validateSections(
|
||||
if (field.required && empty) {
|
||||
errors[`${section.key}.${field.key}`] =
|
||||
field.type === 'BOOLEAN'
|
||||
? `${localized(field.label)} must be accepted`
|
||||
: `${localized(field.label)} is required`;
|
||||
? `${localized(field.label, language)} must be accepted`
|
||||
: `${localized(field.label, language)} is required`;
|
||||
continue;
|
||||
}
|
||||
if (empty) continue;
|
||||
|
||||
27
libs/api/src/lib/features/licensing/use-localized.ts
Normal file
27
libs/api/src/lib/features/licensing/use-localized.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { localized } from './licensing.helpers';
|
||||
import type { Bilingual } from './licensing.types';
|
||||
|
||||
/**
|
||||
* The component-facing bilingual reader — the twin of `useDateDisplayer()`.
|
||||
*
|
||||
* A hook rather than a bare `localized` import so the calling component is
|
||||
* subscribed to i18next: switching language re-renders it and every
|
||||
* backend-configured label flips with the rest of the UI. `useTranslation()`
|
||||
* with no instance argument resolves to the app's own <I18nextProvider>
|
||||
* (portal router.tsx, backoffice AppProviders.tsx), which is what makes this
|
||||
* work across two separate i18n instances.
|
||||
*
|
||||
* DISPLAY ONLY. Code that *matches* on a label — `.includes('nationality')`,
|
||||
* the vessel-picker regex in ConfigDrivenSection, the SUBCITY/WOREDA test in
|
||||
* AddressFormContent — must keep reading `value.en`, or the match breaks the
|
||||
* moment the user switches language.
|
||||
*
|
||||
* The returned function is stable per language, so it is safe — and required —
|
||||
* as a useMemo/useCallback dependency.
|
||||
*/
|
||||
export function useLocalized(): (value: Bilingual | undefined) => string {
|
||||
const { i18n } = useTranslation();
|
||||
return useCallback((value) => localized(value, i18n.language), [i18n.language]);
|
||||
}
|
||||
Reference in New Issue
Block a user