mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-06 14:45:05 +00:00
Merge remote-tracking branch 'origin/dev' into fix/coc-exam-workflow-defects
# Conflicts: # apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx # libs/api/src/lib/features/licensing/licensing-api.ts # libs/api/src/lib/features/licensing/licensing.types.ts
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconFingerprint, IconScan, IconSearch, IconX } from '@tabler/icons-react';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useEnrollBiometricMutation,
|
||||
useGenerateBsidMutation,
|
||||
useGetBiometricEnrollmentsQuery,
|
||||
useGetBiometricSimulateCapabilitiesQuery,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
useRevokeBiometricEnrollmentMutation,
|
||||
type BiometricModality,
|
||||
type SeafarerRegistration,
|
||||
} from '@ema-platform/api';
|
||||
import { notify, PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const MODALITIES: { value: BiometricModality; label: string }[] = [
|
||||
{ value: 'FINGERPRINT', label: 'Fingerprint' },
|
||||
{ value: 'FACE', label: 'Face' },
|
||||
];
|
||||
|
||||
function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middleName' | 'lastName'>): string {
|
||||
return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—';
|
||||
}
|
||||
|
||||
/**
|
||||
* No scanner is wired yet (US-BIO placeholder): "Simulate Scan" stands in for
|
||||
* the real vendor SDK capture, producing a random template so the rest of the
|
||||
* pipeline — encrypt, store, print — is exercisable end to end. Swap the
|
||||
* simulated bytes for the SDK's real template once a vendor is chosen; the
|
||||
* API call shape (base64 template + format tag) does not change.
|
||||
*/
|
||||
function fakeTemplate(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(64));
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a seafarer waiting on enrolment.
|
||||
*
|
||||
* AWAITING_BIOMETRICS only: enrolment is the step that unblocks the review, so
|
||||
* this queue is exactly the registrations held for it. An approved seafarer has
|
||||
* already been through here — listing them would invite a second capture of
|
||||
* someone who is finished.
|
||||
*/
|
||||
function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced] = useDebouncedValue(search, 300);
|
||||
const { data, isFetching } = useListSeafarerRegistrationsQuery({
|
||||
status: 'AWAITING_BIOMETRICS',
|
||||
search: debounced || undefined,
|
||||
take: 10,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<TextInput
|
||||
placeholder="Search seafarers awaiting enrolment…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
mb="sm"
|
||||
/>
|
||||
{isFetching && <Loader size="sm" />}
|
||||
<Table highlightOnHover fz="sm">
|
||||
<Table.Tbody>
|
||||
{(data?.items ?? []).map((r) => (
|
||||
<Table.Tr key={r.id} onClick={() => onPick(r)} style={{ cursor: 'pointer' }}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={600}>{applicantName(r)}</Text>
|
||||
{/* Not seafarerNumber: that is only issued on approval, which
|
||||
is downstream of this screen, so it is always blank here. */}
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{r.registrationNumber}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{!isFetching && (data?.items ?? []).length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">No seafarer is waiting on enrolment.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backoffice counter screen: enroll a scanner capture against a profile, view what's on file, print the slip. */
|
||||
export function BiometricEnrollmentPage() {
|
||||
const showDate = useDateDisplayer();
|
||||
const [selected, setSelected] = useState<SeafarerRegistration | null>(null);
|
||||
const [modality, setModality] = useState<BiometricModality>('FINGERPRINT');
|
||||
const [deviceId, setDeviceId] = useState('');
|
||||
|
||||
const profileId = selected?.profileId ?? '';
|
||||
const { data: enrollments, isLoading } = useGetBiometricEnrollmentsQuery(profileId, { skip: !profileId });
|
||||
// No vendor SDK integrated yet — "Simulate Scan" fakes a capture so the
|
||||
// rest of the flow is exercisable. Reports false in production unless
|
||||
// ALLOW_BIOMETRIC_SIMULATION=true, same shortcut the payment bypass uses.
|
||||
const { data: capabilities } = useGetBiometricSimulateCapabilitiesQuery();
|
||||
const simulateEnabled = capabilities?.simulateEnabled ?? false;
|
||||
const [generateBsid, { isLoading: generatingBsid }] = useGenerateBsidMutation();
|
||||
// Seeded from the seafarer registration list (which does not carry BSID
|
||||
// yet) and updated locally once generated — this screen's only source of
|
||||
// truth for it until the registry surfaces the profile's BSID directly.
|
||||
const [bsid, setBsid] = useState<string | null>(null);
|
||||
const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation();
|
||||
const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation();
|
||||
|
||||
const hasActive = useMemo(
|
||||
() => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m),
|
||||
[enrollments],
|
||||
);
|
||||
|
||||
async function handleEnroll() {
|
||||
if (!profileId) return;
|
||||
try {
|
||||
await enroll({
|
||||
profileId,
|
||||
modality,
|
||||
template: fakeTemplate(),
|
||||
templateFormat: 'SIMULATED',
|
||||
deviceId: deviceId || undefined,
|
||||
consentAt: new Date().toISOString(),
|
||||
}).unwrap();
|
||||
notify.success(`${modality === 'FINGERPRINT' ? 'Fingerprint' : 'Face'} enrolled.`);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Enrollment failed.'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateBsid() {
|
||||
if (!profileId) return;
|
||||
try {
|
||||
const result = await generateBsid(profileId).unwrap();
|
||||
setBsid(result.bsid);
|
||||
notify.success(`BSID ${result.bsid} generated.`);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not generate BSID.'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(id: string) {
|
||||
if (!profileId) return;
|
||||
try {
|
||||
await revoke({ id, profileId, reason: 'Withdrawn at counter' }).unwrap();
|
||||
notify.success('Enrollment revoked.');
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not revoke.'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="md" py="md">
|
||||
<PageHeader
|
||||
title="Biometric Enrollment"
|
||||
subtitle="Capture a fingerprint or face template for a seafarer awaiting enrolment, then issue their BSID."
|
||||
/>
|
||||
|
||||
{!selected ? (
|
||||
<ProfilePicker
|
||||
onPick={(r) => {
|
||||
setSelected(r);
|
||||
setBsid(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{applicantName(selected)}</Text>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{selected.registrationNumber}</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setBsid(null);
|
||||
}}
|
||||
>
|
||||
Change seafarer
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="sm" fw={600} mb="sm">Capture</Text>
|
||||
{simulateEnabled ? (
|
||||
<>
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />} mb="sm" variant="light">
|
||||
No scanner is wired yet — this simulates a capture so the rest of the flow can be tested.
|
||||
</Alert>
|
||||
<Group align="flex-end">
|
||||
<Select label="Modality" data={MODALITIES} value={modality} onChange={(v) => setModality((v as BiometricModality) ?? 'FINGERPRINT')} w={160} />
|
||||
<TextInput label="Device (optional)" placeholder="scanner-01" value={deviceId} onChange={(e) => setDeviceId(e.currentTarget.value)} w={180} />
|
||||
<Button leftSection={<IconScan size={16} />} onClick={handleEnroll} loading={enrolling}>
|
||||
Simulate Scan & Enroll
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Alert color="gray" icon={<IconAlertTriangle size={16} />} variant="light">
|
||||
No scanner is wired yet, and capture simulation is off in this environment.
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="sm" fw={600} mb="sm">Biometric Subject ID (BSID)</Text>
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
Required before this registration can be approved. Generating it is final —
|
||||
confirm the capture is good first.
|
||||
</Text>
|
||||
<Group justify="space-between">
|
||||
{bsid ? (
|
||||
<StatusBadge tone="success" label={`BSID ${bsid}`} />
|
||||
) : (
|
||||
<Badge color="gray" variant="light">Not generated</Badge>
|
||||
)}
|
||||
{!bsid && (
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleGenerateBsid}
|
||||
loading={generatingBsid}
|
||||
disabled={!hasActive('FINGERPRINT') && !hasActive('FACE')}
|
||||
>
|
||||
Generate BSID
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="sm" fw={600} mb="sm">On file</Text>
|
||||
{isLoading ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{MODALITIES.map((m) => (
|
||||
<Group key={m.value} justify="space-between" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color={hasActive(m.value) ? 'teal' : 'gray'} size={30} radius="md">
|
||||
<IconFingerprint size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">{m.label}</Text>
|
||||
</Group>
|
||||
{hasActive(m.value) ? (
|
||||
<Group gap="xs">
|
||||
<StatusBadge tone="success" label="Enrolled" />
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
loading={revoking}
|
||||
onClick={() => {
|
||||
const row = (enrollments ?? []).find((e) => e.modality === m.value);
|
||||
if (row) handleRevoke(row.id);
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Badge color="gray" variant="light">Not enrolled</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
{(enrollments ?? []).map((e) => (
|
||||
<Text key={e.id} fz="xs" c="dimmed">
|
||||
{e.modality} captured {showDate(e.enrolledAt)}{e.deviceId ? ` · ${e.deviceId}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default BiometricEnrollmentPage;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
|
||||
import { Button, Group, Select, Stack, Text } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api';
|
||||
@@ -12,12 +12,11 @@ interface Props {
|
||||
ranks: Rank[];
|
||||
rankId: string | null;
|
||||
onRankChange: (id: string | null) => void;
|
||||
/** Shown for context only; edited on Certificate Requirements → Behaviour. */
|
||||
validityMonths: number;
|
||||
onValidityChange: (months: number) => void;
|
||||
currentValidityMonths?: number | null;
|
||||
/** Non-null when the type is configured in days rather than months. */
|
||||
validityDays?: number | null;
|
||||
canEdit: boolean;
|
||||
savingValidity: boolean;
|
||||
onSaveValidity: () => void;
|
||||
onNewVersion: () => void;
|
||||
}
|
||||
|
||||
@@ -30,11 +29,8 @@ export function DesignerToolbar({
|
||||
rankId,
|
||||
onRankChange,
|
||||
validityMonths,
|
||||
onValidityChange,
|
||||
currentValidityMonths,
|
||||
validityDays,
|
||||
canEdit,
|
||||
savingValidity,
|
||||
onSaveValidity,
|
||||
onNewVersion,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -73,38 +69,31 @@ export function DesignerToolbar({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Validity lives beside the design because it is the other half of
|
||||
what a certificate promises. */}
|
||||
<NumberInput
|
||||
label={t('designer.validityYears', 'Valid for (years)')}
|
||||
description={t('designer.validityHint', 'Applied when a licence is issued')}
|
||||
value={Number((validityMonths / 12).toFixed(2))}
|
||||
onChange={(value) => onValidityChange(Math.round(Number(value || 0) * 12))}
|
||||
min={0.5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
decimalScale={1}
|
||||
w={190}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<Tooltip
|
||||
label={
|
||||
canEdit
|
||||
? t('designer.saveValidity', 'Save validity')
|
||||
: t('designer.noPermission', 'You do not have permission')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="light"
|
||||
loading={savingValidity}
|
||||
disabled={!canEdit || !typeId || validityMonths === currentValidityMonths}
|
||||
onClick={onSaveValidity}
|
||||
>
|
||||
{t('designer.saveValidity', 'Save validity')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/* Read-only here. Validity is one policy decision with the renewal
|
||||
window and the expiry reminders, so it is edited in one place —
|
||||
Certificate Requirements → Behaviour — rather than from two screens
|
||||
behind two different permissions. Still shown, because a designer
|
||||
laying out a certificate that prints an expiry needs to see the term
|
||||
it promises. */}
|
||||
{typeId && (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{t('designer.validity', 'Valid for')}
|
||||
</Text>
|
||||
<Group gap={6} align="baseline">
|
||||
<Text size="sm" fw={600}>
|
||||
{validityDays != null
|
||||
? t('designer.validityDays', '{{count}} days', { count: validityDays })
|
||||
: t('designer.validityMonths', '{{count}} months', {
|
||||
count: validityMonths,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ema-platform/api';
|
||||
|
||||
import { BASE_API_URL } from '@ema-platform/api';
|
||||
|
||||
/** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */
|
||||
export const API_BASE_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
export const API_BASE_URL = BASE_API_URL;
|
||||
|
||||
export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
useGetRanksQuery,
|
||||
useGetTemplateVariablesQuery,
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
|
||||
@@ -88,7 +87,6 @@ export function CertificateDesignerPage() {
|
||||
const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation();
|
||||
const [archiveTemplate] = useArchiveLicenseTemplateMutation();
|
||||
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
|
||||
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
|
||||
|
||||
const draft = useTemplateDraft(templates);
|
||||
const run = useDesignerActions();
|
||||
@@ -96,7 +94,6 @@ export function CertificateDesignerPage() {
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [validityMonths, setValidityMonths] = useState<number>(12);
|
||||
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
|
||||
|
||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
@@ -127,10 +124,6 @@ export function CertificateDesignerPage() {
|
||||
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
|
||||
}, [licenseTypes, typeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
||||
}, [selectedType]);
|
||||
|
||||
// Switching licence type leaves a stale rank selected from the previous
|
||||
// type's ladder — reset to the type's default design.
|
||||
useEffect(() => {
|
||||
@@ -169,17 +162,9 @@ export function CertificateDesignerPage() {
|
||||
setRankId(value);
|
||||
draft.setSelectedId(null);
|
||||
}}
|
||||
validityMonths={validityMonths}
|
||||
onValidityChange={setValidityMonths}
|
||||
currentValidityMonths={selectedType?.validityMonths}
|
||||
validityMonths={selectedType?.validityMonths ?? 12}
|
||||
validityDays={selectedType?.validityDays ?? null}
|
||||
canEdit={canEdit}
|
||||
savingValidity={savingValidity}
|
||||
onSaveValidity={() =>
|
||||
run(
|
||||
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
|
||||
t('designer.validitySaved', 'Validity updated'),
|
||||
)
|
||||
}
|
||||
onNewVersion={startNewVersion}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LICENSE_PERMISSIONS, usePermissions } from '@ema-platform/auth';
|
||||
import {
|
||||
useUpdateLicenseBehaviorMutation,
|
||||
type CertificateCategory,
|
||||
type CompletionEffect,
|
||||
type LicenseType,
|
||||
type ServiceKind,
|
||||
type WorkflowProfile,
|
||||
} from '@ema-platform/api';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
|
||||
/** The shape the form edits — every field the behaviour endpoint accepts. */
|
||||
interface Draft {
|
||||
workflowProfile: WorkflowProfile;
|
||||
serviceKind: ServiceKind;
|
||||
completionEffect: CompletionEffect | null;
|
||||
certificateCategory: CertificateCategory | null;
|
||||
requiresExamination: boolean;
|
||||
inspectionRequired: boolean;
|
||||
issuesCertificate: boolean;
|
||||
renewalEnabled: boolean;
|
||||
requiresSeafarerRegistration: boolean;
|
||||
requiresValidMedical: boolean;
|
||||
minSeaTimeDays: number | null;
|
||||
capitalThreshold: number | null;
|
||||
validityMonths: number;
|
||||
validityDays: number | null;
|
||||
renewalWindowDays: number;
|
||||
expiryReminderDays: number[];
|
||||
requiresOperatorMode: boolean;
|
||||
allowMultipleOpenDrafts: boolean;
|
||||
requiresIssuanceScheduling: boolean;
|
||||
uniqueFormKeyPath: string | null;
|
||||
slaHours: number | null;
|
||||
}
|
||||
|
||||
/** Offsets EMA reminds on. Fixed rather than free-form — these are policy, not arithmetic. */
|
||||
const REMINDER_OFFSETS = ['90', '60', '30', '14', '7'];
|
||||
|
||||
function toDraft(licenseType: LicenseType): Draft {
|
||||
return {
|
||||
workflowProfile: licenseType.workflowProfile ?? 'STANDARD',
|
||||
serviceKind: licenseType.serviceKind ?? 'LICENSE',
|
||||
completionEffect: licenseType.completionEffect ?? null,
|
||||
certificateCategory: licenseType.certificateCategory ?? null,
|
||||
requiresExamination: licenseType.requiresExamination ?? false,
|
||||
inspectionRequired: licenseType.inspectionRequired ?? true,
|
||||
issuesCertificate: licenseType.issuesCertificate ?? true,
|
||||
renewalEnabled: licenseType.renewalEnabled ?? true,
|
||||
requiresSeafarerRegistration:
|
||||
licenseType.requiresSeafarerRegistration ?? false,
|
||||
requiresValidMedical: licenseType.requiresValidMedical ?? false,
|
||||
minSeaTimeDays: licenseType.minSeaTimeDays ?? null,
|
||||
capitalThreshold:
|
||||
licenseType.capitalThreshold == null
|
||||
? null
|
||||
: Number(licenseType.capitalThreshold),
|
||||
validityMonths: licenseType.validityMonths ?? 12,
|
||||
validityDays: licenseType.validityDays ?? null,
|
||||
renewalWindowDays: licenseType.renewalWindowDays ?? 60,
|
||||
expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7],
|
||||
requiresOperatorMode: licenseType.requiresOperatorMode ?? true,
|
||||
allowMultipleOpenDrafts: licenseType.allowMultipleOpenDrafts ?? false,
|
||||
requiresIssuanceScheduling: licenseType.requiresIssuanceScheduling ?? false,
|
||||
uniqueFormKeyPath: licenseType.uniqueFormKeyPath ?? null,
|
||||
slaHours: licenseType.slaHours ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How one licence type behaves: the course it runs, who may apply, when it
|
||||
* renews and what rules the applicant meets.
|
||||
*
|
||||
* These were seed-only until now — changing an SLA target or a renewal window
|
||||
* meant editing a file and redeploying. They are read live off the licence
|
||||
* type rather than snapshotted onto applications, so a change here applies to
|
||||
* files already in the queue as well as new ones. For the three settings that
|
||||
* decide an application's course the server refuses the change outright while
|
||||
* anything is still awaiting a decision, rather than stranding it.
|
||||
*/
|
||||
export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
const { t } = useTranslation();
|
||||
const run = useRequirementActions();
|
||||
const { can } = usePermissions();
|
||||
const canEdit = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]);
|
||||
|
||||
const [save, { isLoading: saving }] = useUpdateLicenseBehaviorMutation();
|
||||
const [draft, setDraft] = useState<Draft>(() => toDraft(licenseType));
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// Keyed on the id alone, like FormSchemaTab: this tab's own save invalidates
|
||||
// the licence-type list, and the refetch that follows must not overwrite an
|
||||
// edit the administrator is still working on.
|
||||
useEffect(() => {
|
||||
setDraft(toDraft(licenseType));
|
||||
setDirty(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [licenseType.id]);
|
||||
|
||||
function set<K extends keyof Draft>(key: K, value: Draft[K]) {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
const ok = await run(
|
||||
() => save({ id: licenseType.id, ...draft }).unwrap(),
|
||||
t('certReq.behavior.saved', 'Configuration saved.'),
|
||||
);
|
||||
if (ok) setDirty(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Section title={t('certReq.behavior.workflow', 'Workflow')}>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'certReq.behavior.workflowWarning',
|
||||
'These three settings decide the course an application runs. They cannot be changed while applications of this type are still awaiting a decision — saving will be refused until those are decided.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Select
|
||||
label={t('certReq.behavior.workflowProfile', 'Workflow profile')}
|
||||
description={t(
|
||||
'certReq.behavior.workflowProfileHint',
|
||||
'REGISTRATION drops the evaluation and inspection stages.',
|
||||
)}
|
||||
data={[
|
||||
{ value: 'STANDARD', label: t('certReq.behavior.standard', 'Standard licence course') },
|
||||
{ value: 'REGISTRATION', label: t('certReq.behavior.registration', 'Registration (review only)') },
|
||||
]}
|
||||
value={draft.workflowProfile}
|
||||
onChange={(v) => v && set('workflowProfile', v as WorkflowProfile)}
|
||||
allowDeselect={false}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('certReq.behavior.completionEffect', 'Completion effect')}
|
||||
description={t(
|
||||
'certReq.behavior.completionEffectHint',
|
||||
'Platform action taken when an application of this type completes.',
|
||||
)}
|
||||
data={[
|
||||
{ value: 'REGISTER_SEAFARER', label: t('certReq.behavior.registerSeafarer', 'Register the seafarer') },
|
||||
{ value: 'REGISTER_VESSEL', label: t('certReq.behavior.registerVessel', 'Register the vessel') },
|
||||
{ value: 'OPEN_SEAFARER_DOCUMENTS', label: t('certReq.behavior.openDocuments', 'Open seafarer documents') },
|
||||
]}
|
||||
value={draft.completionEffect}
|
||||
onChange={(v) => set('completionEffect', (v as CompletionEffect) ?? null)}
|
||||
placeholder={t('certReq.behavior.noEffect', 'No side effect')}
|
||||
clearable
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.requiresExamination}
|
||||
onChange={(e) => set('requiresExamination', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.requiresExamination', 'Requires an examination')}
|
||||
description={t(
|
||||
'certReq.behavior.requiresExaminationHint',
|
||||
'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.issuesCertificate}
|
||||
onChange={(e) => set('issuesCertificate', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.issuesCertificate', 'Issues a certificate')}
|
||||
description={t(
|
||||
'certReq.behavior.issuesCertificateHint',
|
||||
'Turn off for a type that ends with an EMA decision and never reaches a payment stage. Turning it back on requires a new-application fee to be set, or approved applicants would be asked for a fee that does not exist.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('certReq.behavior.serviceKind', 'Service kind')}
|
||||
description={t(
|
||||
'certReq.behavior.serviceKindHint',
|
||||
'Catalogue classification only — no workflow depends on it.',
|
||||
)}
|
||||
data={[
|
||||
{ value: 'LICENSE', label: t('certReq.behavior.license', 'Licence') },
|
||||
{ value: 'REGISTRATION', label: t('certReq.behavior.registrationKind', 'Registration') },
|
||||
]}
|
||||
value={draft.serviceKind}
|
||||
onChange={(v) => v && set('serviceKind', v as ServiceKind)}
|
||||
allowDeselect={false}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title={t('certReq.behavior.eligibility', 'Eligibility gates')}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'certReq.behavior.eligibilityHint',
|
||||
'Checked when an applicant submits, and the capital requirement again when an officer approves. Tightening a gate can stop someone mid-way from submitting a draft they have already started.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<NullableNumber
|
||||
label={t('certReq.behavior.capitalThreshold', 'Minimum paid-up capital')}
|
||||
switchLabel={t('certReq.behavior.capitalOn', 'Require a minimum paid-up capital')}
|
||||
description={t(
|
||||
'certReq.behavior.capitalHint',
|
||||
'An officer cannot approve until they have verified capital at or above this. Applies to applications already in the queue, not just new ones.',
|
||||
)}
|
||||
value={draft.capitalThreshold}
|
||||
onChange={(v) => set('capitalThreshold', v)}
|
||||
disabled={!canEdit}
|
||||
min={0}
|
||||
defaultValue={1_000_000}
|
||||
thousandSeparator
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.requiresSeafarerRegistration}
|
||||
onChange={(e) => set('requiresSeafarerRegistration', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.requiresSeafarer', 'Requires an active seafarer registration')}
|
||||
description={t(
|
||||
'certReq.behavior.requiresSeafarerHint',
|
||||
'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.requiresValidMedical}
|
||||
onChange={(e) => set('requiresValidMedical', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.requiresMedical', 'Requires a current medical certificate')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<NullableNumber
|
||||
label={t('certReq.behavior.minSeaTime', 'Minimum sea time (days)')}
|
||||
switchLabel={t('certReq.behavior.minSeaTimeOn', 'Require verified sea time')}
|
||||
value={draft.minSeaTimeDays}
|
||||
onChange={(v) => set('minSeaTimeDays', v)}
|
||||
disabled={!canEdit}
|
||||
min={0}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('certReq.behavior.certificateCategory', 'Certificate category')}
|
||||
description={t(
|
||||
'certReq.behavior.certificateCategoryHint',
|
||||
'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.',
|
||||
)}
|
||||
data={['COC', 'COP', 'ENDORSEMENT', 'GOC', 'NATIONAL'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
value={draft.certificateCategory}
|
||||
onChange={(v) => set('certificateCategory', (v as CertificateCategory) ?? null)}
|
||||
placeholder={t('certReq.behavior.notACertificate', 'Not a certificate')}
|
||||
clearable
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title={t('certReq.behavior.renewal', 'Validity and renewal')}>
|
||||
{/* Amount + unit rather than a years box that silently divides by 12:
|
||||
the seed says `validityMonths: 12` and this now says "12 Months",
|
||||
so the two read the same. Months advance the calendar (issued on
|
||||
the 31st, expires on the 31st); days are for terms shorter than a
|
||||
month can express. */}
|
||||
<Group align="flex-end" gap="sm" wrap="nowrap">
|
||||
<NumberInput
|
||||
label={t('certReq.behavior.validity', 'Valid for')}
|
||||
description={t(
|
||||
'certReq.behavior.validityHint',
|
||||
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
|
||||
)}
|
||||
value={draft.validityDays ?? draft.validityMonths}
|
||||
onChange={(v) => {
|
||||
const next = typeof v === 'number' ? v : 0;
|
||||
if (!next) return;
|
||||
if (draft.validityDays !== null) set('validityDays', next);
|
||||
else set('validityMonths', next);
|
||||
}}
|
||||
// Matches the server's ranges, so the box cannot offer a value the
|
||||
// save would reject: 1–3650 days, or 6–240 months.
|
||||
min={draft.validityDays !== null ? 1 : 6}
|
||||
max={draft.validityDays !== null ? 3650 : 240}
|
||||
allowNegative={false}
|
||||
disabled={!canEdit}
|
||||
flex={1}
|
||||
/>
|
||||
<Select
|
||||
aria-label={t('certReq.behavior.validityUnit', 'Validity unit')}
|
||||
data={[
|
||||
{ value: 'MONTHS', label: t('certReq.behavior.unitMonths', 'Months') },
|
||||
{ value: 'DAYS', label: t('certReq.behavior.unitDays', 'Days') },
|
||||
]}
|
||||
value={draft.validityDays !== null ? 'DAYS' : 'MONTHS'}
|
||||
onChange={(unit) => {
|
||||
// Switching unit is a change of policy, not a conversion: 12
|
||||
// calendar months is not 365 days, so carry no arithmetic across
|
||||
// and let the administrator state the new term outright.
|
||||
if (unit === 'DAYS') set('validityDays', draft.validityDays ?? 90);
|
||||
else set('validityDays', null);
|
||||
}}
|
||||
allowDeselect={false}
|
||||
disabled={!canEdit}
|
||||
w={130}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Switch
|
||||
checked={draft.renewalEnabled}
|
||||
onChange={(e) => set('renewalEnabled', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.renewalEnabled', 'Holders may renew this licence')}
|
||||
description={t(
|
||||
'certReq.behavior.renewalEnabledHint',
|
||||
'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
{/* The window and the reminders are both measured against an expiry a
|
||||
non-renewing licence never reaches, so they are hidden rather than
|
||||
shown as settings that quietly do nothing. */}
|
||||
{draft.renewalEnabled && (
|
||||
<>
|
||||
<NumberInput
|
||||
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}
|
||||
value={draft.renewalWindowDays}
|
||||
onChange={(v) =>
|
||||
set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
|
||||
}
|
||||
min={1}
|
||||
max={365}
|
||||
allowNegative={false}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label={t('certReq.behavior.reminders', 'Expiry reminders (days before)')}
|
||||
description={t(
|
||||
'certReq.behavior.remindersHint',
|
||||
'The holder is reminded at each of these offsets.',
|
||||
)}
|
||||
data={REMINDER_OFFSETS}
|
||||
value={draft.expiryReminderDays.map(String)}
|
||||
onChange={(values) =>
|
||||
set(
|
||||
'expiryReminderDays',
|
||||
values.map(Number).sort((a, b) => b - a),
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
clearable
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t('certReq.behavior.applicantRules', 'Applicant rules')}>
|
||||
<Switch
|
||||
checked={draft.requiresOperatorMode}
|
||||
onChange={(e) => set('requiresOperatorMode', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.requiresOperatorMode', 'Applicant must declare this operating mode')}
|
||||
description={t(
|
||||
'certReq.behavior.requiresOperatorModeHint',
|
||||
'Turn off for person-centric registrations any signed-in applicant may start.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.allowMultipleOpenDrafts}
|
||||
onChange={(e) => set('allowMultipleOpenDrafts', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.allowMultipleDrafts', 'Allow several open drafts at once')}
|
||||
description={t(
|
||||
'certReq.behavior.allowMultipleDraftsHint',
|
||||
'On for per-asset registrations — registering a second vessel must not resume the first one’s draft.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.inspectionRequired}
|
||||
onChange={(e) => set('inspectionRequired', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.inspectionRequired', 'Requires a physical inspection')}
|
||||
description={t(
|
||||
'certReq.behavior.inspectionRequiredHint',
|
||||
'An officer cannot approve until an inspection has been recorded and passed. Safe to change with applications in progress — an officer can still assign an inspector to a file already under evaluation.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.requiresIssuanceScheduling}
|
||||
onChange={(e) => set('requiresIssuanceScheduling', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.requiresScheduling', 'Schedule a pickup date before issuing')}
|
||||
description={t(
|
||||
'certReq.behavior.requiresSchedulingHint',
|
||||
'For documents printed once and handed over in person.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<NullableNumber
|
||||
label={t('certReq.behavior.slaHours', 'Decision target (hours)')}
|
||||
switchLabel={t('certReq.behavior.slaOn', 'Track against an SLA')}
|
||||
value={draft.slaHours}
|
||||
onChange={(v) => set('slaHours', v)}
|
||||
disabled={!canEdit}
|
||||
min={1}
|
||||
description={t(
|
||||
'certReq.behavior.slaHint',
|
||||
'Drives the Age/SLA column and the Overdue view. Applies to applications already submitted as well as new ones.',
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t('certReq.behavior.uniqueFormKey', 'One application per answer at')}
|
||||
description={t(
|
||||
'certReq.behavior.uniqueFormKeyHint',
|
||||
'Dotted form path, e.g. shipment.billOfLading. The field must exist in this type’s form, or no application can be submitted.',
|
||||
)}
|
||||
value={draft.uniqueFormKeyPath ?? ''}
|
||||
onChange={(e) =>
|
||||
set('uniqueFormKeyPath', e.currentTarget.value.trim() || null)
|
||||
}
|
||||
maxLength={128}
|
||||
placeholder={t('certReq.behavior.noUniqueRule', 'No uniqueness rule')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Tooltip
|
||||
label={t('certReq.behavior.noPermission', 'You do not have permission to change licence configuration.')}
|
||||
disabled={canEdit}
|
||||
>
|
||||
<Button
|
||||
leftSection={<IconDeviceFloppy size={16} />}
|
||||
onClick={onSave}
|
||||
loading={saving}
|
||||
disabled={!canEdit || !dirty}
|
||||
>
|
||||
{t('certReq.behavior.save', 'Save configuration')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number that may be switched off entirely.
|
||||
*
|
||||
* Null is a real configuration state here — "not tracked against an SLA", "no
|
||||
* sea-time floor" — and is not the same as an empty box, so the choice gets its
|
||||
* own switch rather than being inferred from a blank field.
|
||||
*/
|
||||
function NullableNumber({
|
||||
label,
|
||||
switchLabel,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
min,
|
||||
defaultValue,
|
||||
thousandSeparator,
|
||||
}: {
|
||||
label: string;
|
||||
switchLabel: string;
|
||||
description?: string;
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
disabled: boolean;
|
||||
min: number;
|
||||
/** Seeded when the switch is turned on. Defaults to the minimum. */
|
||||
defaultValue?: number;
|
||||
thousandSeparator?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
checked={value !== null}
|
||||
onChange={(e) =>
|
||||
onChange(e.currentTarget.checked ? (defaultValue ?? min ?? 1) : null)
|
||||
}
|
||||
label={switchLabel}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value !== null && (
|
||||
<NumberInput
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(v) => onChange(typeof v === 'number' ? v : value)}
|
||||
min={min}
|
||||
allowNegative={false}
|
||||
thousandSeparator={thousandSeparator ? ',' : undefined}
|
||||
decimalScale={thousandSeparator ? 2 : undefined}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -198,7 +198,7 @@ function ConditionArmFields({
|
||||
fz="xs"
|
||||
px={6}
|
||||
py={2}
|
||||
bg="var(--mantine-color-gray-1)"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ borderRadius: 4, cursor: 'pointer' }}
|
||||
onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
|
||||
title={t('certReq.condition.removeValue', 'Click to remove')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Drawer,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -13,33 +14,103 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
|
||||
import type { ApplicationKind, DocumentRequirement, FormSchemaPalette } from '@ema-platform/api';
|
||||
import {
|
||||
useLocalized,
|
||||
type ApplicationKind,
|
||||
type DocumentRequirement,
|
||||
type FormSchemaPalette,
|
||||
type LicenseType,
|
||||
} from '@ema-platform/api';
|
||||
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
|
||||
import type { ConditionTarget } from '../config/schema-paths';
|
||||
|
||||
/**
|
||||
* File types a slot may be opened to.
|
||||
*
|
||||
* Longer than the three a slot starts with, because what an applicant
|
||||
* actually has is not always a scan: a phone photographs an ID as HEIC, a
|
||||
* scanner writes multi-page TIFF, and an academic record often arrives as the
|
||||
* Word file its institution issued. Widening a slot stays a deliberate choice
|
||||
* — the defaults below do not change — but it no longer needs a release.
|
||||
*/
|
||||
const MIME_OPTIONS = [
|
||||
{ value: 'application/pdf', label: 'PDF' },
|
||||
{ value: 'image/jpeg', label: 'JPEG' },
|
||||
{ value: 'image/png', label: 'PNG' },
|
||||
{ value: 'image/webp', label: 'WebP' },
|
||||
{ value: 'image/heic', label: 'HEIC (iPhone photo)' },
|
||||
{ value: 'image/heif', label: 'HEIF' },
|
||||
{ value: 'image/tiff', label: 'TIFF (scan)' },
|
||||
{ value: 'image/bmp', label: 'BMP' },
|
||||
{ value: 'application/msword', label: 'Word (.doc)' },
|
||||
{
|
||||
value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
label: 'Word (.docx)',
|
||||
},
|
||||
{ value: 'application/vnd.ms-excel', label: 'Excel (.xls)' },
|
||||
{
|
||||
value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
label: 'Excel (.xlsx)',
|
||||
},
|
||||
{ value: 'text/csv', label: 'CSV' },
|
||||
{ value: 'text/plain', label: 'Plain text (.txt)' },
|
||||
// Video is measured in hundreds of megabytes, not the 5 MB a slot starts
|
||||
// with: raise "Max file size" on any slot that accepts one.
|
||||
{ value: 'video/mp4', label: 'Video (.mp4)' },
|
||||
{ value: 'video/quicktime', label: 'Video (.mov, iPhone)' },
|
||||
{ value: 'video/webm', label: 'Video (.webm)' },
|
||||
{ value: 'video/x-msvideo', label: 'Video (.avi)' },
|
||||
{ value: 'audio/mpeg', label: 'Audio (.mp3)' },
|
||||
{ value: 'audio/wav', label: 'Audio (.wav)' },
|
||||
// Both, because the same .m4a is reported as audio/mp4 by Chrome and
|
||||
// audio/x-m4a by Safari; picking one would reject half the recordings.
|
||||
{ value: 'audio/mp4', label: 'Audio (.m4a)' },
|
||||
{ value: 'audio/x-m4a', label: 'Audio (.m4a, Safari)' },
|
||||
{ value: 'audio/ogg', label: 'Audio (.ogg)' },
|
||||
];
|
||||
|
||||
/** What a new slot accepts until someone widens it. */
|
||||
const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
|
||||
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||
|
||||
function emptyDraft(applicationKind: ApplicationKind): DraftRequirement {
|
||||
/**
|
||||
* Which licences a personal document is asked for.
|
||||
*
|
||||
* Empty means every licence (stored as a row with no licence type); otherwise
|
||||
* one row per chosen type, all sharing the key. The applicant sees one slot
|
||||
* either way — the portal collapses the rows by key — and only if they have
|
||||
* declared operating as one of the types.
|
||||
*/
|
||||
export type PersonalScope = string[];
|
||||
|
||||
function emptyDraft(applicationKind: ApplicationKind, personal: boolean): DraftRequirement {
|
||||
return {
|
||||
key: '',
|
||||
name: { en: '', am: '' },
|
||||
applicationKind,
|
||||
mode: 'ALWAYS',
|
||||
allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
|
||||
// A personal document is never demanded by one application, so "always
|
||||
// required" would be a promise nothing here can keep.
|
||||
mode: personal ? 'OPTIONAL' : 'ALWAYS',
|
||||
allowedMimeTypes: [...DEFAULT_MIME_TYPES],
|
||||
maxSizeMb: 5,
|
||||
requiresValidityDates: false,
|
||||
allowMultiple: false,
|
||||
isPersonal: personal,
|
||||
maxFiles: personal ? 1 : null,
|
||||
sortOrder: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds/edits one document requirement slot for a licence type + application kind. */
|
||||
/**
|
||||
* Adds/edits one document requirement slot.
|
||||
*
|
||||
* Two shapes of the same row: a slot on one licence type's application form,
|
||||
* and — with `personal` — a document every applicant keeps in their own vault
|
||||
* whatever they apply for. The vault has no application to condition on and no
|
||||
* renewal of its own, so those fields are hidden rather than left to mean
|
||||
* nothing.
|
||||
*/
|
||||
export function DocumentRequirementEditorDrawer({
|
||||
opened,
|
||||
onClose,
|
||||
@@ -49,19 +120,33 @@ export function DocumentRequirementEditorDrawer({
|
||||
palette,
|
||||
conditionTargets,
|
||||
saving,
|
||||
personal = false,
|
||||
licenseTypes = [],
|
||||
scope = [],
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Null = adding a new requirement. */
|
||||
requirement: DocumentRequirement | null;
|
||||
defaultApplicationKind: ApplicationKind;
|
||||
onSave: (draft: DraftRequirement) => void;
|
||||
onSave: (draft: DraftRequirement, scope: PersonalScope) => void;
|
||||
palette: FormSchemaPalette | undefined;
|
||||
conditionTargets: ConditionTarget[];
|
||||
saving: boolean;
|
||||
/** Editing a personal document — one kept in the applicant's own vault. */
|
||||
personal?: boolean;
|
||||
/** Licence types offered as scope; only read when `personal`. */
|
||||
licenseTypes?: LicenseType[];
|
||||
/** The licence types this document is already scoped to. */
|
||||
scope?: PersonalScope;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<DraftRequirement>(emptyDraft(defaultApplicationKind));
|
||||
const localized = useLocalized();
|
||||
const [draft, setDraft] = useState<DraftRequirement>(
|
||||
emptyDraft(defaultApplicationKind, personal),
|
||||
);
|
||||
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
|
||||
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const isNew = !requirement;
|
||||
|
||||
@@ -80,13 +165,19 @@ export function DocumentRequirementEditorDrawer({
|
||||
maxSizeMb: requirement.maxSizeMb,
|
||||
requiresValidityDates: requirement.requiresValidityDates,
|
||||
allowMultiple: requirement.allowMultiple,
|
||||
isPersonal: requirement.isPersonal ?? personal,
|
||||
maxFiles: requirement.maxFiles ?? null,
|
||||
sortOrder: requirement.sortOrder,
|
||||
}
|
||||
: emptyDraft(defaultApplicationKind),
|
||||
: emptyDraft(defaultApplicationKind, personal),
|
||||
);
|
||||
setScopeIds(scope);
|
||||
setAppliesToAll(scope.length === 0);
|
||||
setKeyError(null);
|
||||
}
|
||||
}, [opened, requirement, defaultApplicationKind]);
|
||||
// `scope` is a fresh array each render; the opened flag is what gates this.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, requirement, defaultApplicationKind, personal]);
|
||||
|
||||
function save() {
|
||||
if (!draft.key.trim()) {
|
||||
@@ -107,11 +198,22 @@ export function DocumentRequirementEditorDrawer({
|
||||
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
...draft,
|
||||
key: draft.key.trim(),
|
||||
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
|
||||
});
|
||||
if (personal && !appliesToAll && scopeIds.length === 0) {
|
||||
setKeyError(t('certReq.doc.scopeRequired', 'Choose at least one licence type'));
|
||||
return;
|
||||
}
|
||||
onSave(
|
||||
{
|
||||
...draft,
|
||||
key: draft.key.trim(),
|
||||
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
|
||||
isPersonal: personal,
|
||||
// `allowMultiple` predates `maxFiles` and nothing reads it any more;
|
||||
// kept in step so the two columns never contradict each other.
|
||||
allowMultiple: draft.maxFiles !== 1,
|
||||
},
|
||||
appliesToAll ? [] : scopeIds,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -131,7 +233,13 @@ export function DocumentRequirementEditorDrawer({
|
||||
error={keyError}
|
||||
disabled={!isNew}
|
||||
description={isNew ? t('certReq.doc.keyHelp', 'Stable slug identifying this document slot') : t('certReq.doc.keyLocked', 'Key cannot change once created')}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
|
||||
// The value is read out of the event first: a functional updater
|
||||
// runs after React has released the event, so `currentTarget` is
|
||||
// null by the time it would be read inside one.
|
||||
onChange={(e) => {
|
||||
const { value } = e.currentTarget;
|
||||
setDraft((d) => ({ ...d, key: value }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<BilingualInput
|
||||
@@ -147,32 +255,72 @@ export function DocumentRequirementEditorDrawer({
|
||||
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('certReq.doc.applicationKind', 'Application kind')}
|
||||
data={[
|
||||
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
|
||||
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
|
||||
]}
|
||||
value={draft.applicationKind}
|
||||
onChange={(v) => v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))}
|
||||
allowDeselect={false}
|
||||
disabled={!isNew}
|
||||
description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined}
|
||||
/>
|
||||
{personal && (
|
||||
<Stack gap="xs">
|
||||
<Text fz="sm" fw={500}>
|
||||
{t('certReq.doc.scope', 'Applies to')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={appliesToAll ? 'all' : 'selected'}
|
||||
onChange={(v) => setAppliesToAll(v === 'all')}
|
||||
data={[
|
||||
{ value: 'all', label: t('certReq.doc.scopeAll', 'All licences') },
|
||||
{
|
||||
value: 'selected',
|
||||
label: t('certReq.doc.scopeSelected', 'Selected licence types'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{!appliesToAll && (
|
||||
<MultiSelect
|
||||
data={licenseTypes
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key }))}
|
||||
value={scopeIds}
|
||||
onChange={setScopeIds}
|
||||
searchable
|
||||
placeholder={t('certReq.doc.scopePlaceholder', 'Choose licence types')}
|
||||
description={t(
|
||||
'certReq.doc.scopeHelp',
|
||||
'Only applicants who declared one of these as a mode of operation are asked for it.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={t('certReq.doc.mode', 'Mode')}
|
||||
data={[
|
||||
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
|
||||
{ value: 'CONDITIONAL', label: t('certReq.doc.modeConditional', 'Required when condition holds') },
|
||||
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
|
||||
]}
|
||||
value={draft.mode}
|
||||
onChange={(v) => v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{!personal && (
|
||||
<Select
|
||||
label={t('certReq.doc.applicationKind', 'Application kind')}
|
||||
data={[
|
||||
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
|
||||
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
|
||||
]}
|
||||
value={draft.applicationKind}
|
||||
onChange={(v) => v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))}
|
||||
allowDeselect={false}
|
||||
disabled={!isNew}
|
||||
description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{draft.mode === 'CONDITIONAL' && (
|
||||
{!personal && (
|
||||
<Select
|
||||
label={t('certReq.doc.mode', 'Mode')}
|
||||
data={[
|
||||
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
|
||||
{ value: 'CONDITIONAL', label: t('certReq.doc.modeConditional', 'Required when condition holds') },
|
||||
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
|
||||
]}
|
||||
value={draft.mode}
|
||||
onChange={(v) => v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!personal && draft.mode === 'CONDITIONAL' && (
|
||||
<>
|
||||
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
|
||||
<ConditionBuilder
|
||||
@@ -187,6 +335,11 @@ export function DocumentRequirementEditorDrawer({
|
||||
|
||||
<MultiSelect
|
||||
label={t('certReq.doc.allowedTypes', 'Allowed file types')}
|
||||
description={t(
|
||||
'certReq.doc.allowedTypesHelp',
|
||||
'The applicant can only upload these. PDF, JPEG and PNG are selected by default.',
|
||||
)}
|
||||
searchable
|
||||
data={MIME_OPTIONS}
|
||||
value={draft.allowedMimeTypes}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, allowedMimeTypes: v }))}
|
||||
@@ -199,16 +352,29 @@ export function DocumentRequirementEditorDrawer({
|
||||
onChange={(v) => setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
|
||||
checked={draft.requiresValidityDates}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))}
|
||||
/>
|
||||
{!personal && (
|
||||
<Checkbox
|
||||
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
|
||||
checked={draft.requiresValidityDates}
|
||||
onChange={(e) => {
|
||||
const { checked } = e.currentTarget;
|
||||
setDraft((d) => ({ ...d, requiresValidityDates: checked }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
label={t('certReq.doc.allowMultiple', 'Allow multiple uploads')}
|
||||
checked={draft.allowMultiple}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, allowMultiple: e.currentTarget.checked }))}
|
||||
<NumberInput
|
||||
label={t('certReq.doc.maxFiles', 'Files accepted')}
|
||||
description={t(
|
||||
'certReq.doc.maxFilesHelp',
|
||||
'Leave empty for no limit. Use 2 for a document with a front and a back.',
|
||||
)}
|
||||
placeholder={t('certReq.doc.maxFilesUnlimited', 'No limit')}
|
||||
min={1}
|
||||
value={draft.maxFiles ?? ''}
|
||||
onChange={(v) =>
|
||||
setDraft((d) => ({ ...d, maxFiles: typeof v === 'number' ? v : null }))
|
||||
}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
|
||||
@@ -50,7 +50,12 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
|
||||
|
||||
const requirements = useMemo(
|
||||
() => (data?.items ?? []).filter((r) => r.licenseTypeId === licenseType.id),
|
||||
// Personal documents can also name a licence type — they are asked for in
|
||||
// the applicant's vault, not on this form, and are edited in Configuration.
|
||||
() =>
|
||||
(data?.items ?? []).filter(
|
||||
(r) => r.licenseTypeId === licenseType.id && !r.isPersonal,
|
||||
),
|
||||
[data, licenseType.id],
|
||||
);
|
||||
const conditionTargets = collectConditionTargets(licenseType.formSchema.sections);
|
||||
@@ -127,7 +132,7 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{rows.map((req) => (
|
||||
<Card key={req.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
|
||||
<Card key={req.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-default-hover)">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap={6}>
|
||||
|
||||
@@ -113,7 +113,10 @@ export function FieldEditorDrawer({
|
||||
? t('certReq.field.keyHelp', 'Letters, numbers and underscores only — becomes the form data key')
|
||||
: t('certReq.field.keyLocked', 'Key cannot change once created')
|
||||
}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
|
||||
onChange={(e) => {
|
||||
const { value } = e.currentTarget;
|
||||
setDraft((d) => ({ ...d, key: value }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<BilingualInput
|
||||
@@ -134,7 +137,10 @@ export function FieldEditorDrawer({
|
||||
<Checkbox
|
||||
label={t('certReq.field.required', 'Required')}
|
||||
checked={Boolean(draft.required)}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, required: e.currentTarget.checked }))}
|
||||
onChange={(e) => {
|
||||
const { checked } = e.currentTarget;
|
||||
setDraft((d) => ({ ...d, required: checked }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<BilingualInput
|
||||
|
||||
@@ -209,7 +209,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
<Card key={section.key} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" align="flex-start" mb="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconGripVertical size={16} color="var(--mantine-color-gray-5)" />
|
||||
<IconGripVertical size={16} color="var(--mantine-color-dimmed)" />
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text fw={700}>{localized(section.title) || section.key}</Text>
|
||||
@@ -237,7 +237,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
|
||||
<Stack gap="xs">
|
||||
{section.fields.map((field, fIndex) => (
|
||||
<Card key={field.key} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
|
||||
<Card key={field.key} withBorder radius="sm" p="xs" bg="var(--mantine-color-default-hover)">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconEdit, IconPlus, IconSearch, IconTrash, IconX } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AdvancedTable,
|
||||
ModalFooter,
|
||||
useServerTable,
|
||||
type AdvancedColumn,
|
||||
} from '@ema-platform/ui';
|
||||
import {
|
||||
useCreateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetPersonalDocumentsQuery,
|
||||
useLocalized,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
type DocumentRequirement,
|
||||
type PersonalDocumentGroup as PersonalDocumentGroupDto,
|
||||
} from '@ema-platform/api';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
import {
|
||||
DocumentRequirementEditorDrawer,
|
||||
type PersonalScope,
|
||||
} from './DocumentRequirementEditorDrawer';
|
||||
|
||||
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||
|
||||
/** Filter value for "the documents every licence asks for". */
|
||||
const GLOBAL_ONLY = 'GLOBAL';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Badge colours for licence types.
|
||||
*
|
||||
* Red is left out: it reads as a problem, and a licence type is not one.
|
||||
* Everything else the theme offers is in, because the point of the colour is
|
||||
* telling two licence types apart at a glance.
|
||||
*/
|
||||
const SCOPE_COLORS = [
|
||||
'blue',
|
||||
'grape',
|
||||
'teal',
|
||||
'orange',
|
||||
'violet',
|
||||
'cyan',
|
||||
'pink',
|
||||
'lime',
|
||||
'indigo',
|
||||
'green',
|
||||
'yellow',
|
||||
'gray',
|
||||
];
|
||||
|
||||
/** Doubles the palette: the same hue, a visibly different badge. */
|
||||
const SCOPE_VARIANTS = ['light', 'outline'] as const;
|
||||
|
||||
/**
|
||||
* A colour per licence type, assigned by position in the catalogue.
|
||||
*
|
||||
* Hashing the id looked tidier and was wrong: eight buckets over sixteen
|
||||
* licence types collide by the pigeonhole principle, so Vessel Registration
|
||||
* and Freight Forwarder came out the same colour and the badge stopped
|
||||
* carrying information. Walking the sorted catalogue instead gives every type
|
||||
* a distinct colour until the palette runs out, and only then repeats a hue in
|
||||
* the other variant — 24 distinct badges before any two can look alike.
|
||||
*
|
||||
* Sorted by `sortOrder` so the assignment is the same for every officer and
|
||||
* survives a refresh; a type added later takes the next free style rather than
|
||||
* reshuffling the ones already learned.
|
||||
*/
|
||||
function buildScopeStyles(
|
||||
types: { id: string; sortOrder: number }[],
|
||||
): Map<string, { color: string; variant: (typeof SCOPE_VARIANTS)[number] }> {
|
||||
const styles = new Map<
|
||||
string,
|
||||
{ color: string; variant: (typeof SCOPE_VARIANTS)[number] }
|
||||
>();
|
||||
types
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.forEach((type, index) => {
|
||||
styles.set(type.id, {
|
||||
color: SCOPE_COLORS[index % SCOPE_COLORS.length],
|
||||
variant:
|
||||
SCOPE_VARIANTS[
|
||||
Math.floor(index / SCOPE_COLORS.length) % SCOPE_VARIANTS.length
|
||||
],
|
||||
});
|
||||
});
|
||||
return styles;
|
||||
}
|
||||
|
||||
/** `application/vnd.openxmlformats-…-document` is nobody's idea of a column. */
|
||||
const MIME_LABELS: Record<string, string> = {
|
||||
'application/pdf': 'PDF',
|
||||
'application/msword': 'DOC',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
|
||||
'application/vnd.ms-excel': 'XLS',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
|
||||
};
|
||||
|
||||
function shortMime(mime: string): string {
|
||||
return MIME_LABELS[mime] ?? mime.split('/')[1]?.toUpperCase() ?? mime;
|
||||
}
|
||||
|
||||
/**
|
||||
* A document as the table renders it: what the server sent, plus the row id
|
||||
* `AdvancedTable` keys on and the scope read off its rows.
|
||||
*
|
||||
* The grouping itself belongs to the server — a page of rows would split a
|
||||
* document configured for three licence types across two pages and misreport
|
||||
* the scope of both halves.
|
||||
*/
|
||||
interface PersonalDocumentGroup extends PersonalDocumentGroupDto {
|
||||
/** The key doubles as the row id; one group is one document. */
|
||||
id: string;
|
||||
/** Empty when the document applies to every licence. */
|
||||
scope: PersonalScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents an applicant keeps in their own vault.
|
||||
*
|
||||
* Same `document_requirements` table as a licence type's upload slots, flagged
|
||||
* `isPersonal`: these are not asked for on an application form but held once,
|
||||
* under My Documents in the portal. A document can apply to every licence or
|
||||
* only to the modes of operation an applicant has declared — a sea service
|
||||
* book is worth asking a seafarer for and pointless for a freight forwarder.
|
||||
*/
|
||||
export function PersonalDocumentsCard() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const run = useRequirementActions();
|
||||
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation();
|
||||
const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation();
|
||||
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
|
||||
|
||||
const [editing, setEditing] = useState<{ group: PersonalDocumentGroup | null } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<PersonalDocumentGroup | null>(null);
|
||||
/** null = any licence, GLOBAL_ONLY = the all-licence ones, else a type id. */
|
||||
const [licenseTypeFilter, setLicenseTypeFilter] = useState<string | null>(null);
|
||||
|
||||
const { pageIndex, setPageIndex, pageSize, setPageSize } = useServerTable({
|
||||
pageSize: 10,
|
||||
});
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
// Typing must not fire a request per keystroke; same 300ms as the queue.
|
||||
const [search] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
// Every facet goes to the server: it filters and searches in SQL, groups the
|
||||
// rows into documents, then pages the documents.
|
||||
const { data, isFetching, refetch } = useGetPersonalDocumentsQuery({
|
||||
search: search.trim() || undefined,
|
||||
licenseTypeId:
|
||||
licenseTypeFilter && licenseTypeFilter !== GLOBAL_ONLY
|
||||
? licenseTypeFilter
|
||||
: undefined,
|
||||
globalOnly: licenseTypeFilter === GLOBAL_ONLY,
|
||||
take: pageSize,
|
||||
skip: pageIndex * pageSize,
|
||||
locale: i18n.language === 'am' ? 'am' : 'en',
|
||||
});
|
||||
|
||||
const groups = useMemo<PersonalDocumentGroup[]>(
|
||||
() =>
|
||||
(data?.items ?? []).map((group) => ({
|
||||
...group,
|
||||
id: group.key,
|
||||
// A single row with no licence type means "every licence"; the two
|
||||
// never coexist, because the editor writes one shape or the other.
|
||||
scope: group.rows
|
||||
.map((r) => r.licenseTypeId)
|
||||
.filter((id): id is string => id !== null),
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
|
||||
/** Filters are the server's business now; an empty page is its answer. */
|
||||
const isFiltered = search.trim() !== '' || licenseTypeFilter !== null;
|
||||
|
||||
function clearFilters() {
|
||||
setSearchInput('');
|
||||
setLicenseTypeFilter(null);
|
||||
setPageIndex(0);
|
||||
}
|
||||
|
||||
const scopeStyles = useMemo(
|
||||
() => buildScopeStyles(licenseTypes?.items ?? []),
|
||||
[licenseTypes],
|
||||
);
|
||||
|
||||
const typeName = (id: string) => {
|
||||
const found = (licenseTypes?.items ?? []).find((lt) => lt.id === id);
|
||||
return found ? localized(found.name) || found.key : id;
|
||||
};
|
||||
|
||||
const columns = useMemo<AdvancedColumn<PersonalDocumentGroup>[]>(
|
||||
() => [
|
||||
{
|
||||
header: t('certReq.personal.columns.document', 'Document'),
|
||||
label: t('certReq.personal.columns.document', 'Document'),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>
|
||||
{localized(row.original.rows[0].name) || row.original.key}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{row.original.key}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('certReq.doc.scope', 'Applies to'),
|
||||
label: t('certReq.doc.scope', 'Applies to'),
|
||||
cell: ({ row }) =>
|
||||
row.original.scope.length === 0 ? (
|
||||
// Filled, where a licence type is outlined: "every licence" is a
|
||||
// different kind of answer, not one more item in the same list.
|
||||
<Badge size="sm" variant="filled" color="gray">
|
||||
{t('certReq.doc.scopeAll', 'All licences')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Group gap={4}>
|
||||
{row.original.scope.map((id) => {
|
||||
// A type the catalogue no longer lists still needs a badge.
|
||||
const style = scopeStyles.get(id) ?? { color: 'gray', variant: 'light' };
|
||||
return (
|
||||
<Badge key={id} size="sm" variant={style.variant} color={style.color}>
|
||||
{typeName(id)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('certReq.doc.maxFiles', 'Files accepted'),
|
||||
label: t('certReq.doc.maxFiles', 'Files accepted'),
|
||||
align: 'center',
|
||||
cell: ({ row }) =>
|
||||
row.original.rows[0].maxFiles === null
|
||||
? t('certReq.doc.maxFilesUnlimited', 'No limit')
|
||||
: row.original.rows[0].maxFiles,
|
||||
},
|
||||
{
|
||||
header: t('certReq.doc.allowedTypes', 'Allowed file types'),
|
||||
label: t('certReq.doc.allowedTypes', 'Allowed file types'),
|
||||
cell: ({ row }) => {
|
||||
const types = row.original.rows[0].allowedMimeTypes ?? [];
|
||||
return (
|
||||
// Twenty-odd mime types would own the row; the full list is one
|
||||
// hover away instead.
|
||||
<Tooltip label={types.join(', ')} multiline w={280} disabled={types.length <= 3}>
|
||||
<Text fz="xs">
|
||||
{types.slice(0, 3).map(shortMime).join(', ')}
|
||||
{types.length > 3
|
||||
? t('certReq.personal.moreTypes', ' +{{count}} more', {
|
||||
count: types.length - 3,
|
||||
})
|
||||
: ''}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('certReq.doc.maxSize', 'Max file size (MB)'),
|
||||
label: t('certReq.doc.maxSize', 'Max file size (MB)'),
|
||||
align: 'center',
|
||||
cell: ({ row }) => `${row.original.rows[0].maxSizeMb} MB`,
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('certReq.personal.columns.actions', 'Actions'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => setEditing({ group: row.original })}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
// `typeName` and `scopeStyles` both close over the licence-type list.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[t, localized, licenseTypes, scopeStyles],
|
||||
);
|
||||
|
||||
/**
|
||||
* Saves the group as the set of rows it now means.
|
||||
*
|
||||
* The scope is edited as a whole, so the diff is the honest way to apply it:
|
||||
* rows for licence types that were added get created, rows for types that
|
||||
* were dropped get deleted, and everything still in scope is updated. A
|
||||
* document moved to "all licences" collapses to a single row with none.
|
||||
*/
|
||||
async function handleSave(draft: DraftRequirement, scope: PersonalScope) {
|
||||
const existing = editing?.group?.rows ?? [];
|
||||
// `null` is a licence type here too — the one meaning "every licence".
|
||||
const wanted: (string | null)[] = scope.length ? scope : [null];
|
||||
|
||||
const ok = await run(async () => {
|
||||
const stale = existing.filter((row) => !wanted.includes(row.licenseTypeId));
|
||||
const kept = existing.filter((row) => wanted.includes(row.licenseTypeId));
|
||||
const added = wanted.filter(
|
||||
(id) => !existing.some((row) => row.licenseTypeId === id),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
...kept.map((row) => updateRequirement({ id: row.id, ...draft }).unwrap()),
|
||||
...added.map((licenseTypeId) =>
|
||||
createRequirement({ ...draft, licenseTypeId }).unwrap(),
|
||||
),
|
||||
...stale.map((row) => deleteRequirement(row.id).unwrap()),
|
||||
]);
|
||||
}, editing?.group
|
||||
? t('certReq.doc.updated', 'Document requirement updated')
|
||||
: t('certReq.doc.created', 'Document requirement added'));
|
||||
|
||||
if (ok) setEditing(null);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
const ok = await run(
|
||||
() => Promise.all(deleteTarget.rows.map((row) => deleteRequirement(row.id).unwrap())),
|
||||
t('certReq.doc.deleted', 'Document requirement removed'),
|
||||
);
|
||||
if (ok) setDeleteTarget(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Title order={5}>{t('certReq.personal.title', 'Personal documents')}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t(
|
||||
'certReq.personal.subtitle',
|
||||
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
onClick={() => setEditing({ group: null })}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t('certReq.personal.add', 'Add personal document')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Facets, in the shape the licence-review queue uses. No date range:
|
||||
a configuration row has no submission date to filter on. */}
|
||||
<Paper withBorder p="sm">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label={t('certReq.personal.search', 'Search')}
|
||||
placeholder={t('certReq.personal.searchPlaceholder', 'Search by name or key')}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
const { value } = e.currentTarget;
|
||||
setSearchInput(value);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
w={240}
|
||||
/>
|
||||
<Select
|
||||
label={t('certReq.doc.scope', 'Applies to')}
|
||||
placeholder={t('certReq.personal.filterAny', 'Any licence type')}
|
||||
data={[
|
||||
{
|
||||
value: GLOBAL_ONLY,
|
||||
label: t('certReq.personal.filterGlobal', 'All-licence documents only'),
|
||||
},
|
||||
...(licenseTypes?.items ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key })),
|
||||
]}
|
||||
value={licenseTypeFilter}
|
||||
onChange={(value) => {
|
||||
setLicenseTypeFilter(value);
|
||||
// A narrower list can be shorter than the page you were on.
|
||||
setPageIndex(0);
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
w={240}
|
||||
/>
|
||||
{isFiltered && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
{t('certReq.personal.clearFilters', 'Clear')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<AdvancedTable
|
||||
tableName={t('certReq.personal.title', 'Personal documents')}
|
||||
columns={columns}
|
||||
data={groups}
|
||||
itemCount={data?.total ?? 0}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={
|
||||
isFiltered
|
||||
? t('certReq.personal.noMatch', 'No personal document matches those filters.')
|
||||
: t('certReq.personal.empty', 'No personal documents configured yet.')
|
||||
}
|
||||
/>
|
||||
|
||||
<DocumentRequirementEditorDrawer
|
||||
opened={editing !== null}
|
||||
onClose={() => setEditing(null)}
|
||||
requirement={editing?.group?.rows[0] ?? null}
|
||||
defaultApplicationKind="NEW"
|
||||
onSave={handleSave}
|
||||
palette={undefined}
|
||||
conditionTargets={[]}
|
||||
saving={creating || updating}
|
||||
personal
|
||||
licenseTypes={licenseTypes?.items ?? []}
|
||||
scope={editing?.group?.scope ?? []}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title={t('certReq.doc.delete', 'Delete document requirement')}
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="yellow" variant="light">
|
||||
{t(
|
||||
'certReq.personal.deleteWarning',
|
||||
'The slot disappears from every applicant’s My Documents. Files already uploaded are kept, but nobody can reach them.',
|
||||
)}
|
||||
</Alert>
|
||||
<Text fz="sm">
|
||||
{t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', {
|
||||
name: deleteTarget
|
||||
? localized(deleteTarget.rows[0].name) || deleteTarget.key
|
||||
: '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
{t('certReq.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button color="red" onClick={confirmDelete}>
|
||||
{t('certReq.delete', 'Delete')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -72,7 +72,10 @@ export function SectionEditorDrawer({
|
||||
? t('certReq.section.keyHelp', 'Letters, numbers and underscores only')
|
||||
: t('certReq.section.keyLocked', 'Key cannot change once created')
|
||||
}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
|
||||
onChange={(e) => {
|
||||
const { value } = e.currentTarget;
|
||||
setDraft((d) => ({ ...d, key: value }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<BilingualInput
|
||||
@@ -95,7 +98,10 @@ export function SectionEditorDrawer({
|
||||
'Sections sharing the same group render together on one step',
|
||||
)}
|
||||
value={draft.group ?? ''}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, group: e.currentTarget.value || undefined }))}
|
||||
onChange={(e) => {
|
||||
const { value } = e.currentTarget;
|
||||
setDraft((d) => ({ ...d, group: value || undefined }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Container, Select, Stack, Tabs } from '@mantine/core';
|
||||
import { IconAlertCircle, IconFileText, IconFiles, IconSettings } from '@tabler/icons-react';
|
||||
import {
|
||||
IconAdjustments,
|
||||
IconAlertCircle,
|
||||
IconFileText,
|
||||
IconFiles,
|
||||
IconSettings,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ErrorState, PageHeader, PageLoader } from '@ema-platform/ui';
|
||||
import { extractErrorMessage, useGetLicenseTypesQuery, useLocalized } from '@ema-platform/api';
|
||||
import { BehaviorTab } from '../components/BehaviorTab';
|
||||
import { DocumentRequirementsTab } from '../components/DocumentRequirementsTab';
|
||||
import { FormSchemaTab } from '../components/FormSchemaTab';
|
||||
|
||||
@@ -78,6 +85,9 @@ export function CertificateRequirementsPage() {
|
||||
<Tabs.Tab value="documents" leftSection={<IconFiles size={15} />}>
|
||||
{t('certReq.tabDocuments', 'Document requirements')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="behavior" leftSection={<IconAdjustments size={15} />}>
|
||||
{t('certReq.tabBehavior', 'Behaviour')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="schema" pt="md">
|
||||
@@ -87,6 +97,10 @@ export function CertificateRequirementsPage() {
|
||||
<Tabs.Panel value="documents" pt="md">
|
||||
<DocumentRequirementsTab licenseType={selectedType} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="behavior" pt="md">
|
||||
<BehaviorTab licenseType={selectedType} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -38,6 +38,7 @@ const SCOPES: { value: NumberFormatScope; label: string }[] = [
|
||||
{ value: 'SEAFARER_NUMBER', label: 'Seafarer Number' },
|
||||
{ value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' },
|
||||
{ value: 'BTC_NUMBER', label: 'BTC Number' },
|
||||
{ value: 'BSID', label: 'Biometric Subject ID (BSID)' },
|
||||
];
|
||||
|
||||
const scopeLabel = (scope: NumberFormatScope) =>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
IconHash,
|
||||
IconInfoCircle,
|
||||
IconAnchor,
|
||||
IconId,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -35,6 +36,9 @@ import {
|
||||
PageLoader,
|
||||
} from "@ema-platform/ui";
|
||||
import { LocationPage } from "../../../location/pages/LocationPage";
|
||||
// Lives with the document-requirement editor it reuses; shown here because a
|
||||
// document required for every licence is configuration, not one type's form.
|
||||
import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard";
|
||||
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
||||
import { NumberFormatTab } from "../../components/NumberFormatTab";
|
||||
import { RankDepartmentTab } from "./RankDepartmentTab";
|
||||
@@ -406,6 +410,9 @@ export function ConfigurationPage() {
|
||||
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
|
||||
{t("configuration.ranksTab", "Ranks & Departments")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="personalDocuments" leftSection={<IconId size={16} />}>
|
||||
{t("configuration.personalDocumentsTab", "Personal Documents")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="professions" pt="md">
|
||||
@@ -427,6 +434,10 @@ export function ConfigurationPage() {
|
||||
<Tabs.Panel value="ranks" pt="md">
|
||||
<RankDepartmentTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="personalDocuments" pt="md">
|
||||
<PersonalDocumentsCard />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,8 @@ export interface UpdateProfessionPayload {
|
||||
export type NumberFormatScope =
|
||||
| 'SEAFARER_NUMBER'
|
||||
| 'SEAMAN_BOOK_NUMBER'
|
||||
| 'BTC_NUMBER';
|
||||
| 'BTC_NUMBER'
|
||||
| 'BSID';
|
||||
|
||||
/**
|
||||
* The shape of a generated identifier — prefix, optional year, separator and
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ActionId =
|
||||
| 'complete-review'
|
||||
| 'approve-documents'
|
||||
| 'schedule-inspection'
|
||||
| 'reschedule-inspection'
|
||||
| 'record-inspection'
|
||||
| 'final-approve'
|
||||
| 'request-adjustment'
|
||||
@@ -198,6 +199,17 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
permissions: ['can:create:inspection'],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
{
|
||||
id: 'reschedule-inspection',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.rescheduleInspection',
|
||||
from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
|
||||
// Either permission: the team leader who booked the visit holds CREATE,
|
||||
// the inspector who has to attend holds UPDATE, and both have a reason to
|
||||
// move it. The server guards the same pair.
|
||||
permissions: ['can:create:inspection', 'can:update:inspection'],
|
||||
emphasis: 'light',
|
||||
},
|
||||
{
|
||||
id: 'record-inspection',
|
||||
tier: 'primary',
|
||||
@@ -420,6 +432,12 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||
// one applies depends on whether an inspection is already booked.
|
||||
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
|
||||
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
|
||||
// The mirror of the scheduling gate: there is nothing to move until a
|
||||
// visit is booked, and once one is, moving it is the officer's only
|
||||
// option until the day arrives.
|
||||
if (action.id === 'reschedule-inspection' && !ctx.hasPendingInspection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// The transition table doesn't know which types need an inspection, so
|
||||
// `availableEvents` lists approve-documents at UNDER_EVALUATION even for
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { Badge, Checkbox, Text, Tooltip } from "@mantine/core";
|
||||
import { Badge, Checkbox, Group, Text, Tooltip } from "@mantine/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
applicantOrCompanyName,
|
||||
localized,
|
||||
type ApplicationKind,
|
||||
type LicenseApplication,
|
||||
type QueueFilter,
|
||||
} from "@ema-platform/api";
|
||||
|
||||
const KIND_COLOR: Record<ApplicationKind, string> = {
|
||||
NEW: "blue",
|
||||
RENEWAL: "teal",
|
||||
REISSUE: "orange",
|
||||
};
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { dateDisplayer } from "@ema-platform/shared";
|
||||
import { computeSla } from "../../sla";
|
||||
@@ -112,9 +119,19 @@ export function licenseQueueColumns(
|
||||
{
|
||||
header: t("queue.typeCol", "Type"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{localized(row.original.licenseType?.name, locale) || "—"}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm">
|
||||
{localized(row.original.licenseType?.name, locale) || "—"}
|
||||
</Text>
|
||||
{row.original.kind !== "NEW" && (
|
||||
<Badge size="xs" variant="light" color={KIND_COLOR[row.original.kind]}>
|
||||
{t(
|
||||
`queue.kindValues.${row.original.kind}`,
|
||||
row.original.kind === "RENEWAL" ? "Renewal" : "Replacement",
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
useGetQueueCountsQuery,
|
||||
useGetQueueQuery,
|
||||
useLazyExportApplicationsQuery,
|
||||
type ApplicationKind,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
type LicenseType,
|
||||
@@ -437,6 +438,7 @@ export function LicenseQueuePage() {
|
||||
const hasFacets = Boolean(
|
||||
urlFilter.status?.length ||
|
||||
urlFilter.licenseTypeId ||
|
||||
urlFilter.kind ||
|
||||
urlFilter.assignee ||
|
||||
urlFilter.submittedFrom ||
|
||||
debouncedSearch,
|
||||
@@ -589,6 +591,19 @@ export function LicenseQueuePage() {
|
||||
w={220}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label={t("queue.kind", "Application kind")}
|
||||
placeholder={t("queue.anyType", "Any")}
|
||||
data={[
|
||||
{ value: "NEW", label: t("queue.kindValues.NEW", "New") },
|
||||
{ value: "RENEWAL", label: t("queue.kindValues.RENEWAL", "Renewal") },
|
||||
{ value: "REISSUE", label: t("queue.kindValues.REISSUE", "Replacement") },
|
||||
]}
|
||||
value={urlFilter.kind ?? null}
|
||||
onChange={(v) => setFacet({ kind: (v as ApplicationKind) ?? undefined })}
|
||||
clearable
|
||||
w={180}
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label={t("queue.submittedFrom", "Submitted from")}
|
||||
value={urlFilter.submittedFrom ?? ""}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconPaperclip,
|
||||
IconPencil,
|
||||
IconQuestionMark,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
useRequestAdjustmentMutation,
|
||||
useResumeApplicationMutation,
|
||||
useScheduleInspectionMutation,
|
||||
useRescheduleInspectionMutation,
|
||||
useGetCertificateUrlForOfficerMutation,
|
||||
uploadDocument,
|
||||
type RemarkTargetType,
|
||||
@@ -225,6 +227,7 @@ export function LicenseReviewPage() {
|
||||
const [finalApprove] = useFinalApproveMutation();
|
||||
const [rejectApplication] = useRejectApplicationMutation();
|
||||
const [scheduleInspection] = useScheduleInspectionMutation();
|
||||
const [rescheduleInspection] = useRescheduleInspectionMutation();
|
||||
const [recordResult] = useRecordInspectionResultMutation();
|
||||
const [confirmPayment] = useConfirmPaymentMutation();
|
||||
const [scheduleIssuance] = useScheduleIssuanceMutation();
|
||||
@@ -265,8 +268,14 @@ export function LicenseReviewPage() {
|
||||
const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
|
||||
"MORNING",
|
||||
);
|
||||
/** The booking modal moves an existing visit rather than creating one. */
|
||||
const [rescheduling, setRescheduling] = useState(false);
|
||||
const [rescheduleReason, setRescheduleReason] = useState("");
|
||||
const [issuanceOpen, setIssuanceOpen] = useState(false);
|
||||
const [issuanceDate, setIssuanceDate] = useState("");
|
||||
const [issuancePeriod, setIssuancePeriod] = useState<"MORNING" | "AFTERNOON">(
|
||||
"MORNING",
|
||||
);
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [findings, setFindings] = useState("");
|
||||
const [findingsUploadBusy, setFindingsUploadBusy] = useState(false);
|
||||
@@ -312,10 +321,14 @@ export function LicenseReviewPage() {
|
||||
|
||||
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
|
||||
// A visit cannot have an outcome before it happens — mirror of the server's
|
||||
// inspection_not_yet_due guard, compared instant-to-instant.
|
||||
// inspection_not_yet_due guard. The column holds a calendar day, so the
|
||||
// comparison is between day strings in the authority's timezone: parsing
|
||||
// "2026-08-28" as a Date would read it as UTC midnight, i.e. 03:00 in Addis.
|
||||
const inspectionNotYetDue = Boolean(
|
||||
pendingInspection?.scheduledDate &&
|
||||
new Date(pendingInspection.scheduledDate) > new Date(),
|
||||
new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Addis_Ababa" }).format(
|
||||
new Date(),
|
||||
) < pendingInspection.scheduledDate.slice(0, 10),
|
||||
);
|
||||
|
||||
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
|
||||
@@ -559,12 +572,29 @@ export function LicenseReviewPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the booking modal seeded with the visit already on the books. */
|
||||
function openReschedule() {
|
||||
if (!pendingInspection) return;
|
||||
setRescheduling(true);
|
||||
setInspectionDate(pendingInspection.scheduledDate ?? "");
|
||||
setInspectionTimeSlot(pendingInspection.timeSlot ?? "MORNING");
|
||||
setRescheduleReason("");
|
||||
setInspectionOpen(true);
|
||||
}
|
||||
|
||||
/** Actions with their own dedicated form open that; the rest confirm. */
|
||||
function handleAction(action: ResolvedAction) {
|
||||
switch (action.id) {
|
||||
case "schedule-inspection":
|
||||
setRescheduling(false);
|
||||
setInspectionDate("");
|
||||
setInspectionTimeSlot("MORNING");
|
||||
setRescheduleReason("");
|
||||
setInspectionOpen(true);
|
||||
return;
|
||||
case "reschedule-inspection":
|
||||
openReschedule();
|
||||
return;
|
||||
case "schedule-issuance":
|
||||
setIssuanceOpen(true);
|
||||
return;
|
||||
@@ -848,6 +878,11 @@ export function LicenseReviewPage() {
|
||||
<Badge color={STATUS_COLORS[status]} variant="light">
|
||||
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||
</Badge>
|
||||
{data.issuedLicenseStatus === "SUPERSEDED" && (
|
||||
<Badge color="gray" variant="light">
|
||||
{t("review.certificateSuperseded", "Certificate superseded")}
|
||||
</Badge>
|
||||
)}
|
||||
{app.adjustmentRound > 0 && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{t("review.round", {
|
||||
@@ -1210,18 +1245,6 @@ export function LicenseReviewPage() {
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="inspection">
|
||||
{status === "INSPECTION_FAILED" && (
|
||||
<Alert
|
||||
mb="md"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
>
|
||||
{t(
|
||||
"review.inspectionFailedBlocked",
|
||||
"Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.",
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
<Paper withBorder p="md">
|
||||
{inspections.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -1254,21 +1277,48 @@ export function LicenseReviewPage() {
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={
|
||||
inspection.result === "FAILED" ? "red" : "teal"
|
||||
}
|
||||
>
|
||||
{inspection.result === "PASSED"
|
||||
? t("review.passed", "Passed")
|
||||
: inspection.result === "FAILED"
|
||||
? t("review.failed", "Failed")
|
||||
: t(
|
||||
`review.inspectionStatus.${inspection.status}`,
|
||||
inspection.status,
|
||||
<Group gap="xs">
|
||||
{inspection.status === "SCHEDULED" &&
|
||||
inspection.id === pendingInspection?.id &&
|
||||
can([
|
||||
"can:create:inspection",
|
||||
"can:update:inspection",
|
||||
]) && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"review.actions.rescheduleInspection",
|
||||
"Reschedule inspection",
|
||||
)}
|
||||
</Badge>
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label={t(
|
||||
"review.actions.rescheduleInspection",
|
||||
"Reschedule inspection",
|
||||
)}
|
||||
onClick={openReschedule}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={
|
||||
inspection.result === "FAILED" ? "red" : "teal"
|
||||
}
|
||||
>
|
||||
{inspection.result === "PASSED"
|
||||
? t("review.passed", "Passed")
|
||||
: inspection.result === "FAILED"
|
||||
? t("review.failed", "Failed")
|
||||
: t(
|
||||
`review.inspectionStatus.${inspection.status}`,
|
||||
inspection.status,
|
||||
)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -1277,6 +1327,18 @@ export function LicenseReviewPage() {
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* Page-level, not inside the inspection tab: a license type
|
||||
configured without an inspection detail section must still show
|
||||
why approval is blocked if it ever lands here. */}
|
||||
{status === "INSPECTION_FAILED" && (
|
||||
<Alert mt="md" color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
{t(
|
||||
"review.inspectionFailedBlocked",
|
||||
"Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.",
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === "PAYMENT_PENDING" && (
|
||||
<Alert
|
||||
mt="md"
|
||||
@@ -1335,7 +1397,11 @@ export function LicenseReviewPage() {
|
||||
<Modal
|
||||
opened={inspectionOpen}
|
||||
onClose={() => setInspectionOpen(false)}
|
||||
title={t("review.actions.scheduleInspection", "Schedule inspection")}
|
||||
title={
|
||||
rescheduling
|
||||
? t("review.actions.rescheduleInspection", "Reschedule inspection")
|
||||
: t("review.actions.scheduleInspection", "Schedule inspection")
|
||||
}
|
||||
>
|
||||
<Stack>
|
||||
<AmharicDatePicker
|
||||
@@ -1351,36 +1417,72 @@ export function LicenseReviewPage() {
|
||||
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
|
||||
]}
|
||||
/>
|
||||
{rescheduling && (
|
||||
<Textarea
|
||||
label={t("review.rescheduleReason", "Why is it moving?")}
|
||||
description={t(
|
||||
"review.rescheduleReasonHint",
|
||||
"Kept in the audit trail and sent to the applicant.",
|
||||
)}
|
||||
value={rescheduleReason}
|
||||
onChange={(e) => setRescheduleReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
)}
|
||||
<ModalFooter>
|
||||
{/* Mantine strips pointer events from a disabled control, so the
|
||||
tooltip wraps a span — same trick as DecisionBar's ActionButton;
|
||||
a disabled button must still say why. */}
|
||||
<Tooltip
|
||||
label={t("review.pickDate", "Pick a date first")}
|
||||
disabled={Boolean(inspectionDate)}
|
||||
>
|
||||
<span>
|
||||
<button type="button" hidden aria-hidden />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="lg"
|
||||
disabled={!inspectionDate}
|
||||
aria-label={t("review.schedule", "Schedule")}
|
||||
aria-label={
|
||||
rescheduling
|
||||
? t("review.reschedule", "Reschedule")
|
||||
: t("review.schedule", "Schedule")
|
||||
}
|
||||
onClick={() =>
|
||||
run(
|
||||
async () => {
|
||||
await scheduleInspection({
|
||||
applicationId: id,
|
||||
scheduledDate: inspectionDate,
|
||||
timeSlot: inspectionTimeSlot,
|
||||
}).unwrap();
|
||||
if (rescheduling) {
|
||||
// Guarded by the action's own gate, which only offers
|
||||
// rescheduling while a booking exists.
|
||||
if (!pendingInspection) return;
|
||||
await rescheduleInspection({
|
||||
inspectionId: pendingInspection.id,
|
||||
applicationId: id,
|
||||
scheduledDate: inspectionDate,
|
||||
timeSlot: inspectionTimeSlot,
|
||||
...(rescheduleReason.trim()
|
||||
? { reason: rescheduleReason.trim() }
|
||||
: {}),
|
||||
}).unwrap();
|
||||
} else {
|
||||
await scheduleInspection({
|
||||
applicationId: id,
|
||||
scheduledDate: inspectionDate,
|
||||
timeSlot: inspectionTimeSlot,
|
||||
}).unwrap();
|
||||
}
|
||||
setInspectionOpen(false);
|
||||
},
|
||||
t("review.done.scheduled", "Inspection scheduled"),
|
||||
rescheduling
|
||||
? t("review.done.rescheduled", "Inspection rescheduled")
|
||||
: t("review.done.scheduled", "Inspection scheduled"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
@@ -1396,35 +1498,46 @@ export function LicenseReviewPage() {
|
||||
value={issuanceDate}
|
||||
onChange={setIssuanceDate}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={issuancePeriod}
|
||||
onChange={(value) => setIssuancePeriod(value as "MORNING" | "AFTERNOON")}
|
||||
data={[
|
||||
{ value: "MORNING", label: t("review.morning", "Morning") },
|
||||
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
|
||||
]}
|
||||
/>
|
||||
<ModalFooter>
|
||||
{/* Span-wrapped like DecisionBar's ActionButton — Mantine strips
|
||||
pointer events from a disabled control, and a disabled button
|
||||
must still say why. */}
|
||||
<Tooltip
|
||||
label={t("review.pickDate", "Pick a date and time first")}
|
||||
label={t("review.pickDate", "Pick a date first")}
|
||||
disabled={Boolean(issuanceDate)}
|
||||
>
|
||||
<span>
|
||||
<button type="button" hidden aria-hidden />
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="lg"
|
||||
disabled={!issuanceDate}
|
||||
aria-label={t("review.schedule", "Schedule")}
|
||||
onClick={() =>
|
||||
run(
|
||||
async () => {
|
||||
await scheduleIssuance({
|
||||
id,
|
||||
scheduledDate: issuanceDate,
|
||||
scheduledPeriod: issuancePeriod,
|
||||
}).unwrap();
|
||||
setIssuanceOpen(false);
|
||||
},
|
||||
t("review.done.scheduleIssuance", "Pickup scheduled"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="lg"
|
||||
disabled={!issuanceDate}
|
||||
aria-label={t("review.schedule", "Schedule")}
|
||||
onClick={() =>
|
||||
run(
|
||||
async () => {
|
||||
await scheduleIssuance({
|
||||
id,
|
||||
scheduledDate: issuanceDate,
|
||||
}).unwrap();
|
||||
setIssuanceOpen(false);
|
||||
},
|
||||
t("review.done.scheduleIssuance", "Pickup scheduled"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -179,9 +179,25 @@ function FeeEditModal({
|
||||
// are real configuration states, not blank fields.
|
||||
const [chargeable, setChargeable] = useState(true);
|
||||
const [sameAsNew, setSameAsNew] = useState(true);
|
||||
// The examined-certificate stages. Held the same way — null means "this stage
|
||||
// is not charged", which is different from a blank box.
|
||||
const [eligibilityFee, setEligibilityFee] = useState<number | ''>('');
|
||||
const [examinationFee, setExaminationFee] = useState<number | ''>('');
|
||||
const [certificateFee, setCertificateFee] = useState<number | ''>('');
|
||||
|
||||
const examined = licenseType?.requiresExamination ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!licenseType) return;
|
||||
setEligibilityFee(
|
||||
licenseType.feeEligibility == null ? '' : Number(licenseType.feeEligibility),
|
||||
);
|
||||
setExaminationFee(
|
||||
licenseType.feeExamination == null ? '' : Number(licenseType.feeExamination),
|
||||
);
|
||||
setCertificateFee(
|
||||
licenseType.feeCertificate == null ? '' : Number(licenseType.feeCertificate),
|
||||
);
|
||||
setChargeable(licenseType.feeNewApplication !== null);
|
||||
setNewFee(
|
||||
licenseType.feeNewApplication === null
|
||||
@@ -221,6 +237,14 @@ function FeeEditModal({
|
||||
feeNewApplication: chargeable ? Number(newFee) : null,
|
||||
feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null,
|
||||
feeCurrency: currency.trim() || 'ETB',
|
||||
// Only sent for examined types; otherwise the columns stay untouched.
|
||||
...(examined
|
||||
? {
|
||||
feeEligibility: eligibilityFee === '' ? null : Number(eligibilityFee),
|
||||
feeExamination: examinationFee === '' ? null : Number(examinationFee),
|
||||
feeCertificate: certificateFee === '' ? null : Number(certificateFee),
|
||||
}
|
||||
: {}),
|
||||
}).unwrap();
|
||||
notify.success(
|
||||
t('paymentConfig.modal.updated', {
|
||||
@@ -325,6 +349,68 @@ function FeeEditModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{examined && (
|
||||
<>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'paymentConfig.modal.examinedNotice',
|
||||
'This certificate is earned by examination, so it is charged in three stages. Unlike the fees above, these are not fixed at approval — a change applies to candidates already part-way through. Clearing one while candidates are waiting to pay it will be refused.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<NumberInput
|
||||
label={t('paymentConfig.modal.eligibilityFeeLabel', 'Eligibility assessment fee')}
|
||||
description={t(
|
||||
'paymentConfig.modal.eligibilityFeeHint',
|
||||
'Due on submission, before an officer reviews the application. Leave empty for no charge.',
|
||||
)}
|
||||
value={eligibilityFee}
|
||||
onChange={(v) => setEligibilityFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
thousandSeparator=","
|
||||
allowNegative={false}
|
||||
decimalScale={2}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('paymentConfig.modal.examinationFeeLabel', 'Examination fee')}
|
||||
description={t(
|
||||
'paymentConfig.modal.examinationFeeHint',
|
||||
'Due once eligibility is approved, and again for a retake.',
|
||||
)}
|
||||
value={examinationFee}
|
||||
onChange={(v) => setExaminationFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
thousandSeparator=","
|
||||
allowNegative={false}
|
||||
decimalScale={2}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('paymentConfig.modal.certificateFeeLabel', 'Certificate fee')}
|
||||
description={t(
|
||||
'paymentConfig.modal.certificateFeeHint',
|
||||
'Due after a pass, before the certificate is issued.',
|
||||
)}
|
||||
value={certificateFee}
|
||||
onChange={(v) => setCertificateFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
thousandSeparator=","
|
||||
allowNegative={false}
|
||||
decimalScale={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ModalFooter mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
||||
{t('paymentConfig.modal.cancel', 'Cancel')}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { IconCheck, IconUserCheck, IconX } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { PickupAppointment } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
export function pickupDeskActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onCheckIn: (appointment: PickupAppointment) => void;
|
||||
onIssue: (appointment: PickupAppointment) => void;
|
||||
onNoShow: (appointment: PickupAppointment) => void;
|
||||
},
|
||||
loadingId: string | null,
|
||||
): AdvancedColumn<PickupAppointment> {
|
||||
return {
|
||||
header: '',
|
||||
size: 260,
|
||||
align: 'right',
|
||||
cell: ({ row }) => {
|
||||
const appointment = row.original;
|
||||
const loading = loadingId === appointment.id;
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{appointment.status === 'SCHEDULED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_PICKUP_DESK]} hideOnly>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
loading={loading}
|
||||
leftSection={<IconUserCheck size={14} />}
|
||||
onClick={() => handlers.onCheckIn(appointment)}
|
||||
>
|
||||
{t('pickupDesk.checkIn', 'Check in')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{(appointment.status === 'SCHEDULED' || appointment.status === 'CHECKED_IN') && (
|
||||
<>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.ISSUE_CERTIFICATE]} hideOnly>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="teal"
|
||||
loading={loading}
|
||||
leftSection={<IconCheck size={14} />}
|
||||
onClick={() => handlers.onIssue(appointment)}
|
||||
>
|
||||
{t('pickupDesk.issue', 'Issue')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_PICKUP_DESK]} hideOnly>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={loading}
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => handlers.onNoShow(appointment)}
|
||||
>
|
||||
{t('pickupDesk.noShow', 'No-show')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { PickupAppointment, PickupOffice } from '@ema-platform/api';
|
||||
|
||||
const STATUS_COLOR: Record<PickupAppointment['status'], string> = {
|
||||
SCHEDULED: 'cyan',
|
||||
CHECKED_IN: 'yellow',
|
||||
ISSUED: 'green',
|
||||
NO_SHOW: 'red',
|
||||
RESCHEDULED: 'gray',
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
export function pickupDeskColumns(
|
||||
t: TFunction,
|
||||
officesById: Map<string, PickupOffice>,
|
||||
): AdvancedColumn<PickupAppointment>[] {
|
||||
return [
|
||||
{
|
||||
header: t('pickupDesk.columns.time', 'Time'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} ff="monospace">
|
||||
{row.original.slotStartTime}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupDesk.columns.appointment', 'Appointment'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" ff="monospace">
|
||||
{row.original.appointmentNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupDesk.columns.office', 'Office'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{officesById.get(row.original.officeId)?.name ?? '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupDesk.columns.status', 'Status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{row.original.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, Select, Stack, ThemeIcon } from '@mantine/core';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { AmharicDatePicker, AdvancedTable, PageHeader, notify, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useCheckInPickupMutation,
|
||||
useGetPickupOfficesQuery,
|
||||
useGetPickupWorklistQuery,
|
||||
useIssueCertificateMutation,
|
||||
useMarkPickupIssuedMutation,
|
||||
useMarkPickupNoShowMutation,
|
||||
type PickupAppointment,
|
||||
} from '@ema-platform/api';
|
||||
import { pickupDeskActionsColumn } from './actions';
|
||||
import { pickupDeskColumns } from './columns';
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pickup officer's worklist for one day (spec §43): who is booked, when,
|
||||
* and where they are in the visit. Check-in and no-show are pickup-desk
|
||||
* concerns; Issue calls the existing certificate-issuance endpoint and then
|
||||
* marks the appointment issued, so the two stay in the same state a
|
||||
* `SCHEDULED` application has always moved through.
|
||||
*/
|
||||
export function PickupDeskPage() {
|
||||
const { t } = useTranslation();
|
||||
const [date, setDate] = useState(todayIso());
|
||||
const [officeId, setOfficeId] = useState<string | null>(null);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
|
||||
const { data: offices } = useGetPickupOfficesQuery();
|
||||
const {
|
||||
data: appointments,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useGetPickupWorklistQuery({ date, officeId: officeId ?? undefined });
|
||||
|
||||
const [checkIn] = useCheckInPickupMutation();
|
||||
const [markIssued] = useMarkPickupIssuedMutation();
|
||||
const [markNoShow] = useMarkPickupNoShowMutation();
|
||||
const [issueCertificate] = useIssueCertificateMutation();
|
||||
|
||||
const officesById = useMemo(
|
||||
() => new Map((offices ?? []).map((o) => [o.id, o])),
|
||||
[offices],
|
||||
);
|
||||
const officeOptions = useMemo(
|
||||
() => (offices ?? []).map((o) => ({ value: o.id, label: o.name })),
|
||||
[offices],
|
||||
);
|
||||
|
||||
const rows = [...(appointments ?? [])].sort((a, b) =>
|
||||
a.slotStartTime.localeCompare(b.slotStartTime),
|
||||
);
|
||||
const page = paginate(rows);
|
||||
|
||||
async function withLoading(id: string, action: () => Promise<unknown>) {
|
||||
setLoadingId(id);
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), t('pickupDesk.actionFailed', 'Action failed'));
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckIn(appointment: PickupAppointment) {
|
||||
await withLoading(appointment.id, () => checkIn(appointment.id).unwrap());
|
||||
}
|
||||
|
||||
async function handleIssue(appointment: PickupAppointment) {
|
||||
await withLoading(appointment.id, async () => {
|
||||
// Renders and stores the certificate — the same action a raw
|
||||
// schedule-only application reaches from the review page.
|
||||
await issueCertificate(appointment.applicationId).unwrap();
|
||||
await markIssued(appointment.id).unwrap();
|
||||
notify.success(t('pickupDesk.issued', 'Document issued'));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleNoShow(appointment: PickupAppointment) {
|
||||
await withLoading(appointment.id, () => markNoShow(appointment.id).unwrap());
|
||||
}
|
||||
|
||||
const columns = [
|
||||
...pickupDeskColumns(t, officesById),
|
||||
pickupDeskActionsColumn(
|
||||
t,
|
||||
{ onCheckIn: handleCheckIn, onIssue: handleIssue, onNoShow: handleNoShow },
|
||||
loadingId,
|
||||
),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={t('pickupDesk.title', 'Pickup Desk')}
|
||||
subtitle={t(
|
||||
'pickupDesk.subtitle',
|
||||
"Today's and upcoming document pickup appointments.",
|
||||
)}
|
||||
noMargin
|
||||
action={
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconCalendarEvent size={22} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<Group gap="sm">
|
||||
<AmharicDatePicker
|
||||
label={t('pickupDesk.date', 'Date')}
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
/>
|
||||
<Select
|
||||
label={t('pickupDesk.office', 'Office')}
|
||||
placeholder={t('pickupDesk.allOffices', 'All offices')}
|
||||
data={officeOptions}
|
||||
value={officeId}
|
||||
onChange={setOfficeId}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<AdvancedTable
|
||||
tableName="pickup-desk-appointments"
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('pickupDesk.empty', 'No appointments for this day.')}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default PickupDeskPage;
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from '@mantine/core';
|
||||
import { IconBuildingWarehouse, IconPlus } from '@tabler/icons-react';
|
||||
import { AdvancedTable, ModalFooter, PageHeader, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useCreatePickupOfficeMutation,
|
||||
useGetPickupOfficesQuery,
|
||||
useUpdatePickupOfficeMutation,
|
||||
type PickupOffice,
|
||||
} from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const WEEKDAYS = [
|
||||
{ value: '0', label: 'Sun' },
|
||||
{ value: '1', label: 'Mon' },
|
||||
{ value: '2', label: 'Tue' },
|
||||
{ value: '3', label: 'Wed' },
|
||||
{ value: '4', label: 'Thu' },
|
||||
{ value: '5', label: 'Fri' },
|
||||
{ value: '6', label: 'Sat' },
|
||||
];
|
||||
|
||||
type OfficeDraft = {
|
||||
name: string;
|
||||
address: string;
|
||||
workingDays: string[];
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
slotDurationMinutes: number;
|
||||
maxApplicantsPerSlot: number;
|
||||
rescheduleMinNoticeHours: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: OfficeDraft = {
|
||||
name: '',
|
||||
address: '',
|
||||
workingDays: ['1', '2', '3', '4', '5'],
|
||||
startTime: '08:30',
|
||||
endTime: '17:00',
|
||||
slotDurationMinutes: 30,
|
||||
maxApplicantsPerSlot: 10,
|
||||
rescheduleMinNoticeHours: 24,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
function toDraft(office: PickupOffice): OfficeDraft {
|
||||
return {
|
||||
name: office.name,
|
||||
address: office.address ?? '',
|
||||
workingDays: office.workingDays.map(String),
|
||||
startTime: office.startTime,
|
||||
endTime: office.endTime,
|
||||
slotDurationMinutes: office.slotDurationMinutes,
|
||||
maxApplicantsPerSlot: office.maxApplicantsPerSlot,
|
||||
rescheduleMinNoticeHours: office.rescheduleMinNoticeHours,
|
||||
isActive: office.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Office/location, working hours, slot capacity and reschedule cutoff — the
|
||||
* configuration `PickupService.availableSlots` computes real slots from
|
||||
* (spec §20). Holiday management lives here too, one office at a time,
|
||||
* rather than a separate page — a holiday has no meaning without an office.
|
||||
*/
|
||||
export function PickupOfficesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: offices, isFetching, refetch } = useGetPickupOfficesQuery();
|
||||
const [createOffice, { isLoading: creating }] = useCreatePickupOfficeMutation();
|
||||
const [updateOffice, { isLoading: updating }] = useUpdatePickupOfficeMutation();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
|
||||
const [editing, setEditing] = useState<PickupOffice | null>(null);
|
||||
const [creatingNew, setCreatingNew] = useState(false);
|
||||
const [draft, setDraft] = useState<OfficeDraft>(EMPTY_DRAFT);
|
||||
|
||||
const page = paginate(offices ?? []);
|
||||
|
||||
function openEdit(office: PickupOffice) {
|
||||
setEditing(office);
|
||||
setDraft(toDraft(office));
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setCreatingNew(true);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
}
|
||||
|
||||
function close() {
|
||||
setEditing(null);
|
||||
setCreatingNew(false);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const body = {
|
||||
name: draft.name,
|
||||
address: draft.address || undefined,
|
||||
workingDays: draft.workingDays.map(Number),
|
||||
startTime: draft.startTime,
|
||||
endTime: draft.endTime,
|
||||
slotDurationMinutes: draft.slotDurationMinutes,
|
||||
maxApplicantsPerSlot: draft.maxApplicantsPerSlot,
|
||||
rescheduleMinNoticeHours: draft.rescheduleMinNoticeHours,
|
||||
isActive: draft.isActive,
|
||||
};
|
||||
try {
|
||||
if (editing) {
|
||||
await updateOffice({ id: editing.id, ...body }).unwrap();
|
||||
} else {
|
||||
await createOffice(body).unwrap();
|
||||
}
|
||||
notify.success(t('pickupOffices.saved', 'Office saved'));
|
||||
close();
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), t('pickupOffices.saveFailed', 'Could not save'));
|
||||
}
|
||||
}
|
||||
|
||||
const columns: AdvancedColumn<PickupOffice>[] = [
|
||||
{
|
||||
header: t('pickupOffices.columns.name', 'Office'),
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.original.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.address ?? '—'}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupOffices.columns.hours', 'Working hours'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.startTime}–{row.original.endTime}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupOffices.columns.capacity', 'Capacity / slot'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.maxApplicantsPerSlot} · {row.original.slotDurationMinutes}min
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupOffices.columns.status', 'Status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" color={row.original.isActive ? 'teal' : 'gray'} variant="light">
|
||||
{row.original.isActive
|
||||
? t('pickupOffices.active', 'Active')
|
||||
: t('pickupOffices.inactive', 'Inactive')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CONFIGURE_PICKUP_OFFICES]} hideOnly>
|
||||
<Button size="xs" variant="light" onClick={() => openEdit(row.original)}>
|
||||
{t('pickupOffices.edit', 'Edit')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={t('pickupOffices.title', 'Pickup Offices')}
|
||||
subtitle={t(
|
||||
'pickupOffices.subtitle',
|
||||
'Where applicants collect printed documents, and how many can be booked into each slot.',
|
||||
)}
|
||||
noMargin
|
||||
action={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconBuildingWarehouse size={22} />
|
||||
</ThemeIcon>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CONFIGURE_PICKUP_OFFICES]} hideOnly>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
|
||||
{t('pickupOffices.new', 'New office')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<AdvancedTable
|
||||
tableName="pickup-offices"
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(editing) || creatingNew}
|
||||
onClose={close}
|
||||
title={editing ? t('pickupOffices.editTitle', 'Edit office') : t('pickupOffices.new', 'New office')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t('pickupOffices.form.name', 'Name')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.currentTarget.value }))}
|
||||
withAsterisk
|
||||
/>
|
||||
<TextInput
|
||||
label={t('pickupOffices.form.address', 'Address')}
|
||||
value={draft.address}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, address: e.currentTarget.value }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={t('pickupOffices.form.workingDays', 'Working days')}
|
||||
data={WEEKDAYS}
|
||||
value={draft.workingDays}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, workingDays: v }))}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label={t('pickupOffices.form.startTime', 'Start time')}
|
||||
placeholder="08:30"
|
||||
value={draft.startTime}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, startTime: e.currentTarget.value }))}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('pickupOffices.form.endTime', 'End time')}
|
||||
placeholder="17:00"
|
||||
value={draft.endTime}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, endTime: e.currentTarget.value }))}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t('pickupOffices.form.slotDuration', 'Slot length (min)')}
|
||||
min={5}
|
||||
value={draft.slotDurationMinutes}
|
||||
onChange={(v) =>
|
||||
setDraft((d) => ({ ...d, slotDurationMinutes: Number(v) || d.slotDurationMinutes }))
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('pickupOffices.form.capacity', 'Max per slot')}
|
||||
min={1}
|
||||
value={draft.maxApplicantsPerSlot}
|
||||
onChange={(v) =>
|
||||
setDraft((d) => ({ ...d, maxApplicantsPerSlot: Number(v) || d.maxApplicantsPerSlot }))
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<NumberInput
|
||||
label={t('pickupOffices.form.rescheduleCutoff', 'Reschedule minimum notice (hours)')}
|
||||
min={0}
|
||||
value={draft.rescheduleMinNoticeHours}
|
||||
onChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
rescheduleMinNoticeHours: Number(v) || d.rescheduleMinNoticeHours,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
label={t('pickupOffices.form.active', 'Active')}
|
||||
checked={draft.isActive}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, isActive: e.currentTarget.checked }))}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={close}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button loading={creating || updating} disabled={!draft.name.trim()} onClick={save}>
|
||||
{t('common.save', 'Save')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default PickupOfficesPage;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* The officer's own signing signature — drawn onto certificates they approve
|
||||
* (`{{signatureImage}}`), as distinct from the seafarer's specimen signature
|
||||
* that the portal manages.
|
||||
*
|
||||
* Scoped to the caller: the API resolves the employee record from the token,
|
||||
* so no employee id is passed and nobody can upload on another's behalf.
|
||||
*/
|
||||
const signatureApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: ['MyEmployeeSignature'] as const })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getMyEmployeeSignature: builder.query<{ url: string | null }, void>({
|
||||
query: () => ({ url: '/employee-signatures/me' }),
|
||||
providesTags: ['MyEmployeeSignature'],
|
||||
}),
|
||||
|
||||
uploadMyEmployeeSignature: builder.mutation<{ id: string }, File>({
|
||||
query: (file) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
// No Content-Type header: fetch sets it with the multipart boundary.
|
||||
return { url: '/employee-signatures/me', method: 'POST', body };
|
||||
},
|
||||
invalidatesTags: (_r, error) => (error ? [] : ['MyEmployeeSignature']),
|
||||
}),
|
||||
|
||||
deleteMyEmployeeSignature: builder.mutation<{ removed: boolean }, void>({
|
||||
query: () => ({ url: '/employee-signatures/me', method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : ['MyEmployeeSignature']),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetMyEmployeeSignatureQuery,
|
||||
useUploadMyEmployeeSignatureMutation,
|
||||
useDeleteMyEmployeeSignatureMutation,
|
||||
} = signatureApi;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SignaturePad } from '@ema-platform/ui';
|
||||
import {
|
||||
useDeleteMyEmployeeSignatureMutation,
|
||||
useGetMyEmployeeSignatureQuery,
|
||||
useUploadMyEmployeeSignatureMutation,
|
||||
} from '../api/signature-api';
|
||||
|
||||
/** The signature drawn onto certificates this officer approves. */
|
||||
export function MySignaturePad() {
|
||||
const { data, isLoading } = useGetMyEmployeeSignatureQuery();
|
||||
const [upload, { isLoading: isUploading }] =
|
||||
useUploadMyEmployeeSignatureMutation();
|
||||
const [remove, { isLoading: isDeleting }] =
|
||||
useDeleteMyEmployeeSignatureMutation();
|
||||
|
||||
return (
|
||||
<SignaturePad
|
||||
currentUrl={data?.url ?? null}
|
||||
isLoading={isLoading}
|
||||
isUploading={isUploading}
|
||||
isDeleting={isDeleting}
|
||||
onUpload={(file) => upload(file).unwrap()}
|
||||
onDelete={() => remove().unwrap()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
IconMail,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSignature,
|
||||
IconShieldLock,
|
||||
IconSun,
|
||||
IconUser,
|
||||
@@ -43,12 +44,18 @@ import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
||||
import {
|
||||
ActiveSessions,
|
||||
LICENSE_PERMISSIONS,
|
||||
setUser,
|
||||
usePermissions,
|
||||
} from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { setLayoutMode } from '../../../store/preferences.slice';
|
||||
import type { LayoutMode } from '../../../store/preferences.slice';
|
||||
import { MySignaturePad } from '../components/MySignaturePad';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
function getInitials(name: string, fallback: string) {
|
||||
@@ -77,6 +84,10 @@ export function ProfilePage() {
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
const { handleError } = useErrorHandler();
|
||||
// Only officers who approve applications ever sign a certificate, so nobody
|
||||
// else is asked for a signature they would never use.
|
||||
const { can } = usePermissions();
|
||||
const canSign = can([LICENSE_PERMISSIONS.APPROVE_APPLICATION]);
|
||||
|
||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
@@ -318,6 +329,11 @@ export function ProfilePage() {
|
||||
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
|
||||
{t('profile.tabs.profile')}
|
||||
</Tabs.Tab>
|
||||
{canSign && (
|
||||
<Tabs.Tab value="signature" leftSection={<IconSignature size={18} />}>
|
||||
{t('profile.tabs.signature')}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
||||
{t('profile.tabs.security')}
|
||||
</Tabs.Tab>
|
||||
@@ -405,6 +421,15 @@ export function ProfilePage() {
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Signature (drawn onto certificates this officer approves) ---- */}
|
||||
{canSign && (
|
||||
<Tabs.Panel value="signature" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<MySignaturePad />
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
|
||||
{/* ---- Security ---- */}
|
||||
<Tabs.Panel value="security" pt="md">
|
||||
<Stack gap="lg">
|
||||
|
||||
@@ -5,10 +5,13 @@ import { IconSearch } from '@tabler/icons-react';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||
useListSeafarerDocumentsQuery,
|
||||
type SeafarerDocumentKind,
|
||||
type SeafarerDocumentRequestKind,
|
||||
type SeafarerDocumentRow,
|
||||
type SeafarerDocumentStatus,
|
||||
} from '@ema-platform/api';
|
||||
@@ -22,6 +25,10 @@ const STATUS_FILTERS = (
|
||||
['PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'SCHEDULED', 'ISSUED', 'REJECTED'] as SeafarerDocumentStatus[]
|
||||
).map((value) => ({ value, label: SEAFARER_DOCUMENT_STATUS_LABELS[value] }));
|
||||
|
||||
const REQUEST_KIND_FILTERS = (
|
||||
['NEW', 'RENEWAL', 'REPLACEMENT'] as SeafarerDocumentRequestKind[]
|
||||
).map((value) => ({ value, label: SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[value] }));
|
||||
|
||||
/**
|
||||
* The Seaman Book queue and the BTC queue — one page, keyed by kind. Requests
|
||||
* appear here once the seafarer registration that opened them is approved.
|
||||
@@ -30,6 +37,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const [status, setStatus] = useState<SeafarerDocumentStatus | null>(null);
|
||||
const [requestKind, setRequestKind] = useState<SeafarerDocumentRequestKind | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -38,6 +46,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
const { data, isLoading, isFetching, refetch } = useListSeafarerDocumentsQuery({
|
||||
kind,
|
||||
status: status ?? undefined,
|
||||
requestKind: requestKind ?? undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
take: pageSize,
|
||||
skip: page * pageSize,
|
||||
@@ -75,6 +84,15 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Type',
|
||||
accessorKey: 'requestKind',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="outline" color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[row.original.requestKind]}>
|
||||
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[row.original.requestKind]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Fee',
|
||||
accessorKey: 'feeAmount',
|
||||
@@ -148,6 +166,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All types"
|
||||
data={REQUEST_KIND_FILTERS}
|
||||
value={requestKind}
|
||||
onChange={(v) => {
|
||||
setRequestKind(v as SeafarerDocumentRequestKind | null);
|
||||
setPage(0);
|
||||
}}
|
||||
clearable
|
||||
w={160}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
itemCount={data?.total ?? 0}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader,
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
@@ -133,6 +135,11 @@ export function SeafarerDocumentReviewPage() {
|
||||
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]}>
|
||||
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
||||
</Badge>
|
||||
{document.requestKind !== 'NEW' && (
|
||||
<Badge size="sm" variant="outline" color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[document.requestKind]}>
|
||||
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]}
|
||||
</Badge>
|
||||
)}
|
||||
{document.documentNumber && (
|
||||
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
{document.documentNumber}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middl
|
||||
return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—';
|
||||
}
|
||||
|
||||
/** Submitted seafarer registrations, oldest first — click a row to review it. */
|
||||
/** Submitted seafarer registrations, newest first — click a row to review it. */
|
||||
export function SeafarerRegistrationQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
@@ -43,6 +43,8 @@ export function SeafarerRegistrationQueuePage() {
|
||||
const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
|
||||
status: status ?? undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
sortBy: 'submittedAt',
|
||||
sortDir: 'DESC',
|
||||
take: pageSize,
|
||||
skip: page * pageSize,
|
||||
});
|
||||
|
||||
@@ -63,8 +63,13 @@ export function SeafarerRegistrationReviewPage() {
|
||||
}
|
||||
|
||||
const { registration, attachments } = data;
|
||||
// Decided straight off the queue — no claim step.
|
||||
const canDecide = registration.status === 'SUBMITTED';
|
||||
// Decided straight off the queue — no claim step. AWAITING_BIOMETRICS is
|
||||
// deliberately excluded: approval is blocked on a BSID that only exists once
|
||||
// the applicant has been enrolled, so the decision is not the reviewer's to
|
||||
// take yet. SUBMITTED stays decidable for files that predate the gate.
|
||||
const awaitingBiometrics = registration.status === 'AWAITING_BIOMETRICS';
|
||||
const canDecide =
|
||||
registration.status === 'UNDER_REVIEW' || registration.status === 'SUBMITTED';
|
||||
const busy = approving || rejecting || requesting;
|
||||
|
||||
async function run(action: () => Promise<unknown>, done: string) {
|
||||
@@ -141,6 +146,18 @@ export function SeafarerRegistrationReviewPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{awaitingBiometrics && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title="Awaiting biometric enrolment"
|
||||
mb="md"
|
||||
>
|
||||
This registration cannot be decided yet. The applicant has to be
|
||||
enrolled at a counter and issued a BSID first — the registration moves
|
||||
to Under Review automatically once that happens.
|
||||
</Alert>
|
||||
)}
|
||||
{registration.status === 'RESUBMIT_REQUIRED' && (
|
||||
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
|
||||
{registration.reviewRemark}
|
||||
|
||||
@@ -184,7 +184,7 @@ export function SeafarerRegistryPage() {
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Search + filters */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Paper withBorder radius="lg" p={{ base: 'md', sm: 'xl' }}>
|
||||
<Group mb="sm" gap="sm" justify="space-between">
|
||||
<TextInput
|
||||
placeholder="Search by name, seafarer ID, or seaman book…"
|
||||
@@ -215,6 +215,7 @@ export function SeafarerRegistryPage() {
|
||||
|
||||
<Text fz="xs" c="dimmed" mb="sm">{filtered.length} seafarer(s) found</Text>
|
||||
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
@@ -278,6 +279,7 @@ export function SeafarerRegistryPage() {
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Paper>
|
||||
|
||||
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserManagementApp } from '@tria-plc/iamui';
|
||||
import type { DesignConfig, UserManagementSessionOptions } from '@tria-plc/iamui';
|
||||
import '@tria-plc/iamui/style.css';
|
||||
import Cookies from "js-cookie";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { UserManagementApp } from "@tria-plc/iamui";
|
||||
import { BASE_API_URL } from "@ema-platform/api";
|
||||
import type {
|
||||
DesignConfig,
|
||||
UserManagementSessionOptions,
|
||||
} from "@tria-plc/iamui";
|
||||
import "@tria-plc/iamui/style.css";
|
||||
|
||||
const UM_OVERRIDES = `
|
||||
.um-theme-light {
|
||||
@@ -58,73 +62,73 @@ const UM_OVERRIDES = `
|
||||
|
||||
const UM_CONFIG: DesignConfig = {
|
||||
brand: {
|
||||
appName: 'Ethiopian Maritime Licence',
|
||||
logoUrl: '/assets/emaLogo.jpg',
|
||||
appName: "Ethiopian Maritime Licence",
|
||||
logoUrl: "/assets/emaLogo.jpg",
|
||||
},
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
sidebar: '#ffffff',
|
||||
background: '#f8fafc',
|
||||
foreground: '#1e293b',
|
||||
border: '#e2e8f0',
|
||||
mutedForeground: '#94a3b8',
|
||||
card: '#ffffff',
|
||||
primary: "#2563eb",
|
||||
sidebar: "#ffffff",
|
||||
background: "#f8fafc",
|
||||
foreground: "#1e293b",
|
||||
border: "#e2e8f0",
|
||||
mutedForeground: "#94a3b8",
|
||||
card: "#ffffff",
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
|
||||
},
|
||||
layout: {
|
||||
userManagementView: 'classic',
|
||||
sidebarBrandLabel: 'Ethiopian Maritime Authority',
|
||||
sidebarBrandSublabel: 'User Management',
|
||||
sidebarBackground: '#ffffff',
|
||||
sidebarColor: '#1e293b',
|
||||
sidebarMutedColor: '#94a3b8',
|
||||
sidebarActiveBackground: '#eff6ff',
|
||||
sidebarActiveColor: '#2563eb',
|
||||
sidebarHoverBackground: '#f8fafc',
|
||||
sidebarBorder: '#e2e8f0',
|
||||
sidebarWidth: '280px',
|
||||
sidebarCollapsedWidth: '80px',
|
||||
modalAccentColor: '#2563eb',
|
||||
modalHeaderBackground: '#f8fafc',
|
||||
modalHeaderEditBackground: '#eff6ff',
|
||||
modalIconBackground: '#eff6ff',
|
||||
modalIconColor: '#2563eb',
|
||||
modalTitleColor: '#1e293b',
|
||||
modalFocusColor: '#2563eb',
|
||||
modalSurface: '#ffffff',
|
||||
userManagementView: "classic",
|
||||
sidebarBrandLabel: "Ethiopian Maritime Authority",
|
||||
sidebarBrandSublabel: "User Management",
|
||||
sidebarBackground: "#ffffff",
|
||||
sidebarColor: "#1e293b",
|
||||
sidebarMutedColor: "#94a3b8",
|
||||
sidebarActiveBackground: "#eff6ff",
|
||||
sidebarActiveColor: "#2563eb",
|
||||
sidebarHoverBackground: "#f8fafc",
|
||||
sidebarBorder: "#e2e8f0",
|
||||
sidebarWidth: "280px",
|
||||
sidebarCollapsedWidth: "80px",
|
||||
modalAccentColor: "#2563eb",
|
||||
modalHeaderBackground: "#f8fafc",
|
||||
modalHeaderEditBackground: "#eff6ff",
|
||||
modalIconBackground: "#eff6ff",
|
||||
modalIconColor: "#2563eb",
|
||||
modalTitleColor: "#1e293b",
|
||||
modalFocusColor: "#2563eb",
|
||||
modalSurface: "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
const UM_RUNTIME = {
|
||||
basename: '/um',
|
||||
basename: "/um",
|
||||
// Keep the embedded IAM module on the same API as the backoffice client.
|
||||
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
|
||||
// fall back to its remote development server, where the local JWT is
|
||||
// rejected and the module redirects to its login page.
|
||||
apiUrl: import.meta.env.VITE_BASE_API_URL ?? 'http://localhost:3000/api',
|
||||
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "http://localhost:3000/api",
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
position: "fixed",
|
||||
top: 12,
|
||||
left: 12,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: '8px 16px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: "8px 16px",
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 8,
|
||||
background: '#ffffff',
|
||||
color: '#2563eb',
|
||||
background: "#ffffff",
|
||||
color: "#2563eb",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: "pointer",
|
||||
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
|
||||
transition: "all 150ms ease",
|
||||
};
|
||||
|
||||
export default function UserManagementPage() {
|
||||
@@ -133,29 +137,31 @@ export default function UserManagementPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleReturn = useCallback(() => {
|
||||
navigate('/dashboard');
|
||||
navigate("/dashboard");
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
const style = document.createElement("style");
|
||||
style.textContent = UM_OVERRIDES;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const token = Cookies.get('ema-backoffice-auth-token') ?? '';
|
||||
const refreshToken = Cookies.get('ema-backoffice-refresh-token');
|
||||
const token = Cookies.get("ema-backoffice-auth-token") ?? "";
|
||||
const refreshToken = Cookies.get("ema-backoffice-refresh-token");
|
||||
|
||||
const session: UserManagementSessionOptions = {
|
||||
initialSession: token
|
||||
? { token, refreshToken, rememberMe: true }
|
||||
: null,
|
||||
initialSession: token ? { token, refreshToken, rememberMe: true } : null,
|
||||
enableEmbeddedAuthBridge: false,
|
||||
};
|
||||
|
||||
rootRef.current = createRoot(containerRef.current);
|
||||
rootRef.current.render(
|
||||
<UserManagementApp config={UM_CONFIG} runtime={UM_RUNTIME} session={session} />,
|
||||
<UserManagementApp
|
||||
config={UM_CONFIG}
|
||||
runtime={UM_RUNTIME}
|
||||
session={session}
|
||||
/>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -173,21 +179,28 @@ export default function UserManagementPage() {
|
||||
onClick={handleReturn}
|
||||
style={buttonStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f8fafc';
|
||||
e.currentTarget.style.boxShadow = '0 1px 6px rgba(0,0,0,0.12)';
|
||||
e.currentTarget.style.background = "#f8fafc";
|
||||
e.currentTarget.style.boxShadow = "0 1px 6px rgba(0,0,0,0.12)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#ffffff';
|
||||
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)';
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
e.currentTarget.style.background = "#ffffff";
|
||||
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0,0,0,0.08)";
|
||||
}}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
Return to EMA
|
||||
</button>
|
||||
<div ref={containerRef} style={{ position: 'fixed', inset: 0 }} />
|
||||
<div ref={containerRef} style={{ position: "fixed", inset: 0 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,14 +88,19 @@ export const am: Translations = {
|
||||
btcQueue: "የBTC ወረፋ",
|
||||
cocQueue: "የCoC ወረፋ",
|
||||
copQueue: "የCoP ወረፋ",
|
||||
endorsementCocQueue: "የCoC እውቅና ወረፋ",
|
||||
endorsementGocQueue: "የGOC እውቅና ወረፋ",
|
||||
endorsementQueue: "የማስተያየት ወረፋ",
|
||||
vesselRegistrations: "የመርከብ ምዝገባ",
|
||||
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
||||
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
|
||||
seafarerRegistry: "የመርከበኞች መዝገብ",
|
||||
biometricEnrollment: "ባዮሜትሪክ ምዝገባ",
|
||||
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
|
||||
applications: "ማመልከቻዎች",
|
||||
paymentConfig: "የክፍያ ውቅረት",
|
||||
pickupDesk: "የመረከቢያ ዴስክ",
|
||||
pickupOffices: "የመረከቢያ ቢሮዎች",
|
||||
analytics: "ትንታኔ",
|
||||
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
|
||||
medicalVerification: "የሕክምና ማረጋገጫ",
|
||||
@@ -469,9 +474,31 @@ export const am: Translations = {
|
||||
unverified: "ያልተረጋገጠ",
|
||||
tabs: {
|
||||
profile: "መገለጫ",
|
||||
signature: "ፊርማ",
|
||||
security: "ደህንነት",
|
||||
preferences: "ምርጫዎች",
|
||||
},
|
||||
signature: {
|
||||
title: "የመፈረሚያ ፊርማ",
|
||||
description: "እርስዎ በሚያጸድቋቸው ሰነዶች ላይ ይታተማል። አንድ ጊዜ ይሳሉ ወይም ምስል ይጫኑ።",
|
||||
reissueNotice:
|
||||
"ፊርማዎን መቀየር አስቀድሞ የተሰጠን ሰነድ አይለውጥም — ከአሁን በኋላ በሚፈርሙዋቸው ላይ ብቻ ይሠራል።",
|
||||
current: "የተመዘገበ ፊርማ",
|
||||
currentAlt: "የተቀመጠ ፊርማዎ",
|
||||
none: "እስካሁን የተመዘገበ ፊርማ የለም። የሚያጸድቋቸው ሰነዶች ያለ ፊርማ ይሰጣሉ።",
|
||||
modeDraw: "ይሳሉ",
|
||||
modeUpload: "ይጫኑ",
|
||||
save: "ፊርማ አስቀምጥ",
|
||||
clear: "አጽዳ",
|
||||
choose: "ምስል ይምረጡ",
|
||||
fileHint: "PNG ወይም JPEG፣ እስከ 2 ሜባ።",
|
||||
remove: "አስወግድ",
|
||||
saved: "ፊርማ ተቀምጧል።",
|
||||
removed: "ፊርማ ተወግዷል።",
|
||||
badType: "PNG እና JPEG ምስሎች ብቻ ይፈቀዳሉ።",
|
||||
tooLarge: "ምስሉ ከ2 ሜባ ይበልጣል።",
|
||||
drawFailed: "ሥዕሉን ማንበብ አልተቻለም። እባክዎ እንደገና ይሞክሩ።",
|
||||
},
|
||||
personalHint: "በኦፊሴላዊ ኢማ ሰነዶች ላይ እንደሚታየው ስምዎ።",
|
||||
languageTitle: "ቋንቋ",
|
||||
languageHint: "በአስተዳደር ፓነል ውስጥ የሚጠቀሙትን ቋንቋ ይምረጡ።",
|
||||
@@ -849,6 +876,7 @@ export const am: Translations = {
|
||||
|
||||
configuration: {
|
||||
title: "ውቅረት",
|
||||
personalDocumentsTab: "የግል ሰነዶች",
|
||||
departments: "ክፍሎች",
|
||||
professions: "ሙያዎች",
|
||||
departmentsList: "ክፍሎች",
|
||||
@@ -893,11 +921,18 @@ export const am: Translations = {
|
||||
type: "ዓይነት",
|
||||
anyType: "ማንኛውም",
|
||||
typeCol: "ዓይነት",
|
||||
kind: "የማመልከቻ ዓይነት",
|
||||
kindValues: {
|
||||
NEW: "አዲስ",
|
||||
RENEWAL: "እድሳት",
|
||||
REISSUE: "ምትክ",
|
||||
},
|
||||
statusCol: "ሁኔታ",
|
||||
statusValues: {
|
||||
DRAFT: "ረቂቅ",
|
||||
SUBMITTED: "ቀርቧል",
|
||||
UNDER_REVIEW: "በግምገማ ላይ",
|
||||
AWAITING_BIOMETRICS: "ባዮሜትሪክ በመጠባበቅ ላይ",
|
||||
UNDER_EVALUATION: "በምዘና ላይ",
|
||||
RESUBMIT_REQUIRED: "እንደገና ማቅረብ ያስፈልጋል",
|
||||
INSPECTION_PENDING: "ምርመራ በመጠባበቅ ላይ",
|
||||
@@ -967,6 +1002,7 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
review: {
|
||||
certificateSuperseded: "ሰርተፍኬቱ ተተክቷል",
|
||||
summary: "ማጠቃለያ",
|
||||
officer: "ሹም",
|
||||
supervisor: "የበላይ ኃላፊ",
|
||||
@@ -1017,6 +1053,9 @@ export const am: Translations = {
|
||||
morning: "ጠዋት",
|
||||
afternoon: "ከሰዓት በኋላ",
|
||||
schedule: "ያዝ",
|
||||
reschedule: "አዛውር",
|
||||
rescheduleReason: "ለምን ይዛወራል?",
|
||||
rescheduleReasonHint: "በኦዲት መዝገብ ውስጥ ተይዞ ለአመልካቹ ይላካል።",
|
||||
pickDate: "መጀመሪያ ቀን ይምረጡ",
|
||||
passed: "አልፏል",
|
||||
failed: "ወድቋል",
|
||||
@@ -1053,6 +1092,7 @@ export const am: Translations = {
|
||||
completeReview: "ግምገማ አጠናቅቅ",
|
||||
approveDocuments: "ሰነዶችን አጽድቅ",
|
||||
scheduleInspection: "ምርመራ ያዝ",
|
||||
rescheduleInspection: "ምርመራ አዛውር",
|
||||
recordInspection: "የምርመራ ውጤት መዝግብ",
|
||||
finalApprove: "አጽድቅ እና ስጥ",
|
||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||
@@ -1203,6 +1243,7 @@ export const am: Translations = {
|
||||
assign: "እንደገና ተመድቧል",
|
||||
assignReviewer: "ግምገማ ተመድቧል",
|
||||
scheduled: "ምርመራ ተይዟል",
|
||||
rescheduled: "ምርመራ ተዛውሯል",
|
||||
inspectionPassed: "ምርመራ አልፏል",
|
||||
inspectionFailed: "ምርመራ ወድቋል",
|
||||
},
|
||||
@@ -1225,12 +1266,14 @@ export const am: Translations = {
|
||||
|
||||
designer: {
|
||||
title: "የምስክር ወረቀት ንድፍ",
|
||||
subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ፣ የሚቆይበትንም ጊዜ ያዘጋጁ።",
|
||||
subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ።",
|
||||
licenceType: "የፈቃድ ዓይነት",
|
||||
validityYears: "የሚቆይበት (ዓመታት)",
|
||||
validityHint: "ፈቃድ ሲሰጥ ተግባራዊ ይሆናል",
|
||||
saveValidity: "የሚቆይበትን ጊዜ አስቀምጥ",
|
||||
validitySaved: "የሚቆይበት ጊዜ ተዘምኗል",
|
||||
validity: "የሚቆይበት",
|
||||
validityMonths_one: "{{count}} ወር",
|
||||
validityMonths_other: "{{count}} ወራት",
|
||||
validityDays_one: "{{count}} ቀን",
|
||||
validityDays_other: "{{count}} ቀናት",
|
||||
validityEditedOn: "— በምስክር ወረቀት መስፈርቶች → ባህሪ ውስጥ ይዘጋጃል",
|
||||
newVersion: "አዲስ ስሪት",
|
||||
versions: "ስሪቶች",
|
||||
name: "የስሪት ስም",
|
||||
@@ -1273,6 +1316,86 @@ export const am: Translations = {
|
||||
loadFailed: "የፈቃድ ዓይነቶችን መጫን አልተቻለም",
|
||||
tabSchema: "የቅጽ ቅንብር",
|
||||
tabDocuments: "የሰነድ መስፈርቶች",
|
||||
tabBehavior: "ባህሪ",
|
||||
behavior: {
|
||||
workflow: "የሥራ ሂደት",
|
||||
workflowWarning:
|
||||
"እነዚህ ሦስት ቅንብሮች ማመልከቻው የሚከተለውን ሂደት ይወስናሉ። የዚህ ዓይነት ማመልከቻዎች ውሳኔ በመጠባበቅ ላይ እያሉ ሊቀየሩ አይችሉም — እነሱ ውሳኔ እስኪያገኙ ድረስ ማስቀመጡ ተቀባይነት አያገኝም።",
|
||||
workflowProfile: "የሥራ ሂደት ዓይነት",
|
||||
workflowProfileHint: "ምዝገባ የግምገማና የምርመራ ደረጃዎችን ያስቀራል።",
|
||||
standard: "መደበኛ የፈቃድ ሂደት",
|
||||
registration: "ምዝገባ (ግምገማ ብቻ)",
|
||||
completionEffect: "የማጠናቀቂያ ውጤት",
|
||||
completionEffectHint:
|
||||
"የዚህ ዓይነት ማመልከቻ ሲጠናቀቅ ሥርዓቱ የሚወስደው እርምጃ።",
|
||||
registerSeafarer: "መርከበኛውን መዝግብ",
|
||||
registerVessel: "መርከቧን መዝግብ",
|
||||
openDocuments: "የመርከበኛ ሰነዶችን ክፈት",
|
||||
noEffect: "ምንም ውጤት የለም",
|
||||
requiresExamination: "ፈተና ያስፈልገዋል",
|
||||
requiresExaminationHint:
|
||||
"ማመልከቻውን በብቁነት፣ ከዚያ በፈተና፣ ከዚያ በምስክር ወረቀት ሂደት ያሳልፋል። ሦስቱን የደረጃ ክፍያዎች በክፍያ ቅንብር ገጽ ላይ ያስቀምጡ።",
|
||||
issuesCertificate: "የምስክር ወረቀት ይሰጣል",
|
||||
issuesCertificateHint:
|
||||
"በኢማ ውሳኔ ተጠናቆ ወደ ክፍያ ደረጃ ለማይደርስ ዓይነት ያጥፉ። እንደገና ማብራት የአዲስ ማመልከቻ ክፍያ እንዲዘጋጅ ይጠይቃል፤ አለበለዚያ የጸደቁ አመልካቾች የሌለ ክፍያ እንዲከፍሉ ይጠየቃሉ።",
|
||||
inspectionRequired: "አካላዊ ምርመራ ያስፈልገዋል",
|
||||
inspectionRequiredHint:
|
||||
"ምርመራ ተመዝግቦ እስኪያልፍ ድረስ ኃላፊው ማጽደቅ አይችልም። ማመልከቻዎች በሂደት ላይ እያሉ መቀየር አስተማማኝ ነው — ኃላፊው በግምገማ ላይ ላለ ማመልከቻ አሁንም መርማሪ መመደብ ይችላል።",
|
||||
renewalEnabled: "ባለቤቶች ይህንን ፈቃድ ማደስ ይችላሉ",
|
||||
renewalEnabledHint:
|
||||
"አንድ ጊዜ ብቻ ለሚሰጡ ያጥፉ — ጊዜው ለማያልፍ ምዝገባ፣ ወይም ለአንድ ጭነት ለተጻፈ ነፃ ፈቃድ።",
|
||||
serviceKind: "የአገልግሎት ዓይነት",
|
||||
serviceKindHint: "ለዝርዝር ምድብ ብቻ — ምንም የሥራ ሂደት በእሱ ላይ አይመሠረትም።",
|
||||
license: "ፈቃድ",
|
||||
registrationKind: "ምዝገባ",
|
||||
eligibility: "የብቁነት መስፈርቶች",
|
||||
eligibilityHint:
|
||||
"አመልካቹ ሲያስገባ ይመረመራሉ፤ የካፒታል መስፈርቱ ደግሞ ኃላፊው ሲያጸድቅ እንደገና ይመረመራል። መስፈርትን ማጥበቅ ቀደም ብሎ ረቂቅ የጀመረን ሰው ከማስገባት ሊያግደው ይችላል።",
|
||||
capitalThreshold: "አነስተኛ የተከፈለ ካፒታል",
|
||||
capitalOn: "አነስተኛ የተከፈለ ካፒታል ይጠየቅ",
|
||||
capitalHint:
|
||||
"ኃላፊው ከዚህ እኩል ወይም በላይ የሆነ ካፒታል እስኪያረጋግጥ ድረስ ማጽደቅ አይችልም። ለአዲሶቹ ብቻ ሳይሆን ቀደም ብለው በወረፋ ላይ ላሉ ማመልከቻዎችም ይሠራል።",
|
||||
validity: "የሚቆይበት",
|
||||
validityUnit: "የሚቆይበት መለኪያ",
|
||||
unitMonths: "ወራት",
|
||||
unitDays: "ቀናት",
|
||||
validityHint:
|
||||
"ፈቃድ ሲሰጥ ተግባራዊ ይሆናል። ቀደም ብለው የተሰጡ ፈቃዶች የተሰጣቸውን የማብቂያ ቀን ይይዛሉ።",
|
||||
requiresSeafarer: "የጸና የመርከበኛ ምዝገባ ያስፈልገዋል",
|
||||
requiresSeafarerHint:
|
||||
"ይህንን ዓይነት እንደ የመርከበኛ የምስክር ወረቀት ያመለክታል፤ በመርከበኛው ፖርታል ላይ እንዲታይ የሚያደርገው ይኸው ነው።",
|
||||
requiresMedical: "የጸና የሕክምና ማረጋገጫ ያስፈልገዋል",
|
||||
minSeaTime: "አነስተኛ የባህር አገልግሎት (ቀናት)",
|
||||
minSeaTimeOn: "የተረጋገጠ የባህር አገልግሎት ይጠየቅ",
|
||||
certificateCategory: "የምስክር ወረቀት ምድብ",
|
||||
certificateCategoryHint:
|
||||
"ይህ የሚሰጠው የሰነድ ዓይነት። ማረጋገጫዎች በመርከበኛው ፖርታል ላይ ለብቻቸው ይመደባሉ።",
|
||||
notACertificate: "የምስክር ወረቀት አይደለም",
|
||||
renewal: "የሚቆይበት ጊዜና እድሳት",
|
||||
renewalWindow: "እድሳት የሚከፈትበት (ጊዜው ከማብቃቱ በፊት ያሉ ቀናት)",
|
||||
reminders: "የማብቂያ አስታዋሾች (ቀደም ብለው ያሉ ቀናት)",
|
||||
remindersHint: "ባለቤቱ በእያንዳንዱ በእነዚህ ጊዜያት ይታሰባል።",
|
||||
applicantRules: "የአመልካች ደንቦች",
|
||||
requiresOperatorMode: "አመልካቹ ይህንን የሥራ ዘርፍ ማሳወቅ አለበት",
|
||||
requiresOperatorModeHint:
|
||||
"ማንኛውም የገባ አመልካች ሊጀምራቸው ለሚችሉ በሰው ላይ ለሚያተኩሩ ምዝገባዎች ያጥፉ።",
|
||||
allowMultipleDrafts: "በአንድ ጊዜ ብዙ ክፍት ረቂቆችን ፍቀድ",
|
||||
allowMultipleDraftsHint:
|
||||
"ለእያንዳንዱ ንብረት ለሚደረጉ ምዝገባዎች ያብሩ — ሁለተኛ መርከብ መመዝገብ የመጀመሪያዋን ረቂቅ መቀጠል የለበትም።",
|
||||
requiresScheduling: "ከመስጠት በፊት የመረከቢያ ቀን ይያዝ",
|
||||
requiresSchedulingHint: "አንድ ጊዜ ታትመው በአካል ለሚሰጡ ሰነዶች።",
|
||||
slaHours: "የውሳኔ ጊዜ ግብ (ሰዓት)",
|
||||
slaOn: "በጊዜ ገደብ ይከታተል",
|
||||
slaHint:
|
||||
"የዕድሜ/የጊዜ ገደብ አምድንና የዘገዩ እይታን ይመራል። ቀደም ብለው ለቀረቡ ማመልከቻዎችም እንደ አዲሶቹ ይሠራል።",
|
||||
uniqueFormKey: "በዚህ መልስ አንድ ማመልከቻ ብቻ",
|
||||
uniqueFormKeyHint:
|
||||
"በነጥብ የተለየ የቅጽ መንገድ፣ ለምሳሌ shipment.billOfLading። መስኩ በዚህ ዓይነት ቅጽ ውስጥ መኖር አለበት፤ አለበለዚያ ምንም ማመልከቻ ማስገባት አይቻልም።",
|
||||
noUniqueRule: "የልዩነት ደንብ የለም",
|
||||
save: "ቅንብሩን አስቀምጥ",
|
||||
saved: "ቅንብሩ ተቀምጧል።",
|
||||
noPermission: "የፈቃድ ቅንብርን ለመቀየር ፈቃድ የለዎትም።",
|
||||
},
|
||||
cancel: "ይቅር",
|
||||
delete: "ሰርዝ",
|
||||
saveChanges: "ለውጦችን አስቀምጥ",
|
||||
@@ -1372,13 +1495,46 @@ export const am: Translations = {
|
||||
modeOptional: "አማራጭ ስቀላ",
|
||||
conditionRequired: "ሁኔታዊ መስፈርት ሁኔታ ያስፈልገዋል",
|
||||
allowedTypes: "የተፈቀዱ የፋይል ዓይነቶች",
|
||||
allowedTypesHelp:
|
||||
"አመልካቹ እነዚህን ብቻ መስቀል ይችላል። በነባሪ PDF፣ JPEG እና PNG ተመርጠዋል።",
|
||||
maxSize: "ከፍተኛ የፋይል መጠን (MB)",
|
||||
requiresValidity: "የቀን ገደብ ያስፈልጋል",
|
||||
allowMultiple: "ብዙ ስቀላዎችን ፍቀድ",
|
||||
multiple: "ብዙ",
|
||||
scope: "የሚመለከተው",
|
||||
scopeAll: "ሁሉም ፈቃዶች",
|
||||
scopeSelected: "የተመረጡ የፈቃድ አይነቶች",
|
||||
scopePlaceholder: "የፈቃድ አይነቶችን ይምረጡ",
|
||||
scopeHelp: "ከእነዚህ አንዱን የሥራ ዘርፍ አድርገው ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።",
|
||||
scopeRequired: "ቢያንስ አንድ የፈቃድ አይነት ይምረጡ",
|
||||
maxFiles: "የሚፈቀዱ ፋይሎች",
|
||||
maxFilesHelp: "ገደብ ከሌለ ባዶ ይተዉት። ፊትና ጀርባ ላለው ሰነድ 2 ይጠቀሙ።",
|
||||
maxFilesUnlimited: "ገደብ የለም",
|
||||
sortOrder: "የቅደም ተከተል ቁጥር",
|
||||
when: "መቼ",
|
||||
},
|
||||
personal: {
|
||||
title: "የግል ሰነዶች",
|
||||
subtitle:
|
||||
"አመልካቹ በእያንዳንዱ ማመልከቻ ላይ ከመስቀል ይልቅ በ\u2018ሰነዶቼ\u2019 ውስጥ አንድ ጊዜ የሚያስቀምጣቸው የማንነትና የትምህርት ሰነዶች። ለተወሰኑ የፈቃድ አይነቶች ከወሰኑት፣ እነዚያን የሥራ ዘርፍ ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።",
|
||||
add: "የግል ሰነድ ጨምር",
|
||||
empty: "እስካሁን የተዋቀረ የግል ሰነድ የለም።",
|
||||
search: "ፍለጋ",
|
||||
searchPlaceholder: "በስም ወይም በቁልፍ ይፈልጉ",
|
||||
moreTypes: " +{{count}} ተጨማሪ",
|
||||
columns: {
|
||||
document: "ሰነድ",
|
||||
actions: "ድርጊቶች",
|
||||
},
|
||||
filterAny: "ማንኛውም የፈቃድ አይነት",
|
||||
filterGlobal: "ለሁሉም ፈቃዶች የሚሆኑ ብቻ",
|
||||
noMatch: "በእነዚህ ማጣሪያዎች የሚመጣጠን የግል ሰነድ የለም።",
|
||||
clearFilters: "አጽዳ",
|
||||
fileCount_one: "{{count}} ፋይል",
|
||||
fileCount_other: "{{count}} ፋይሎች",
|
||||
deleteWarning:
|
||||
"ማስገቢያው ከሁሉም አመልካቾች \u2018ሰነዶቼ\u2019 ውስጥ ይጠፋል። ቀደም ብለው የተሰቀሉ ፋይሎች ይቀመጣሉ፣ ነገር ግን ማንም ሊደርስባቸው አይችልም።",
|
||||
},
|
||||
},
|
||||
|
||||
seafarerRegistry: {
|
||||
@@ -1483,6 +1639,15 @@ export const am: Translations = {
|
||||
newFeeLabel: "የአዲስ ማመልከቻ ክፍያ",
|
||||
sameRateLabel: "እድሳትን በተመሳሳይ ተመን ያስከፍሉ",
|
||||
sameRateDescription: "የተለየ የእድሳት ክፍያ ለማዘጋጀት ያጥፉ።",
|
||||
examinedNotice:
|
||||
"ይህ የምስክር ወረቀት በፈተና የሚገኝ ስለሆነ በሦስት ደረጃዎች ይከፈላል። ከላይ ካሉት ክፍያዎች በተለየ እነዚህ በሚጸድቁበት ጊዜ አይቀዘቅዙም — ለውጡ ቀደም ብለው በሂደት ላይ ላሉ ተፈታኞችም ይሠራል። ተፈታኞች ሊከፍሉት በመጠባበቅ ላይ እያሉ አንዱን ማጥፋት ተቀባይነት አያገኝም።",
|
||||
eligibilityFeeLabel: "የብቁነት ምዘና ክፍያ",
|
||||
eligibilityFeeHint:
|
||||
"ኃላፊው ማመልከቻውን ከመገምገሙ በፊት፣ በሚቀርብበት ጊዜ የሚከፈል። ክፍያ ከሌለ ባዶ ይተውት።",
|
||||
examinationFeeLabel: "የፈተና ክፍያ",
|
||||
examinationFeeHint: "ብቁነቱ ሲጸድቅ የሚከፈል፣ እንዲሁም ለድጋሚ ፈተና እንደገና።",
|
||||
certificateFeeLabel: "የምስክር ወረቀት ክፍያ",
|
||||
certificateFeeHint: "ካለፉ በኋላ፣ የምስክር ወረቀቱ ከመሰጠቱ በፊት የሚከፈል።",
|
||||
renewalFeeLabel: "የእድሳት ክፍያ",
|
||||
currencyLabel: "ገንዘብ",
|
||||
cancel: "ሰርዝ",
|
||||
|
||||
@@ -88,13 +88,18 @@ export const en = {
|
||||
postWaiverQueue: 'Post-Waiver Queue',
|
||||
cocQueue: 'CoC Queue',
|
||||
copQueue: 'CoP Queue',
|
||||
endorsementCocQueue: 'CoC Endorsement Queue',
|
||||
endorsementGocQueue: 'GOC Endorsement Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselTransfers: 'Vessel Ownership Transfer',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
biometricEnrollment: 'Biometric Enrollment',
|
||||
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
||||
applications: 'Applications',
|
||||
paymentConfig: 'Payment Config',
|
||||
pickupDesk: 'Pickup Desk',
|
||||
pickupOffices: 'Pickup Offices',
|
||||
analytics: 'Analytics',
|
||||
seaServiceVerification: 'Sea Service Verification',
|
||||
medicalVerification: 'Medical Verification',
|
||||
@@ -468,9 +473,32 @@ export const en = {
|
||||
unverified: 'Unverified',
|
||||
tabs: {
|
||||
profile: 'Profile',
|
||||
signature: 'Signature',
|
||||
security: 'Security',
|
||||
preferences: 'Preferences',
|
||||
},
|
||||
signature: {
|
||||
title: 'Signing signature',
|
||||
description:
|
||||
'Drawn onto the certificates you approve. Draw it once or upload an image.',
|
||||
reissueNotice:
|
||||
'Changing your signature does not alter a certificate already issued — it applies to whatever you sign from now on.',
|
||||
current: 'Signature on file',
|
||||
currentAlt: 'Your stored signature',
|
||||
none: 'No signature on file yet. Certificates you approve will be issued without one.',
|
||||
modeDraw: 'Draw',
|
||||
modeUpload: 'Upload',
|
||||
save: 'Save signature',
|
||||
clear: 'Clear',
|
||||
choose: 'Choose image',
|
||||
fileHint: 'PNG or JPEG, up to 2 MB.',
|
||||
remove: 'Remove',
|
||||
saved: 'Signature saved.',
|
||||
removed: 'Signature removed.',
|
||||
badType: 'Only PNG and JPEG images are accepted.',
|
||||
tooLarge: 'That image is larger than 2 MB.',
|
||||
drawFailed: 'Could not read the drawing. Please try again.',
|
||||
},
|
||||
personalHint: 'Your name as it appears on official EMA documents.',
|
||||
languageTitle: 'Language',
|
||||
languageHint: 'Choose the language used across the admin panel.',
|
||||
@@ -853,6 +881,7 @@ export const en = {
|
||||
|
||||
configuration: {
|
||||
title: 'Configuration',
|
||||
personalDocumentsTab: 'Personal Documents',
|
||||
departments: 'Departments',
|
||||
professions: 'Professions',
|
||||
departmentsList: 'Departments',
|
||||
@@ -900,11 +929,18 @@ export const en = {
|
||||
type: 'Type',
|
||||
anyType: 'Any',
|
||||
typeCol: 'Type',
|
||||
kind: 'Application kind',
|
||||
kindValues: {
|
||||
NEW: 'New',
|
||||
RENEWAL: 'Renewal',
|
||||
REISSUE: 'Replacement',
|
||||
},
|
||||
statusCol: 'Status',
|
||||
statusValues: {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
|
||||
UNDER_EVALUATION: 'Under Evaluation',
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
@@ -976,6 +1012,7 @@ export const en = {
|
||||
},
|
||||
|
||||
review: {
|
||||
certificateSuperseded: 'Certificate superseded',
|
||||
summary: 'Summary',
|
||||
officer: 'Officer',
|
||||
supervisor: 'Supervisor',
|
||||
@@ -1026,6 +1063,9 @@ export const en = {
|
||||
morning: 'Morning',
|
||||
afternoon: 'Afternoon',
|
||||
schedule: 'Schedule',
|
||||
reschedule: 'Reschedule',
|
||||
rescheduleReason: 'Why is it moving?',
|
||||
rescheduleReasonHint: 'Kept in the audit trail and sent to the applicant.',
|
||||
pickDate: 'Pick a date first',
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
@@ -1062,6 +1102,7 @@ export const en = {
|
||||
completeReview: 'Complete review',
|
||||
approveDocuments: 'Approve documents',
|
||||
scheduleInspection: 'Schedule inspection',
|
||||
rescheduleInspection: 'Reschedule inspection',
|
||||
recordInspection: 'Record inspection result',
|
||||
finalApprove: 'Approve & issue',
|
||||
requestAdjustment: 'Request adjustment',
|
||||
@@ -1209,6 +1250,7 @@ export const en = {
|
||||
assign: 'Reassigned',
|
||||
assignReviewer: 'Review assigned',
|
||||
scheduled: 'Inspection scheduled',
|
||||
rescheduled: 'Inspection rescheduled',
|
||||
inspectionPassed: 'Inspection passed',
|
||||
inspectionFailed: 'Inspection failed',
|
||||
},
|
||||
@@ -1231,12 +1273,14 @@ export const en = {
|
||||
|
||||
designer: {
|
||||
title: 'Certificate designer',
|
||||
subtitle: 'Design the certificate issued to licence holders, and set how long it stays valid.',
|
||||
subtitle: 'Design the certificate issued to licence holders.',
|
||||
licenceType: 'Licence type',
|
||||
validityYears: 'Valid for (years)',
|
||||
validityHint: 'Applied when a licence is issued',
|
||||
saveValidity: 'Save validity',
|
||||
validitySaved: 'Validity updated',
|
||||
validity: 'Valid for',
|
||||
validityMonths_one: '{{count}} month',
|
||||
validityMonths_other: '{{count}} months',
|
||||
validityDays_one: '{{count}} day',
|
||||
validityDays_other: '{{count}} days',
|
||||
validityEditedOn: '— set on Certificate Requirements → Behaviour',
|
||||
newVersion: 'New version',
|
||||
versions: 'Versions',
|
||||
name: 'Version name',
|
||||
@@ -1278,6 +1322,86 @@ export const en = {
|
||||
loadFailed: 'Could not load licence types',
|
||||
tabSchema: 'Form schema',
|
||||
tabDocuments: 'Document requirements',
|
||||
tabBehavior: 'Behaviour',
|
||||
behavior: {
|
||||
workflow: 'Workflow',
|
||||
workflowWarning:
|
||||
'These three settings decide the course an application runs. They cannot be changed while applications of this type are still awaiting a decision — saving will be refused until those are decided.',
|
||||
workflowProfile: 'Workflow profile',
|
||||
workflowProfileHint: 'REGISTRATION drops the evaluation and inspection stages.',
|
||||
standard: 'Standard licence course',
|
||||
registration: 'Registration (review only)',
|
||||
completionEffect: 'Completion effect',
|
||||
completionEffectHint:
|
||||
'Platform action taken when an application of this type completes.',
|
||||
registerSeafarer: 'Register the seafarer',
|
||||
registerVessel: 'Register the vessel',
|
||||
openDocuments: 'Open seafarer documents',
|
||||
noEffect: 'No side effect',
|
||||
requiresExamination: 'Requires an examination',
|
||||
requiresExaminationHint:
|
||||
'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.',
|
||||
issuesCertificate: 'Issues a certificate',
|
||||
issuesCertificateHint:
|
||||
'Turn off for a type that ends with an EMA decision and never reaches a payment stage. Turning it back on requires a new-application fee to be set, or approved applicants would be asked for a fee that does not exist.',
|
||||
inspectionRequired: 'Requires a physical inspection',
|
||||
inspectionRequiredHint:
|
||||
'An officer cannot approve until an inspection has been recorded and passed. Safe to change with applications in progress — an officer can still assign an inspector to a file already under evaluation.',
|
||||
renewalEnabled: 'Holders may renew this licence',
|
||||
renewalEnabledHint:
|
||||
'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
|
||||
serviceKind: 'Service kind',
|
||||
serviceKindHint: 'Catalogue classification only — no workflow depends on it.',
|
||||
license: 'Licence',
|
||||
registrationKind: 'Registration',
|
||||
eligibility: 'Eligibility gates',
|
||||
eligibilityHint:
|
||||
'Checked when an applicant submits, and the capital requirement again when an officer approves. Tightening a gate can stop someone mid-way from submitting a draft they have already started.',
|
||||
capitalThreshold: 'Minimum paid-up capital',
|
||||
capitalOn: 'Require a minimum paid-up capital',
|
||||
capitalHint:
|
||||
'An officer cannot approve until they have verified capital at or above this. Applies to applications already in the queue, not just new ones.',
|
||||
validity: 'Valid for',
|
||||
validityUnit: 'Validity unit',
|
||||
unitMonths: 'Months',
|
||||
unitDays: 'Days',
|
||||
validityHint:
|
||||
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
|
||||
requiresSeafarer: 'Requires an active seafarer registration',
|
||||
requiresSeafarerHint:
|
||||
'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.',
|
||||
requiresMedical: 'Requires a current medical certificate',
|
||||
minSeaTime: 'Minimum sea time (days)',
|
||||
minSeaTimeOn: 'Require verified sea time',
|
||||
certificateCategory: 'Certificate category',
|
||||
certificateCategoryHint:
|
||||
'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.',
|
||||
notACertificate: 'Not a certificate',
|
||||
renewal: 'Validity and renewal',
|
||||
renewalWindow: 'Renewal opens (days before expiry)',
|
||||
reminders: 'Expiry reminders (days before)',
|
||||
remindersHint: 'The holder is reminded at each of these offsets.',
|
||||
applicantRules: 'Applicant rules',
|
||||
requiresOperatorMode: 'Applicant must declare this operating mode',
|
||||
requiresOperatorModeHint:
|
||||
'Turn off for person-centric registrations any signed-in applicant may start.',
|
||||
allowMultipleDrafts: 'Allow several open drafts at once',
|
||||
allowMultipleDraftsHint:
|
||||
'On for per-asset registrations — registering a second vessel must not resume the first one\u2019s draft.',
|
||||
requiresScheduling: 'Schedule a pickup date before issuing',
|
||||
requiresSchedulingHint: 'For documents printed once and handed over in person.',
|
||||
slaHours: 'Decision target (hours)',
|
||||
slaOn: 'Track against an SLA',
|
||||
slaHint:
|
||||
'Drives the Age/SLA column and the Overdue view. Applies to applications already submitted as well as new ones.',
|
||||
uniqueFormKey: 'One application per answer at',
|
||||
uniqueFormKeyHint:
|
||||
'Dotted form path, e.g. shipment.billOfLading. The field must exist in this type\u2019s form, or no application can be submitted.',
|
||||
noUniqueRule: 'No uniqueness rule',
|
||||
save: 'Save configuration',
|
||||
saved: 'Configuration saved.',
|
||||
noPermission: 'You do not have permission to change licence configuration.',
|
||||
},
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
saveChanges: 'Save changes',
|
||||
@@ -1377,13 +1501,47 @@ export const en = {
|
||||
modeOptional: 'Optional upload',
|
||||
conditionRequired: 'A conditional requirement needs a condition',
|
||||
allowedTypes: 'Allowed file types',
|
||||
allowedTypesHelp:
|
||||
'The applicant can only upload these. PDF, JPEG and PNG are selected by default.',
|
||||
maxSize: 'Max file size (MB)',
|
||||
requiresValidity: 'Requires validity dates',
|
||||
allowMultiple: 'Allow multiple uploads',
|
||||
multiple: 'multiple',
|
||||
scope: 'Applies to',
|
||||
scopeAll: 'All licences',
|
||||
scopeSelected: 'Selected licence types',
|
||||
scopePlaceholder: 'Choose licence types',
|
||||
scopeHelp:
|
||||
'Only applicants who declared one of these as a mode of operation are asked for it.',
|
||||
scopeRequired: 'Choose at least one licence type',
|
||||
maxFiles: 'Files accepted',
|
||||
maxFilesHelp: 'Leave empty for no limit. Use 2 for a document with a front and a back.',
|
||||
maxFilesUnlimited: 'No limit',
|
||||
sortOrder: 'Sort order',
|
||||
when: 'when',
|
||||
},
|
||||
personal: {
|
||||
title: 'Personal documents',
|
||||
subtitle:
|
||||
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
|
||||
add: 'Add personal document',
|
||||
empty: 'No personal documents configured yet.',
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Search by name or key',
|
||||
moreTypes: ' +{{count}} more',
|
||||
columns: {
|
||||
document: 'Document',
|
||||
actions: 'Actions',
|
||||
},
|
||||
filterAny: 'Any licence type',
|
||||
filterGlobal: 'All-licence documents only',
|
||||
noMatch: 'No personal document matches those filters.',
|
||||
clearFilters: 'Clear',
|
||||
fileCount_one: '{{count}} file',
|
||||
fileCount_other: '{{count}} files',
|
||||
deleteWarning:
|
||||
'The slot disappears from every applicant\u2019s My Documents. Files already uploaded are kept, but nobody can reach them.',
|
||||
},
|
||||
},
|
||||
|
||||
seafarerRegistry: {
|
||||
@@ -1488,6 +1646,15 @@ export const en = {
|
||||
newFeeLabel: 'New application fee',
|
||||
sameRateLabel: 'Charge renewal at the same rate',
|
||||
sameRateDescription: 'Turn off to set a separate renewal fee.',
|
||||
examinedNotice:
|
||||
'This certificate is earned by examination, so it is charged in three stages. Unlike the fees above, these are not fixed at approval — a change applies to candidates already part-way through. Clearing one while candidates are waiting to pay it will be refused.',
|
||||
eligibilityFeeLabel: 'Eligibility assessment fee',
|
||||
eligibilityFeeHint:
|
||||
'Due on submission, before an officer reviews the application. Leave empty for no charge.',
|
||||
examinationFeeLabel: 'Examination fee',
|
||||
examinationFeeHint: 'Due once eligibility is approved, and again for a retake.',
|
||||
certificateFeeLabel: 'Certificate fee',
|
||||
certificateFeeHint: 'Due after a pass, before the certificate is issued.',
|
||||
renewalFeeLabel: 'Renewal fee',
|
||||
currencyLabel: 'Currency',
|
||||
cancel: 'Cancel',
|
||||
|
||||
@@ -2,12 +2,15 @@ import {
|
||||
IconAnchor,
|
||||
IconArrowsExchange,
|
||||
IconBook2,
|
||||
IconBuildingWarehouse,
|
||||
IconCalendarEvent,
|
||||
IconChartBar,
|
||||
IconClipboardList,
|
||||
IconClipboardText,
|
||||
IconCreditCard,
|
||||
IconFileDescription,
|
||||
IconFilePlus,
|
||||
IconFingerprint,
|
||||
IconGauge,
|
||||
IconGavel,
|
||||
IconHeart,
|
||||
@@ -25,9 +28,9 @@ import {
|
||||
IconTruck,
|
||||
IconUsers,
|
||||
IconUserShield,
|
||||
} from '@tabler/icons-react';
|
||||
import type { NavSection } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS as P } from '@ema-platform/auth';
|
||||
} from "@tabler/icons-react";
|
||||
import type { NavSection } from "@ema-platform/ui";
|
||||
import { LICENSE_PERMISSIONS as P } from "@ema-platform/auth";
|
||||
|
||||
/**
|
||||
* Every licence-type queue and its review workspace share one gate: the
|
||||
@@ -35,6 +38,18 @@ import { LICENSE_PERMISSIONS as P } from '@ema-platform/auth';
|
||||
*/
|
||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||
|
||||
/**
|
||||
* Seafarer queues are reachable by registry staff and application reviewers
|
||||
* alike, so both permission families gate them as any-of.
|
||||
*/
|
||||
const SEAFARER_QUEUE = [P.VIEW_SEAFARER_REGISTRY, ...APPLICATION_QUEUE];
|
||||
|
||||
const BIOMETRIC_ENROLLMENT = [
|
||||
P.ENROLL_BIOMETRICS,
|
||||
P.VIEW_BIOMETRICS,
|
||||
P.VIEW_SEAFARER_REGISTRY,
|
||||
];
|
||||
|
||||
/**
|
||||
* The backoffice information architecture.
|
||||
*
|
||||
@@ -50,127 +65,294 @@ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||
*/
|
||||
export const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
|
||||
items: [
|
||||
{ to: "/dashboard", label: "nav.dashboard", icon: IconLayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupLicensing',
|
||||
label: "nav.groupLicensing",
|
||||
items: [
|
||||
{
|
||||
to: '/licence-review',
|
||||
label: 'nav.allApplications',
|
||||
to: "/licence-review",
|
||||
label: "nav.allApplications",
|
||||
icon: IconListCheck,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
},
|
||||
{
|
||||
// A disclosure, not a destination — each child deep-links the grid to
|
||||
// one type, which is a facet of the same workspace.
|
||||
label: 'nav.byType',
|
||||
label: "nav.byType",
|
||||
icon: IconTruck,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
children: [
|
||||
{ to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor, permissions: APPLICATION_QUEUE },
|
||||
{
|
||||
to: "/licence-review/type/FREIGHT_FORWARDER",
|
||||
label: "nav.typeFreightForwarder",
|
||||
icon: IconTruck,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/SHIPPING_AGENT",
|
||||
label: "nav.typeShippingAgent",
|
||||
icon: IconShip,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/COMBINED_SA_FF",
|
||||
label: "nav.typeCombined",
|
||||
icon: IconFileDescription,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/JOINT_INVESTOR",
|
||||
label: "nav.typeJointInvestment",
|
||||
icon: IconUsers,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR",
|
||||
label: "nav.typeMto",
|
||||
icon: IconAnchor,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
to: '/licence-register',
|
||||
label: 'nav.licenceRegister',
|
||||
to: "/licence-register",
|
||||
label: "nav.licenceRegister",
|
||||
icon: IconListCheck,
|
||||
permissions: [P.VIEW_LICENSES],
|
||||
},
|
||||
{ to: '/licence-review/type/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
|
||||
{
|
||||
to: "/licence-review/type/PRE_WAIVER",
|
||||
label: "nav.preWaiverQueue",
|
||||
icon: IconShieldOff,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/POST_WAIVER",
|
||||
label: "nav.postWaiverQueue",
|
||||
icon: IconShieldOff,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
},
|
||||
{
|
||||
// Every figure on it is derived from the licence application queue.
|
||||
to: '/logistics-head-dashboard',
|
||||
label: 'nav.logisticsHeadDashboard',
|
||||
to: "/logistics-head-dashboard",
|
||||
label: "nav.logisticsHeadDashboard",
|
||||
icon: IconGauge,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupSeafarer',
|
||||
label: "nav.groupSeafarer",
|
||||
items: [
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_SEAFARER', label: 'nav.endorsementQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupVessels',
|
||||
items: [
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupExaminations',
|
||||
items: [
|
||||
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark, permissions: [P.APPROVE_QUESTION, P.AUTHOR_QUESTION] },
|
||||
{
|
||||
to: '/exams',
|
||||
label: 'nav.exams',
|
||||
to: "/seafarer-registry",
|
||||
label: "nav.seafarerRegistry",
|
||||
icon: IconUsers,
|
||||
permissions: [P.VIEW_SEAFARER_REGISTRY],
|
||||
},
|
||||
{
|
||||
to: "/biometric-enrollment",
|
||||
label: "nav.biometricEnrollment",
|
||||
icon: IconFingerprint,
|
||||
permissions: BIOMETRIC_ENROLLMENT,
|
||||
},
|
||||
{
|
||||
to: "/seafarer-registrations",
|
||||
label: "nav.seafarerRegistrationQueue",
|
||||
icon: IconId,
|
||||
permissions: SEAFARER_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/CERTIFICATE_OF_COMPETENCY",
|
||||
label: "nav.cocQueue",
|
||||
icon: IconShieldCheck,
|
||||
permissions: SEAFARER_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/CERTIFICATE_OF_PROFICIENCY",
|
||||
label: "nav.copQueue",
|
||||
icon: IconShieldCheck,
|
||||
permissions: SEAFARER_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/seaman-book-queue",
|
||||
label: "nav.seamanBookQueue",
|
||||
icon: IconBook2,
|
||||
permissions: SEAFARER_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/btc-queue",
|
||||
label: "nav.btcQueue",
|
||||
icon: IconShieldCheck,
|
||||
permissions: SEAFARER_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/ENDORSEMENT_COC",
|
||||
label: "nav.endorsementCocQueue",
|
||||
icon: IconRubberStamp,
|
||||
permissions: [P.VIEW_SEAFARER_REGISTRY],
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/ENDORSEMENT_GOC",
|
||||
label: "nav.endorsementGocQueue",
|
||||
icon: IconRubberStamp,
|
||||
permissions: [P.VIEW_SEAFARER_REGISTRY],
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/ENDORSEMENT_SEAFARER",
|
||||
label: "nav.endorsementQueue",
|
||||
icon: IconRubberStamp,
|
||||
permissions: APPLICATION_QUEUE,
|
||||
},
|
||||
{
|
||||
to: "/sea-service-verification",
|
||||
label: "nav.seaServiceVerification",
|
||||
icon: IconAnchor,
|
||||
permissions: [P.VERIFY_SEAFARER_RECORDS],
|
||||
},
|
||||
{
|
||||
to: "/medical-verification",
|
||||
label: "nav.medicalVerification",
|
||||
icon: IconHeart,
|
||||
permissions: [P.VERIFY_SEAFARER_RECORDS],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "nav.groupVessels",
|
||||
items: [
|
||||
{
|
||||
to: "/vessel-registration-report",
|
||||
label: "nav.vesselRegistrationReport",
|
||||
icon: IconChartBar,
|
||||
permissions: [P.VIEW_VESSEL_REGISTRY],
|
||||
},
|
||||
{
|
||||
to: "/vessel-registration-queue",
|
||||
label: "nav.vesselRegistrationQueue",
|
||||
icon: IconAnchor,
|
||||
permissions: [P.VIEW_VESSEL_REGISTRY],
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/VESSEL_REGISTRATION",
|
||||
label: "nav.vesselRegistrationApplicationQueue",
|
||||
icon: IconAnchor,
|
||||
permissions: [P.VIEW_VESSEL_REGISTRY],
|
||||
},
|
||||
{
|
||||
to: "/licence-review/type/VESSEL_OWNERSHIP_TRANSFER",
|
||||
label: "nav.ownershipTransferQueue",
|
||||
icon: IconArrowsExchange,
|
||||
permissions: [P.VIEW_VESSEL_REGISTRY],
|
||||
},
|
||||
{
|
||||
to: "/vessel-registration-queue/new",
|
||||
label: "nav.vesselFormBuilder",
|
||||
icon: IconFilePlus,
|
||||
soon: true,
|
||||
permissions: [P.VIEW_VESSEL_REGISTRY],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "nav.groupExaminations",
|
||||
items: [
|
||||
{
|
||||
to: "/questions",
|
||||
label: "nav.questions",
|
||||
icon: IconQuestionMark,
|
||||
permissions: [P.APPROVE_QUESTION, P.AUTHOR_QUESTION],
|
||||
},
|
||||
{
|
||||
to: "/exams",
|
||||
label: "nav.exams",
|
||||
icon: IconClipboardList,
|
||||
permissions: [P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT],
|
||||
permissions: [
|
||||
P.MANAGE_EXAMS,
|
||||
P.RECORD_EXAM_ATTENDANCE,
|
||||
P.MANAGE_EXAM_INCIDENTS,
|
||||
P.PUBLISH_EXAM_RESULT,
|
||||
],
|
||||
},
|
||||
{
|
||||
to: '/exam-results',
|
||||
label: 'nav.examResults',
|
||||
to: "/exam-results",
|
||||
label: "nav.examResults",
|
||||
icon: IconReport,
|
||||
permissions: [P.RECORD_EXAM_RESULT, P.MODERATE_EXAM_RESULT, P.APPROVE_EXAM_RESULT, P.PUBLISH_EXAM_RESULT],
|
||||
permissions: [
|
||||
P.RECORD_EXAM_RESULT,
|
||||
P.MODERATE_EXAM_RESULT,
|
||||
P.APPROVE_EXAM_RESULT,
|
||||
P.PUBLISH_EXAM_RESULT,
|
||||
],
|
||||
},
|
||||
{
|
||||
to: "/exam-appeals",
|
||||
label: "nav.examAppeals",
|
||||
icon: IconGavel,
|
||||
permissions: [P.DECIDE_EXAM_APPEAL],
|
||||
},
|
||||
{ to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel, permissions: [P.DECIDE_EXAM_APPEAL] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupShared',
|
||||
label: "nav.groupShared",
|
||||
items: [
|
||||
{
|
||||
to: '/certificate-designer',
|
||||
label: 'nav.certificateDesigner',
|
||||
to: "/certificate-designer",
|
||||
label: "nav.certificateDesigner",
|
||||
icon: IconRosetteDiscountCheck,
|
||||
permissions: [P.VIEW_TEMPLATES],
|
||||
},
|
||||
{
|
||||
to: '/certificate-requirements',
|
||||
label: 'nav.certificateRequirements',
|
||||
to: "/certificate-requirements",
|
||||
label: "nav.certificateRequirements",
|
||||
icon: IconClipboardText,
|
||||
permissions: [P.VIEW_LICENSE_TYPES],
|
||||
},
|
||||
{
|
||||
to: '/payment-config',
|
||||
label: 'nav.paymentConfig',
|
||||
to: "/payment-config",
|
||||
label: "nav.paymentConfig",
|
||||
icon: IconCreditCard,
|
||||
permissions: [P.VIEW_PAYMENTS],
|
||||
},
|
||||
{
|
||||
to: '/pickup-desk',
|
||||
label: 'nav.pickupDesk',
|
||||
icon: IconCalendarEvent,
|
||||
permissions: [P.MANAGE_PICKUP_DESK],
|
||||
},
|
||||
{
|
||||
to: '/pickup-offices',
|
||||
label: 'nav.pickupOffices',
|
||||
icon: IconBuildingWarehouse,
|
||||
permissions: [P.CONFIGURE_PICKUP_OFFICES],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupAdministration',
|
||||
label: "nav.groupAdministration",
|
||||
items: [
|
||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||
{
|
||||
to: "/um/user-management/dashboard",
|
||||
label: "nav.userManagement",
|
||||
icon: IconUserShield,
|
||||
},
|
||||
{
|
||||
// Professions, locations and certifications have no dedicated keys;
|
||||
// the config-view keys are the closest published contract.
|
||||
to: '/configuration',
|
||||
label: 'nav.configuration',
|
||||
to: "/configuration",
|
||||
label: "nav.configuration",
|
||||
icon: IconSettings,
|
||||
permissions: [P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES],
|
||||
},
|
||||
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
|
||||
{
|
||||
to: "/analytics",
|
||||
label: "nav.analytics",
|
||||
icon: IconChartBar,
|
||||
soon: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
// `/profile` deliberately absent: it is a property of the signed-in user,
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
SeaServiceVerificationPage,
|
||||
} from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
||||
import { PickupDeskPage } from '../features/pickup/pages/PickupDeskPage';
|
||||
import { PickupOfficesPage } from '../features/pickup/pages/PickupOfficesPage';
|
||||
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
||||
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
|
||||
import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage';
|
||||
@@ -48,10 +50,17 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie
|
||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
||||
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
|
||||
import { BiometricEnrollmentPage } from '../features/biometric-enrollment/pages/BiometricEnrollmentPage';
|
||||
|
||||
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||
|
||||
const BIOMETRIC_ENROLLMENT = [
|
||||
P.ENROLL_BIOMETRICS,
|
||||
P.VIEW_BIOMETRICS,
|
||||
P.VIEW_SEAFARER_REGISTRY,
|
||||
];
|
||||
|
||||
/** Route gate: same keys as the route's nav item in nav-config.ts. */
|
||||
const guard = (anyOf: string[], element: ReactNode) => (
|
||||
<RequirePermission anyOf={anyOf}>{element}</RequirePermission>
|
||||
@@ -99,7 +108,10 @@ const router = createBrowserRouter([
|
||||
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
||||
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
||||
{ path: 'pickup-desk', element: guard([P.MANAGE_PICKUP_DESK], <PickupDeskPage />) },
|
||||
{ path: 'pickup-offices', element: guard([P.CONFIGURE_PICKUP_OFFICES], <PickupOfficesPage />) },
|
||||
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||
{ path: 'biometric-enrollment', element: guard(BIOMETRIC_ENROLLMENT, <BiometricEnrollmentPage />) },
|
||||
// Seafarer registration is not a licence: own queue, own review.
|
||||
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
|
||||
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
|
||||
|
||||
@@ -42,14 +42,14 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
},
|
||||
// Unit tests for the pure helpers behind a screen (formatters, URL state).
|
||||
// Component tests are deliberately not set up: nothing here renders React,
|
||||
// so no jsdom environment or setup file is needed.
|
||||
// test: {
|
||||
// watch: false,
|
||||
// globals: true,
|
||||
// environment: 'node',
|
||||
// include: ['src/**/*.spec.ts'],
|
||||
// reporters: ['default'],
|
||||
// },
|
||||
// Unit tests for the pure helpers behind a screen (formatters, URL state,
|
||||
// queue views). Component tests are deliberately not set up: nothing here
|
||||
// renders React, so no jsdom environment or setup file is needed.
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
|
||||
reporters: ['default'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ own ports, against its own database:
|
||||
| Backoffice | 4303 | same |
|
||||
| Database | — | `ema_e2e` |
|
||||
|
||||
This is deliberate. A developer's stack is usually already up on 3000/4200/4201,
|
||||
This is deliberate. A developer's stack is usually already up on 3000/3001/4201,
|
||||
and `dev/start.sh` **rewrites** `emaapi/apps/server/emaapi/.env` and the apps'
|
||||
`.env.local` on every run — a suite that read those files would point at
|
||||
whichever stack was started last. The API is launched with `DATABASE_NAME`,
|
||||
|
||||
@@ -28,19 +28,21 @@ import {
|
||||
IconEye,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { authStorage, useCurrentProfile } from '@ema-platform/auth';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useApiQuery,
|
||||
useBypassPaymentMutation,
|
||||
useDiscardApplicationMutation,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
useGetMySeaServiceRecordsQuery,
|
||||
useGetMyMedicalCertificatesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
||||
import { ConfirmModal, PdfPreviewModal } from '@ema-platform/ui';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
import { examStageFor, registrationForApplication } from '../../licensing/exam-stage';
|
||||
import type { MyRegistration } from '../../exams/pages/ExamsPage';
|
||||
@@ -61,6 +63,8 @@ interface CertificatesOverview {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
type: string;
|
||||
/** Which license type this is a draft of — gates the Apply buttons. */
|
||||
licenseTypeKey: string | null;
|
||||
submitted: string;
|
||||
status: string;
|
||||
/** The fee owed at the current status, or null when nothing is due. */
|
||||
@@ -137,9 +141,7 @@ function formatDate(value: string | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
import { BASE_API_URL as API_BASE } from '@ema-platform/api';
|
||||
|
||||
async function generateCertificate(profileId: string): Promise<Blob> {
|
||||
const token = authStorage.getToken();
|
||||
@@ -172,6 +174,8 @@ export function CertificatesPage() {
|
||||
// never rendered. Same shortcut My Applications offers.
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [discardApplication, { isLoading: discarding }] = useDiscardApplicationMutation();
|
||||
const [discardTarget, setDiscardTarget] = useState<{ id: string; applicationId: string } | null>(null);
|
||||
|
||||
const { data, refetch } = useApiQuery<CertificatesOverview>({
|
||||
url: '/certificates/my',
|
||||
@@ -194,6 +198,22 @@ export function CertificatesPage() {
|
||||
notifications.show({ color: 'red', title: 'Bypass failed', message: extractErrorMessage(err) });
|
||||
}
|
||||
};
|
||||
const handleDiscard = async () => {
|
||||
if (!discardTarget) return;
|
||||
try {
|
||||
await discardApplication(discardTarget.applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Draft discarded',
|
||||
message: `${discardTarget.id} has been deleted.`,
|
||||
});
|
||||
setDiscardTarget(null);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
notifications.show({ color: 'red', title: 'Could not discard', message: extractErrorMessage(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const certificates = data?.certificates ?? [];
|
||||
const applications = data?.applications ?? [];
|
||||
|
||||
@@ -222,6 +242,12 @@ export function CertificatesPage() {
|
||||
// The three queries below only build the human-readable reason list for
|
||||
// the tooltip/banner — the gate itself is the one boolean.
|
||||
const { profile, eligibleForCoc: canApply } = useCurrentProfile();
|
||||
// One open draft per certificate type: the API resumes the existing draft
|
||||
// rather than stacking a second, so the button says so instead of looking
|
||||
// like it did nothing.
|
||||
const draftTypeKeys = new Set(
|
||||
applications.filter((a) => a.status === 'DRAFT').map((a) => a.licenseTypeKey),
|
||||
);
|
||||
const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery();
|
||||
const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery();
|
||||
|
||||
@@ -289,23 +315,25 @@ export function CertificatesPage() {
|
||||
disabled, so the reason still shows on hover. */}
|
||||
<span>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
|
||||
disabled={!canApply}
|
||||
>
|
||||
Apply for CoC
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')}
|
||||
disabled={!canApply}
|
||||
>
|
||||
Apply for CoP
|
||||
</Button>
|
||||
{([
|
||||
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
|
||||
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
|
||||
]).map(({ key, label, variant }) => {
|
||||
const hasDraft = draftTypeKeys.has(key);
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
variant={variant}
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate(`/licensing/${key}/apply`)}
|
||||
disabled={!canApply || hasDraft}
|
||||
title={hasDraft ? `You already have a ${label} draft — continue it below.` : undefined}
|
||||
>
|
||||
{`Apply for ${label}`}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -410,8 +438,21 @@ export function CertificatesPage() {
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/applications/${app.applicationId}`)}
|
||||
>
|
||||
Details
|
||||
{app.status === 'DRAFT' ? 'Continue' : 'Details'}
|
||||
</Text>
|
||||
{/* Drafts only: once submitted the filing is a record,
|
||||
and withdrawing it is the officer's call. */}
|
||||
{app.status === 'DRAFT' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
onClick={() => setDiscardTarget({ id: app.id, applicationId: app.applicationId })}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -457,6 +498,16 @@ export function CertificatesPage() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<ConfirmModal
|
||||
opened={!!discardTarget}
|
||||
onClose={() => setDiscardTarget(null)}
|
||||
onConfirm={handleDiscard}
|
||||
loading={discarding}
|
||||
title="Discard draft"
|
||||
message={`Delete draft ${discardTarget?.id ?? ''}? Anything filled in so far is lost.`}
|
||||
confirmLabel="Discard"
|
||||
/>
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={!!previewUrl}
|
||||
onClose={() => setPreviewUrl(null)}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconPaperclip,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
import {
|
||||
replacePersonalDocumentFile,
|
||||
uploadPersonalDocumentFile,
|
||||
useDeletePersonalDocumentFileMutation,
|
||||
useGetMyPersonalDocumentsQuery,
|
||||
type AttachmentFile,
|
||||
type PersonalDocumentError,
|
||||
type PersonalDocumentSlot,
|
||||
type PersonalDocumentUploadResult,
|
||||
} from '@ema-platform/api';
|
||||
import { PORTAL_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
/**
|
||||
* A refused delete comes back through RTK, which nests the server's payload
|
||||
* under `data`. Uploads report theirs directly — see the XHR helper.
|
||||
*/
|
||||
function errorBody(err: unknown): PersonalDocumentError {
|
||||
const payload = (err as { data?: { message?: unknown } })?.data?.message;
|
||||
return typeof payload === 'object' && payload !== null
|
||||
? (payload as PersonalDocumentError)
|
||||
: { message: typeof payload === 'string' ? payload : undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* The applicant's own document vault.
|
||||
*
|
||||
* Slots are configuration, not code: the backoffice decides which documents
|
||||
* everyone keeps and how many files each holds, so labels, accepted types and
|
||||
* limits all arrive with the data. The upload button knows it is full for the
|
||||
* same reason the server refuses a third file.
|
||||
*/
|
||||
export function PersonalDocumentSlots({
|
||||
onPreview,
|
||||
}: {
|
||||
onPreview: (preview: { url: string; title: string; mimeType?: string | null }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const { data, isLoading, refetch } = useGetMyPersonalDocumentsQuery();
|
||||
const [deleteFile] = useDeletePersonalDocumentFileMutation();
|
||||
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
// Percent for the upload in flight. A video is minutes of waiting, so the
|
||||
// bar is the difference between waiting and reloading the page.
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AttachmentFile | null>(null);
|
||||
// Mantine's FileButton clears its input through a ref object, and there is
|
||||
// one input per slot and per file, so the objects are kept by key.
|
||||
const resetRefs = useRef<Record<string, { current: (() => void) | null }>>({});
|
||||
function resetRef(key: string) {
|
||||
resetRefs.current[key] ??= { current: null };
|
||||
return resetRefs.current[key] as { current: () => void };
|
||||
}
|
||||
function clearInput(key: string) {
|
||||
resetRefs.current[key]?.current?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checked here as well as on the server so the common mistakes — a PDF where
|
||||
* a photograph belongs, a 12 MB scan — never cost a round trip.
|
||||
*/
|
||||
function rejectFile(slot: PersonalDocumentSlot, file: File): string | null {
|
||||
if (slot.allowedMimeTypes.length && !slot.allowedMimeTypes.includes(file.type)) {
|
||||
return t('documents.personal.errors.unsupported_document_type', {
|
||||
allowed: slot.allowedMimeTypes.join(', '),
|
||||
});
|
||||
}
|
||||
if (file.size > slot.maxSizeMb * 1024 * 1024) {
|
||||
return t('documents.personal.errors.document_too_large', {
|
||||
maxBytes: slot.maxSizeMb * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function describe(body: PersonalDocumentError): string {
|
||||
return t(`documents.personal.errors.${body.message ?? 'unknown'}`, {
|
||||
...body,
|
||||
defaultValue: t('documents.personal.errors.unknown'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes, which still go through RTK and refetch themselves. */
|
||||
async function run(busyKey: string, action: () => Promise<unknown>) {
|
||||
setBusy(busyKey);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
setError(describe(errorBody(err)));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
clearInput(busyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads, which report progress and so bypass RTK — the vault is refetched
|
||||
* by hand once the file has landed.
|
||||
*/
|
||||
async function send(
|
||||
busyKey: string,
|
||||
action: (onProgress: (percent: number) => void) => Promise<PersonalDocumentUploadResult>,
|
||||
) {
|
||||
setBusy(busyKey);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
const result = await action(setProgress);
|
||||
if (result.ok) await refetch();
|
||||
else setError(describe(result.error));
|
||||
setBusy(null);
|
||||
setProgress(null);
|
||||
clearInput(busyKey);
|
||||
}
|
||||
|
||||
function handleUpload(slot: PersonalDocumentSlot, file: File | null) {
|
||||
if (!file) return;
|
||||
const rejected = rejectFile(slot, file);
|
||||
if (rejected) {
|
||||
setError(rejected);
|
||||
clearInput(slot.key);
|
||||
return;
|
||||
}
|
||||
return send(slot.key, (onProgress) =>
|
||||
uploadPersonalDocumentFile({ documentKey: slot.key, file, onProgress }),
|
||||
);
|
||||
}
|
||||
|
||||
function handleReplace(slot: PersonalDocumentSlot, fileId: string, file: File | null) {
|
||||
if (!file) return;
|
||||
const rejected = rejectFile(slot, file);
|
||||
if (rejected) {
|
||||
setError(rejected);
|
||||
clearInput(fileId);
|
||||
return;
|
||||
}
|
||||
return send(fileId, (onProgress) =>
|
||||
replacePersonalDocumentFile({ fileId, file, onProgress }),
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
await run(deleteTarget.id, () => deleteFile(deleteTarget.id).unwrap());
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
|
||||
/** The bar belongs to the card whose slot, or whose file, is uploading. */
|
||||
function isThisSlot(slot: PersonalDocumentSlot, busyKey: string) {
|
||||
return busyKey === slot.key || slot.files.some((f) => f.id === busyKey);
|
||||
}
|
||||
|
||||
if (isLoading) return <Loader size="sm" type="oval" />;
|
||||
|
||||
const slots = data?.slots ?? [];
|
||||
if (slots.length === 0) {
|
||||
return (
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('documents.personal.empty')}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('documents.personal.description')}
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} onClose={() => setError(null)} withCloseButton>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{slots.map((slot) => {
|
||||
const full = slot.maxFiles !== null && slot.files.length >= slot.maxFiles;
|
||||
return (
|
||||
<Card
|
||||
key={slot.key}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{
|
||||
borderStyle: slot.files.length ? 'solid' : 'dashed',
|
||||
borderColor: slot.files.length ? 'var(--mantine-color-teal-4)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>
|
||||
{localized(slot.name)}
|
||||
</Text>
|
||||
{slot.description && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{localized(slot.description)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={slot.files.length ? 'teal' : 'gray'}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{slot.maxFiles === null
|
||||
? t('documents.personal.fileCountUnlimited', { count: slot.files.length })
|
||||
: t('documents.personal.fileCount', {
|
||||
count: slot.files.length,
|
||||
max: slot.maxFiles,
|
||||
})}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
{slot.files.length === 0 ? (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('documents.files.none')}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{slot.files.map((file) => (
|
||||
<Group key={file.id} gap={6} wrap="nowrap">
|
||||
<IconPaperclip size={14} />
|
||||
<Text
|
||||
fz="xs"
|
||||
style={{ flex: 1, cursor: file.url ? 'pointer' : undefined }}
|
||||
c={file.url ? 'blue' : undefined}
|
||||
truncate
|
||||
onClick={() =>
|
||||
file.url &&
|
||||
onPreview({
|
||||
url: file.url,
|
||||
title: file.originalName,
|
||||
mimeType: file.mimeType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{file.originalName}
|
||||
</Text>
|
||||
<RequirePermission
|
||||
anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]}
|
||||
hideOnly
|
||||
>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<FileButton
|
||||
resetRef={resetRef(file.id)}
|
||||
onChange={(picked) => handleReplace(slot, file.id, picked)}
|
||||
accept={slot.allowedMimeTypes.join(',')}
|
||||
>
|
||||
{(props) => (
|
||||
<Tooltip label={t('licensing.documents.replace')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
loading={busy === file.id}
|
||||
{...props}
|
||||
>
|
||||
<IconRefresh size={13} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
<Tooltip label={t('documents.personal.delete')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(file)}
|
||||
>
|
||||
<IconTrash size={13} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{busy !== null && progress !== null && isThisSlot(slot, busy) && (
|
||||
<Stack gap={2} mt="sm">
|
||||
<Progress value={progress} size="sm" radius="xl" animated />
|
||||
<Text fz="xs" c="dimmed" ta="right">
|
||||
{t('documents.personal.uploading', { percent: progress })}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
|
||||
<Tooltip label={t('documents.personal.slotFull')} disabled={!full}>
|
||||
<div>
|
||||
<FileButton
|
||||
resetRef={resetRef(slot.key)}
|
||||
onChange={(picked) => handleUpload(slot, picked)}
|
||||
accept={slot.allowedMimeTypes.join(',')}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
mt="sm"
|
||||
size="xs"
|
||||
variant="light"
|
||||
fullWidth
|
||||
leftSection={<IconUpload size={13} />}
|
||||
loading={busy === slot.key}
|
||||
disabled={full}
|
||||
{...props}
|
||||
>
|
||||
{t('licensing.documents.upload')}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</RequirePermission>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
<Modal
|
||||
opened={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title={t('documents.personal.confirmDelete.title')}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm">
|
||||
{t('documents.personal.confirmDelete.body', {
|
||||
name: deleteTarget?.originalName ?? '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button color="red" loading={busy === deleteTarget?.id} onClick={confirmDelete}>
|
||||
{t('documents.personal.confirmDelete.confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,54 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconCertificate,
|
||||
IconEye,
|
||||
IconHeartbeat,
|
||||
IconIdBadge2,
|
||||
IconPaperclip,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { FilePreviewModal, notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useGetAttachmentsQuery,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyLicensesQuery,
|
||||
useGetMyMedicalCertificatesQuery,
|
||||
useGetMySeaServiceRecordsQuery,
|
||||
useGetMySeafarerDocumentsQuery,
|
||||
useLazyGetMySeafarerDocumentDownloadQuery,
|
||||
type SeafarerDocument,
|
||||
type SeafarerRecordStatus,
|
||||
} from '@ema-platform/api';
|
||||
import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard';
|
||||
import { PersonalDocumentSlots } from '../components/PersonalDocumentSlots';
|
||||
|
||||
/** What the viewer needs: the link, a caption, and how to render it. */
|
||||
type Preview = { url: string; title: string; mimeType?: string | null };
|
||||
|
||||
const RECORD_STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
|
||||
SUBMITTED: 'blue',
|
||||
VERIFIED: 'teal',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
@@ -8,14 +56,403 @@ import { useTranslation } from 'react-i18next';
|
||||
* This page previously rendered invented figures/records that were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
function RecordFiles({
|
||||
ownerType,
|
||||
ownerId,
|
||||
onPreview,
|
||||
}: {
|
||||
ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE' | 'SEAFARER_REGISTRATION';
|
||||
ownerId: string;
|
||||
onPreview: (preview: Preview) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useGetAttachmentsQuery({ ownerType, ownerId });
|
||||
const files = (data ?? []).flatMap((a) => a.files);
|
||||
|
||||
if (isLoading) return <Loader size="xs" type="oval" />;
|
||||
if (files.length === 0)
|
||||
return (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('documents.files.none')}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{files.map((file) => (
|
||||
<Group key={file.id} gap={6} wrap="nowrap">
|
||||
<IconPaperclip size={14} />
|
||||
{file.url ? (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
fz="xs"
|
||||
onClick={() =>
|
||||
onPreview({
|
||||
url: file.url as string,
|
||||
title: file.originalName,
|
||||
mimeType: file.mimeType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{file.originalName}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text fz="xs">{file.originalName}</Text>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text fz="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="xs" fw={500} ta="right">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Seaman Book / BTC — issued on their own workflow, not as licences. */
|
||||
function IssuedDocumentCard({
|
||||
document,
|
||||
onPreview,
|
||||
}: {
|
||||
document: SeafarerDocument;
|
||||
onPreview: (preview: Preview) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
const [download, { isFetching }] = useLazyGetMySeafarerDocumentDownloadQuery();
|
||||
const issued = document.status === 'ISSUED';
|
||||
|
||||
async function open() {
|
||||
try {
|
||||
const { url } = await download(document.id).unwrap();
|
||||
onPreview({ url, title: t(`documents.kind.${document.kind}`) });
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), t('documents.openFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size="lg" variant="light" color={issued ? 'teal' : 'gray'} radius="md">
|
||||
<IconCertificate size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>
|
||||
{t(`documents.kind.${document.kind}`)}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{document.documentNumber ?? document.requestNumber}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge size="sm" variant="light" color={issued ? 'teal' : 'gray'} style={{ flexShrink: 0 }}>
|
||||
{t(`documents.documentStatus.${document.status}`, { defaultValue: document.status })}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Stack gap={4}>
|
||||
{document.issueDate && (
|
||||
<FieldRow label={t('seaRecords.columns.issued')} value={showDate(document.issueDate)} />
|
||||
)}
|
||||
{document.expiryDate && (
|
||||
<FieldRow label={t('seaRecords.columns.expires')} value={showDate(document.expiryDate)} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
mt="sm"
|
||||
size="xs"
|
||||
variant="light"
|
||||
fullWidth
|
||||
leftSection={<IconEye size={14} />}
|
||||
disabled={!issued}
|
||||
loading={isFetching}
|
||||
onClick={open}
|
||||
>
|
||||
{issued ? t('documents.view') : t('documents.notIssued')}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentVaultPage() {
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
|
||||
const { data: licences, isLoading: loadingLicences } = useGetMyLicensesQuery();
|
||||
const { data: issuedDocuments } = useGetMySeafarerDocumentsQuery();
|
||||
const { data: medicals, isLoading: loadingMedicals } = useGetMyMedicalCertificatesQuery();
|
||||
const { data: seaService, isLoading: loadingSeaService } = useGetMySeaServiceRecordsQuery();
|
||||
|
||||
const [getCertificateUrl, { isLoading: isDownloadingCert }] = useGetCertificateUrlMutation();
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
|
||||
async function openCertificate(licenseId: string) {
|
||||
try {
|
||||
const { url } = await getCertificateUrl(licenseId).unwrap();
|
||||
setPreview({ url, title: t('licensing.card.downloadCertificate') });
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), t('documents.openFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
const licenceItems = licences?.items ?? [];
|
||||
const issued = [issuedDocuments?.seamanBook, issuedDocuments?.btc].filter(
|
||||
(d): d is SeafarerDocument => Boolean(d),
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title={t('featureUnavailable.documents.title')}
|
||||
description={t('featureUnavailable.documents.description')}
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>{t('documents.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('documents.subtitle')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="license" variant="outline" radius="md" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="license" leftSection={<IconCertificate size={16} />}>
|
||||
{t('documents.tabs.license')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconHeartbeat size={16} />}>
|
||||
{t('documents.tabs.medical')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
||||
{t('documents.tabs.seaService')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="personal" leftSection={<IconIdBadge2 size={16} />}>
|
||||
{t('documents.tabs.personal')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ── Licences and EMA-issued documents ───────────────────────── */}
|
||||
<Tabs.Panel value="license">
|
||||
<Stack gap="xl">
|
||||
{issued.length > 0 && (
|
||||
<div>
|
||||
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="dimmed">
|
||||
{t('documents.issuedTitle')}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{issued.map((document) => (
|
||||
<IssuedDocumentCard
|
||||
key={document.id}
|
||||
document={document}
|
||||
onPreview={setPreview}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="dimmed">
|
||||
{t('documents.licensesTitle')}
|
||||
</Text>
|
||||
{loadingLicences ? (
|
||||
<Loader size="sm" type="oval" />
|
||||
) : licenceItems.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('documents.empty.licenses')}
|
||||
</Text>
|
||||
) : (
|
||||
// Two per row, not three: licence type names run long
|
||||
// ("Multimodal Transport Operator License") and a third
|
||||
// column truncates them.
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
{licenceItems.map((licence) => (
|
||||
<LicenseCard
|
||||
key={licence.id}
|
||||
license={licence}
|
||||
isDownloading={isDownloadingCert}
|
||||
isRenewing={isRenewing}
|
||||
onDownload={() => openCertificate(licence.id)}
|
||||
onRenew={() => renewLicense(licence)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Medical certificates ────────────────────────────────────── */}
|
||||
<Tabs.Panel value="medical">
|
||||
{loadingMedicals ? (
|
||||
<Loader size="sm" type="oval" />
|
||||
) : (medicals ?? []).length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('documents.empty.medical')}
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{(medicals ?? []).map((record) => (
|
||||
<Card key={record.id} withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size="lg" variant="light" color="pink" radius="md">
|
||||
<IconHeartbeat size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>
|
||||
{record.issuerName}
|
||||
</Text>
|
||||
{record.certificateNumber && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('seaRecords.columns.certNumber', {
|
||||
number: record.certificateNumber,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={RECORD_STATUS_COLOR[record.status]}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t(`seaRecords.columns.recordStatus.${record.status}`, {
|
||||
defaultValue: record.status,
|
||||
})}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Stack gap={4}>
|
||||
<FieldRow
|
||||
label={t('seaRecords.columns.issued')}
|
||||
value={showDate(record.issueDate)}
|
||||
/>
|
||||
<FieldRow
|
||||
label={t('seaRecords.columns.expires')}
|
||||
value={showDate(record.expiryDate)}
|
||||
/>
|
||||
<FieldRow
|
||||
label={t('seaRecords.columns.fitness')}
|
||||
value={t(`seaRecords.columns.fitnessOptions.${record.fitnessStatus}`, {
|
||||
defaultValue: record.fitnessStatus,
|
||||
})}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider my="sm" />
|
||||
<RecordFiles
|
||||
ownerType="MEDICAL_CERTIFICATE"
|
||||
ownerId={record.id}
|
||||
onPreview={setPreview}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Sea service records ─────────────────────────────────────── */}
|
||||
<Tabs.Panel value="sea-service">
|
||||
{loadingSeaService ? (
|
||||
<Loader size="sm" type="oval" />
|
||||
) : (seaService ?? []).length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('documents.empty.seaService')}
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{(seaService ?? []).map((record) => (
|
||||
<Card key={record.id} withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size="lg" variant="light" color="blue" radius="md">
|
||||
<IconAnchor size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>
|
||||
{record.vesselName}
|
||||
</Text>
|
||||
{record.imoNumber && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('seaRecords.columns.imo', { number: record.imoNumber })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={RECORD_STATUS_COLOR[record.status]}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t(`seaRecords.columns.recordStatus.${record.status}`, {
|
||||
defaultValue: record.status,
|
||||
})}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Stack gap={4}>
|
||||
<FieldRow label={t('seaRecords.columns.rank')} value={record.rank} />
|
||||
<FieldRow
|
||||
label={t('seaRecords.columns.from')}
|
||||
value={showDate(record.engagementDate)}
|
||||
/>
|
||||
<FieldRow
|
||||
label={t('seaRecords.columns.to')}
|
||||
value={showDate(record.dischargeDate)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider my="sm" />
|
||||
<RecordFiles
|
||||
ownerType="SEA_SERVICE_RECORD"
|
||||
ownerId={record.id}
|
||||
onPreview={setPreview}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── The applicant's own document vault ──────────────────────── */}
|
||||
<Tabs.Panel value="personal">
|
||||
{/* Owned by the profile, not by a registration or an application:
|
||||
the slots are configured in the backoffice and the files travel
|
||||
with the person. */}
|
||||
<PersonalDocumentSlots onPreview={setPreview} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
<FilePreviewModal
|
||||
opened={Boolean(preview)}
|
||||
onClose={() => setPreview(null)}
|
||||
url={preview?.url ?? ''}
|
||||
title={preview?.title}
|
||||
mimeType={preview?.mimeType}
|
||||
labels={{
|
||||
unsupported: t('documents.preview.unsupported'),
|
||||
openInNewTab: t('documents.preview.openInNewTab'),
|
||||
close: t('documents.preview.close'),
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,12 @@ interface Props {
|
||||
flagged?: Record<string, string>;
|
||||
/** When set, only flagged slots accept a new upload. */
|
||||
restrictToFlagged?: boolean;
|
||||
/**
|
||||
* Requirement keys opened because a flagged section drives their condition —
|
||||
* a category correction can make documents newly required, and those have to
|
||||
* be uploadable even though the officer flagged no document.
|
||||
*/
|
||||
alsoUnlocked?: string[];
|
||||
onUploaded: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
@@ -55,6 +61,7 @@ export function DocumentSlots({
|
||||
ownerId,
|
||||
flagged = {},
|
||||
restrictToFlagged = false,
|
||||
alsoUnlocked = [],
|
||||
onUploaded,
|
||||
readOnly,
|
||||
}: Props) {
|
||||
@@ -106,7 +113,11 @@ export function DocumentSlots({
|
||||
const uploaded = Boolean(existing?.files?.length);
|
||||
const fileUrl = existing?.files?.[0]?.url;
|
||||
const flagRemark = flagged[requirement.key];
|
||||
const locked = readOnly || (restrictToFlagged && !flagRemark);
|
||||
const locked =
|
||||
readOnly ||
|
||||
(restrictToFlagged &&
|
||||
!flagRemark &&
|
||||
!alsoUnlocked.includes(requirement.key));
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload, IconRefresh } from '@tabler/icons-react';
|
||||
import { IconAlertTriangle, IconDownload, IconRefresh } from '@tabler/icons-react';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
@@ -59,6 +59,36 @@ export function useRenewLicense() {
|
||||
return { renewLicense, isRenewing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Damaged/Reissue reuses the same wizard, application kind REISSUE — the
|
||||
* "Damage Information" step and the Reissue document set only appear because
|
||||
* the created application carries that kind, exactly the way RENEWAL's own
|
||||
* fields do above.
|
||||
*/
|
||||
export function useReissueLicense() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [createApplication, { isLoading: isReissuing }] =
|
||||
useCreateApplicationMutation();
|
||||
|
||||
async function reissueLicense(license: IssuedLicense) {
|
||||
const typeKey = license.licenseType?.key;
|
||||
if (!typeKey) return;
|
||||
try {
|
||||
const application = await createApplication({
|
||||
licenseType: typeKey,
|
||||
kind: 'REISSUE',
|
||||
previousLicenseId: license.id,
|
||||
}).unwrap();
|
||||
navigate(`/licensing/${typeKey}/applications/${application.id}`);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), t('licensing.card.reissueFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
return { reissueLicense, isReissuing };
|
||||
}
|
||||
|
||||
function daysUntil(date: string): number {
|
||||
const ms = new Date(date).getTime() - Date.now();
|
||||
return Math.ceil(ms / 86_400_000);
|
||||
@@ -68,20 +98,30 @@ export function LicenseCard({
|
||||
license,
|
||||
isDownloading,
|
||||
isRenewing,
|
||||
isReissuing,
|
||||
onDownload,
|
||||
onRenew,
|
||||
onReissue,
|
||||
}: {
|
||||
license: IssuedLicense;
|
||||
isDownloading: boolean;
|
||||
isRenewing: boolean;
|
||||
isReissuing?: boolean;
|
||||
onDownload: () => void;
|
||||
onRenew: () => void;
|
||||
onReissue?: () => void;
|
||||
}) {
|
||||
// The API computes both in the authority's timezone; the local fallbacks are
|
||||
// only for a cached response from before those fields existed.
|
||||
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
|
||||
const expired = license.status === 'EXPIRED' || days < 0;
|
||||
// Suspended, cancelled and superseded are none of them "valid until" their
|
||||
// expiry date — the card used to say exactly that, because only EXPIRED was
|
||||
// treated as not-current. A suspended licence read as a live one with a grey
|
||||
// badge.
|
||||
const current = license.status === 'ACTIVE' && !expired;
|
||||
const renewable = license.renewable ?? false;
|
||||
const reissuable = license.reissuable ?? false;
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const { t } = useTranslation();
|
||||
@@ -100,9 +140,15 @@ export function LicenseCard({
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
|
||||
color={expired ? 'red' : current ? 'teal' : 'gray'}
|
||||
>
|
||||
{expired ? t('licensing.card.expired') : license.status}
|
||||
{/* The raw enum was rendered here, so an Amharic page showed
|
||||
"SUSPENDED" among otherwise translated text. */}
|
||||
{expired
|
||||
? t('licensing.card.expired')
|
||||
: t(`licensing.card.status.${license.status}`, {
|
||||
defaultValue: license.status,
|
||||
})}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
@@ -113,8 +159,19 @@ export function LicenseCard({
|
||||
<Text size="sm" fw={500}>
|
||||
{expired
|
||||
? t('licensing.card.expiredOn', { date: showDate(license.expiryDate) })
|
||||
: t('licensing.card.validUntil', { date: showDate(license.expiryDate) })}
|
||||
: current
|
||||
? t('licensing.card.validUntil', { date: showDate(license.expiryDate) })
|
||||
: t(`licensing.card.status.${license.status}`, {
|
||||
defaultValue: license.status,
|
||||
})}
|
||||
</Text>
|
||||
{/* Why it stopped being current. The API returns it; the card threw
|
||||
it away, leaving the holder to guess. */}
|
||||
{!current && license.statusReason && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{t('licensing.card.statusReason', { reason: license.statusReason })}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<RequirePermission
|
||||
anyOf={[
|
||||
@@ -157,6 +214,25 @@ export function LicenseCard({
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{/* Damaged/Reissue has no window — a lost or damaged document can be
|
||||
replaced at any point in its validity, unlike Renewal above. */}
|
||||
{reissuable && onReissue && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
|
||||
<Button
|
||||
fullWidth
|
||||
mt="xs"
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
loading={isReissuing}
|
||||
leftSection={<IconAlertTriangle size={14} />}
|
||||
onClick={onReissue}
|
||||
>
|
||||
{t('licensing.card.reportDamaged')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
IconBuildingWarehouse,
|
||||
IconChevronRight,
|
||||
IconFileText,
|
||||
IconLock,
|
||||
IconShieldOff,
|
||||
IconShip,
|
||||
IconTrendingUp,
|
||||
@@ -45,9 +47,9 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
|
||||
CARGO_FREIGHT: IconBuildingWarehouse,
|
||||
SHIPPING_AGENCY: IconShip,
|
||||
INVESTMENT: IconTrendingUp,
|
||||
// The three below are filtered out of this catalogue today
|
||||
// (requiresOperatorMode is false for all of them), and are listed only so
|
||||
// the record stays total if that ever changes.
|
||||
// The three below appear only when the applicant has declared a licence
|
||||
// type in them (see the family filter below); listed here so the record
|
||||
// stays total either way.
|
||||
MARITIME_PERSONNEL: IconShip,
|
||||
VESSEL_SERVICES: IconAnchor,
|
||||
WAIVER_SERVICES: IconShieldOff,
|
||||
@@ -81,15 +83,19 @@ export function LicenseCatalogue() {
|
||||
const { groups, orphans } = useMemo(() => {
|
||||
const active = (types?.items ?? [])
|
||||
.filter((t) => t.isActive)
|
||||
// Logistics licences only: this is the operator catalogue, not the
|
||||
// seafarer certificate or vessel/seafarer document catalogue — those
|
||||
// have their own entry points. `familyKind` is the real data-model
|
||||
// classification (set on the type at seed time); `requiresOperatorMode`
|
||||
// was the proxy this used before that column existed and happened to
|
||||
// agree for every type seeded so far, but a type can only be trusted to
|
||||
// stay in sync with the catalogue it belongs in if the catalogue reads
|
||||
// its actual family instead of a flag with a different purpose.
|
||||
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE')
|
||||
// The logistics family, plus whatever this applicant actually declared.
|
||||
//
|
||||
// `familyKind` is the real data-model classification and is what keeps
|
||||
// browse-all to the operator catalogue rather than every certificate and
|
||||
// document type in the system. But it is not what decides eligibility:
|
||||
// the Operations tab also offers the personal registrations (seafarer,
|
||||
// vessel) and the seafarer endorsement, which are DOCUMENT/CERTIFICATE
|
||||
// family, so an applicant who declared one of those was shown an empty
|
||||
// catalogue — allowed to file, and offered nothing to file. Each of
|
||||
// those keys already has an entry point at `/licensing/<key>/apply`
|
||||
// (a router redirect for SEAFARER_REGISTRATION and SEAMAN_BOOK, the
|
||||
// generic wizard for the rest), so the card leads somewhere real.
|
||||
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE' || declared.has(t.id))
|
||||
// Only what the applicant operates as. The server enforces the same rule
|
||||
// on create; this is what stops them starting an application they will
|
||||
// be refused at the end of.
|
||||
@@ -273,8 +279,8 @@ function LicenseTypeCard({
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: 'pointer', height: '100%' }}
|
||||
onClick={() => onSelect(type)}
|
||||
style={{ cursor: canApply ? 'pointer' : 'default', height: '100%' }}
|
||||
onClick={canApply ? () => onSelect(type) : undefined}
|
||||
>
|
||||
<Stack gap="xs" justify="space-between" h="100%">
|
||||
<Box>
|
||||
@@ -282,11 +288,19 @@ function LicenseTypeCard({
|
||||
<Text fw={600} size="sm" lh={1.35}>
|
||||
{localized(type.name)}
|
||||
</Text>
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
{canApply ? (
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
) : (
|
||||
<IconLock
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{type.description && (
|
||||
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
|
||||
@@ -325,13 +339,45 @@ function LicenseTypeCard({
|
||||
mt="sm"
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={canApply ? undefined : 'gray'}
|
||||
disabled={!canApply}
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
>
|
||||
{canApply
|
||||
? t('licensing.catalogue.startApplication')
|
||||
: t('licensing.catalogue.addToOperations')}
|
||||
{t('licensing.catalogue.startApplication')}
|
||||
</Button>
|
||||
{/* A disabled button on its own only says "no". This says why, and
|
||||
where to go about it — the licence is offered against a declared
|
||||
mode of operation, and the server refuses a create for one the
|
||||
applicant has not declared. Called out rather than set in dimmed
|
||||
small print: it is the only thing on a locked card the applicant
|
||||
can act on. */}
|
||||
{!canApply && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
mt="sm"
|
||||
p="xs"
|
||||
>
|
||||
<Text size="xs" lh={1.4}>
|
||||
{t('licensing.catalogue.lockedHint')}
|
||||
</Text>
|
||||
<Anchor
|
||||
size="xs"
|
||||
fw={600}
|
||||
component="button"
|
||||
type="button"
|
||||
mt={4}
|
||||
onClick={(event) => {
|
||||
// The card is inert while locked, but the anchor inside it
|
||||
// must not re-trigger anything if that ever changes.
|
||||
event.stopPropagation();
|
||||
onSelect(type);
|
||||
}}
|
||||
>
|
||||
{t('licensing.catalogue.addToOperations')}
|
||||
</Anchor>
|
||||
</Alert>
|
||||
)}
|
||||
</RequirePermission>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Group, Paper, Stack, Text } from '@mantine/core';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { IssuancePeriod } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Read-only view of the pickup appointment a team leader assigned (spec
|
||||
* §19-21 — the office decides who comes in when, the applicant doesn't pick
|
||||
* a slot). Shown once payment is confirmed and the licence type prints once
|
||||
* and hands the document over in person.
|
||||
*/
|
||||
export function PickupSchedulingPanel({
|
||||
scheduledDate,
|
||||
scheduledPeriod,
|
||||
}: {
|
||||
scheduledDate: string | null;
|
||||
scheduledPeriod: IssuancePeriod | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconCalendarEvent size={16} />
|
||||
<Text fw={600} size="sm">
|
||||
{t('pickup.title')}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{scheduledDate ? (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
{t('pickup.scheduledFor', {
|
||||
date: scheduledDate,
|
||||
period:
|
||||
scheduledPeriod === 'AFTERNOON'
|
||||
? t('pickup.afternoon')
|
||||
: t('pickup.morning'),
|
||||
})}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('pickup.setByOffice')}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('pickup.awaitingSchedule')}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default PickupSchedulingPanel;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
@@ -32,6 +33,8 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
buildWizardSteps,
|
||||
conditionHolds,
|
||||
conditionSections,
|
||||
sectionsDependingOn,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
useLocalized,
|
||||
@@ -65,6 +68,7 @@ import {
|
||||
PORTAL_PERMISSIONS,
|
||||
RequirePermission,
|
||||
useCurrentProfile,
|
||||
usePermissions,
|
||||
} from "@ema-platform/auth";
|
||||
import { ApplicationSummary } from "../components/ApplicationSummary";
|
||||
import {
|
||||
@@ -72,6 +76,7 @@ import {
|
||||
fillFromVessel,
|
||||
} from "../components/ConfigDrivenSection";
|
||||
import { DocumentSlots } from "../components/DocumentSlots";
|
||||
import { PickupSchedulingPanel } from "../components/PickupSchedulingPanel";
|
||||
import { StaffEvidence } from "../components/StaffEvidence";
|
||||
import { useAppSelector } from "../../../store/hooks";
|
||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
@@ -115,9 +120,14 @@ export function LicenseApplicationPage() {
|
||||
const { data: config, isLoading: loadingConfig } =
|
||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||
const { profile } = useCurrentProfile();
|
||||
const { can: hasPermission, known: permissionsKnown } = usePermissions();
|
||||
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
|
||||
// here rather than deeper down since it's the shared source of draft state.
|
||||
const { data: vessels } = useGetMyVesselsQuery();
|
||||
// Skipped for accounts without VIEW_OWN_VESSELS (e.g. freight forwarders):
|
||||
// the API 403s for them, since vessels belong to VESSEL_OWNER accounts.
|
||||
const { data: vessels } = useGetMyVesselsQuery(undefined, {
|
||||
skip: !permissionsKnown || !hasPermission([PORTAL_PERMISSIONS.VIEW_OWN_VESSELS]),
|
||||
});
|
||||
const [createApplication] = useCreateApplicationMutation();
|
||||
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
||||
|
||||
@@ -352,24 +362,60 @@ export function LicenseApplicationPage() {
|
||||
),
|
||||
[roundRemarks],
|
||||
);
|
||||
const hasSectionRemarks = Object.keys(flaggedSections).length > 0;
|
||||
const hasDocRemarks = Object.keys(flaggedDocuments).length > 0;
|
||||
const hasStaffRemarks = roundRemarks.some((r) => r.targetType === "STAFF");
|
||||
// Nothing at all came back for this round (a detail response that predates
|
||||
// the remarks, say) — lock nothing rather than freeze the whole application
|
||||
// with no way forward. Any remark present means the round is itemised, so
|
||||
// only what the officer flagged opens: a documents-only round leaves every
|
||||
// form section frozen, and a sections-only round leaves every document as
|
||||
// filed.
|
||||
const roundIsItemised = isAdjusting && roundRemarks.length > 0;
|
||||
|
||||
// An answer the officer flagged can decide which fields *other* sections
|
||||
// require — the vessel category is the live example. Freeze those and the
|
||||
// applicant is shown newly-required fields they cannot fill, and cannot
|
||||
// resubmit; the server unlocks them the same way.
|
||||
const cascadeUnlocked = useMemo(
|
||||
() =>
|
||||
sectionsDependingOn(
|
||||
config?.licenseType?.formSchema?.sections ?? [],
|
||||
new Set(Object.keys(flaggedSections)),
|
||||
),
|
||||
[config, flaggedSections],
|
||||
);
|
||||
const unlockedDocuments = useMemo(
|
||||
() =>
|
||||
(config?.documentRequirements ?? [])
|
||||
.filter((requirement) =>
|
||||
conditionSections(requirement.conditionExpression).some(
|
||||
(sectionKey) => sectionKey in flaggedSections,
|
||||
),
|
||||
)
|
||||
.map((requirement) => requirement.key),
|
||||
[config, flaggedSections],
|
||||
);
|
||||
|
||||
// A round that flagged no form sections carries no section locks — mirror of
|
||||
// the server's fallback, without which a documents-only correction round
|
||||
// froze every field and the applicant could not edit anything at all.
|
||||
const isSectionLocked = (sectionKey: string) =>
|
||||
isAdjusting && hasSectionRemarks && !flaggedSections[sectionKey];
|
||||
roundIsItemised &&
|
||||
!flaggedSections[sectionKey] &&
|
||||
!cascadeUnlocked.has(sectionKey);
|
||||
const staffLocked = roundIsItemised && !hasStaffRemarks;
|
||||
|
||||
// Sections that share a group collapse onto one step, so the stepper stays
|
||||
// short instead of showing a page per section.
|
||||
// short instead of showing a page per section. A Damaged/Reissue
|
||||
// application skips Staff and Documents outright — it asks nothing beyond
|
||||
// the Damage Information step, regardless of what the licence type
|
||||
// otherwise requires for a new application or renewal.
|
||||
const isReissue = application?.kind === 'REISSUE';
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
|
||||
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
|
||||
hasStaff: isReissue ? false : (config?.staffRoleRequirements?.length ?? 0) > 0,
|
||||
hasDocuments: !isReissue,
|
||||
language: i18n.language,
|
||||
applicationKind: application?.kind,
|
||||
}),
|
||||
[config, draft, i18n.language],
|
||||
[config, draft, i18n.language, application?.kind, isReissue],
|
||||
);
|
||||
const sections = useMemo(
|
||||
() => steps.flatMap((step) => step.sections),
|
||||
@@ -688,6 +734,11 @@ export function LicenseApplicationPage() {
|
||||
>
|
||||
{STATUS_LABELS[application.status]}
|
||||
</Badge>
|
||||
{detail?.issuedLicenseStatus === "SUPERSEDED" && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("licensing.certificateSuperseded", "Certificate superseded")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<Group gap="md" align="center">
|
||||
@@ -727,6 +778,17 @@ export function LicenseApplicationPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{config.licenseType.requiresIssuanceScheduling &&
|
||||
(application.status === "PAYMENT_CONFIRMED" ||
|
||||
application.status === "SCHEDULED") && (
|
||||
<Box mb="md">
|
||||
<PickupSchedulingPanel
|
||||
scheduledDate={application.scheduledIssuanceDate}
|
||||
scheduledPeriod={application.scheduledIssuancePeriod}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showSummary && editableWhileSubmitted && (
|
||||
<Alert
|
||||
color="blue"
|
||||
@@ -862,7 +924,7 @@ export function LicenseApplicationPage() {
|
||||
complete
|
||||
</Badge>
|
||||
)}
|
||||
{!readOnly && (
|
||||
{!readOnly && !staffLocked && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
@@ -898,7 +960,7 @@ export function LicenseApplicationPage() {
|
||||
: ""}
|
||||
</Text>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
{!readOnly && !staffLocked && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
@@ -917,7 +979,7 @@ export function LicenseApplicationPage() {
|
||||
<StaffEvidence
|
||||
staffId={member.id}
|
||||
evidence={role.requiredEvidence}
|
||||
readOnly={readOnly}
|
||||
readOnly={readOnly || staffLocked}
|
||||
onUploaded={refetch}
|
||||
/>
|
||||
</Card>
|
||||
@@ -937,7 +999,8 @@ export function LicenseApplicationPage() {
|
||||
ownerType="APPLICATION"
|
||||
ownerId={appId}
|
||||
flagged={flaggedDocuments}
|
||||
restrictToFlagged={isAdjusting && hasDocRemarks}
|
||||
restrictToFlagged={roundIsItemised}
|
||||
alsoUnlocked={unlockedDocuments}
|
||||
readOnly={readOnly}
|
||||
onUploaded={() => {
|
||||
refetchAttachments();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { IconDownload } from '@tabler/icons-react';
|
||||
import { IconDownload, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { LicenseApplication } from '@ema-platform/api';
|
||||
@@ -33,6 +33,7 @@ export function applicationActionsColumn(
|
||||
onRetakeExam: (app: LicenseApplication) => void;
|
||||
onRegisterForExam: () => void;
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
onDiscard: (app: LicenseApplication) => void;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication> {
|
||||
return {
|
||||
@@ -128,6 +129,19 @@ export function applicationActionsColumn(
|
||||
: t('applications.actions.view')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Drafts only: after submission the filing is a record an officer
|
||||
may already be reading, so it is withdrawn, not deleted. */}
|
||||
{app.status === 'DRAFT' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => deps.onDiscard(app)}
|
||||
>
|
||||
{t('applications.actions.discard')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge, Box, Progress, Text } from '@mantine/core';
|
||||
import { Badge, Box, Group, Progress, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
@@ -6,10 +6,23 @@ import {
|
||||
STATUS_PROGRESS,
|
||||
applicantOrCompanyName,
|
||||
localized,
|
||||
type ApplicationKind,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
const KIND_LABEL: Record<ApplicationKind, string> = {
|
||||
NEW: 'applications.table.kindNew',
|
||||
RENEWAL: 'applications.table.kindRenewal',
|
||||
REISSUE: 'applications.table.kindReissue',
|
||||
};
|
||||
|
||||
const KIND_COLOR: Record<ApplicationKind, string> = {
|
||||
NEW: 'blue',
|
||||
RENEWAL: 'teal',
|
||||
REISSUE: 'orange',
|
||||
};
|
||||
|
||||
export function applicationColumns(
|
||||
t: TFunction,
|
||||
deps: {
|
||||
@@ -26,9 +39,16 @@ export function applicationColumns(
|
||||
header: t('applications.table.licence'),
|
||||
cell: ({ row }) => (
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(row.original.licenseType?.name, deps.language) || '—'}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(row.original.licenseType?.name, deps.language) || '—'}
|
||||
</Text>
|
||||
{row.original.kind !== 'NEW' && (
|
||||
<Badge size="xs" variant="light" color={KIND_COLOR[row.original.kind]}>
|
||||
{t(KIND_LABEL[row.original.kind])}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
|
||||
@@ -29,10 +29,10 @@ import {
|
||||
IconSearch,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, AmharicDatePicker, ConfirmModal, EmptyState, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { LicenseCatalogue } from '../../components/LicenseCatalogue';
|
||||
import { LicenseCard, useRenewLicense } from '../../components/LicenseCard';
|
||||
import { LicenseCard, useReissueLicense, useRenewLicense } from '../../components/LicenseCard';
|
||||
import { useApplicationPayment } from '../../../payments/hooks/useApplicationPayment';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
@@ -42,12 +42,14 @@ import {
|
||||
applicantOrCompanyName,
|
||||
extractErrorMessage,
|
||||
useBypassPaymentMutation,
|
||||
useDiscardApplicationMutation,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useApiQuery,
|
||||
useRetakeExamMutation,
|
||||
type ApplicationKind,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
@@ -114,7 +116,10 @@ export function MyApplicationsPage() {
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const [discardApplication, { isLoading: discarding }] = useDiscardApplicationMutation();
|
||||
const [discardTarget, setDiscardTarget] = useState<{ id: string; label: string } | null>(null);
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
const { reissueLicense, isReissuing } = useReissueLicense();
|
||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
||||
const { can } = usePermissions();
|
||||
|
||||
@@ -146,6 +151,26 @@ export function MyApplicationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws away an unfinished draft, after the applicant confirms. */
|
||||
async function handleDiscard() {
|
||||
if (!discardTarget) return;
|
||||
try {
|
||||
await discardApplication(discardTarget.id).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('applications.actions.discarded'),
|
||||
message: discardTarget.label,
|
||||
});
|
||||
setDiscardTarget(null);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('applications.actions.discardFailed'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-opens the examination fee for a failed candidate.
|
||||
*
|
||||
@@ -220,10 +245,13 @@ export function MyApplicationsPage() {
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<LicenseStatus | null>(null);
|
||||
const [kindFilter, setKindFilter] = useState<ApplicationKind | null>(null);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [bucketFilter, setBucketFilter] = useState<Bucket>(null);
|
||||
const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo || bucketFilter);
|
||||
const hasFilters = Boolean(
|
||||
search || statusFilter || kindFilter || dateFrom || dateTo || bucketFilter,
|
||||
);
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||
|
||||
const counts = useMemo(() => {
|
||||
@@ -241,6 +269,7 @@ export function MyApplicationsPage() {
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
if (statusFilter && app.status !== statusFilter) return false;
|
||||
if (kindFilter && app.kind !== kindFilter) return false;
|
||||
// Drafts have no submittedAt, so date filtering falls back to createdAt
|
||||
// rather than silently excluding every draft from a date-ranged search.
|
||||
const at = app.submittedAt ?? app.createdAt;
|
||||
@@ -258,13 +287,14 @@ export function MyApplicationsPage() {
|
||||
const bAt = b.submittedAt ?? b.createdAt;
|
||||
return aAt < bAt ? 1 : aAt > bAt ? -1 : 0;
|
||||
});
|
||||
}, [allItems, search, statusFilter, dateFrom, dateTo, bucketFilter]);
|
||||
}, [allItems, search, statusFilter, kindFilter, dateFrom, dateTo, bucketFilter]);
|
||||
|
||||
const page = paginate(items);
|
||||
|
||||
function clearFilters() {
|
||||
setSearch('');
|
||||
setStatusFilter(null);
|
||||
setKindFilter(null);
|
||||
setDateFrom('');
|
||||
setDateTo('');
|
||||
setBucketFilter(null);
|
||||
@@ -319,6 +349,8 @@ export function MyApplicationsPage() {
|
||||
onRegisterForExam: () => navigate('/exams'),
|
||||
onOpen: (app) =>
|
||||
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
||||
onDiscard: (app) =>
|
||||
setDiscardTarget({ id: app.id, label: app.applicationNumber }),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -421,6 +453,22 @@ export function MyApplicationsPage() {
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label={t('applications.filters.kind')}
|
||||
placeholder={t('applications.filters.any')}
|
||||
data={[
|
||||
{ value: 'NEW', label: t('applications.table.kindNew') },
|
||||
{ value: 'RENEWAL', label: t('applications.table.kindRenewal') },
|
||||
{ value: 'REISSUE', label: t('applications.table.kindReissue') },
|
||||
]}
|
||||
value={kindFilter}
|
||||
onChange={(v) => {
|
||||
setKindFilter(v as ApplicationKind | null);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
clearable
|
||||
w={160}
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label={t('applications.filters.from')}
|
||||
value={dateFrom}
|
||||
@@ -511,8 +559,10 @@ export function MyApplicationsPage() {
|
||||
license={license}
|
||||
isDownloading={isDownloadingCert}
|
||||
isRenewing={isRenewing}
|
||||
isReissuing={isReissuing}
|
||||
onDownload={() => downloadCertificate(license.id)}
|
||||
onRenew={() => renewLicense(license)}
|
||||
onReissue={() => reissueLicense(license)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -522,6 +572,19 @@ export function MyApplicationsPage() {
|
||||
|
||||
{tab === 'apply' && <LicenseCatalogue />}
|
||||
</Stack>
|
||||
|
||||
<ConfirmModal
|
||||
opened={!!discardTarget}
|
||||
onClose={() => setDiscardTarget(null)}
|
||||
onConfirm={handleDiscard}
|
||||
loading={discarding}
|
||||
title={t('applications.actions.discard')}
|
||||
message={t('applications.actions.discardConfirm', {
|
||||
number: discardTarget?.label ?? '',
|
||||
})}
|
||||
confirmLabel={t('applications.actions.discard')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
46
apps/portal/src/app/features/profile/api/signature-api.ts
Normal file
46
apps/portal/src/app/features/profile/api/signature-api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* The caller's specimen signature.
|
||||
*
|
||||
* Upload is multipart rather than the presign+PUT flow used for documents:
|
||||
* the API validates type and size on the way through, which it cannot do when
|
||||
* bytes go straight to storage. `signatureUrl` on the profile is an
|
||||
* object-storage key, so the stored signature is displayed through a
|
||||
* short-lived link from `GET me/signature` rather than read off the profile.
|
||||
*/
|
||||
const signatureApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: ['CurrentProfile', 'MySignature'] as const })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getMySignature: builder.query<{ url: string | null }, void>({
|
||||
query: () => ({ url: '/profiles/me/signature' }),
|
||||
providesTags: ['MySignature'],
|
||||
}),
|
||||
|
||||
uploadMySignature: builder.mutation<{ signatureUrl: string }, File>({
|
||||
query: (file) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
// No Content-Type header: fetch sets it with the multipart boundary,
|
||||
// and naming it here would omit the boundary and fail to parse.
|
||||
return { url: '/profiles/me/signature', method: 'POST', body };
|
||||
},
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : ['MySignature', 'CurrentProfile'],
|
||||
}),
|
||||
|
||||
deleteMySignature: builder.mutation<{ signatureUrl: null }, void>({
|
||||
query: () => ({ url: '/profiles/me/signature', method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : ['MySignature', 'CurrentProfile'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetMySignatureQuery,
|
||||
useUploadMySignatureMutation,
|
||||
useDeleteMySignatureMutation,
|
||||
} = signatureApi;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SignaturePad } from '@ema-platform/ui';
|
||||
import {
|
||||
useDeleteMySignatureMutation,
|
||||
useGetMySignatureQuery,
|
||||
useUploadMySignatureMutation,
|
||||
} from '../api/signature-api';
|
||||
|
||||
/**
|
||||
* The seafarer's own specimen signature, printed on documents issued to them
|
||||
* (`{{seafarerSignature}}`). Distinct from an officer's signing signature,
|
||||
* which the backoffice manages against a different endpoint.
|
||||
*/
|
||||
export function MySignaturePad() {
|
||||
const { data, isLoading } = useGetMySignatureQuery();
|
||||
const [upload, { isLoading: isUploading }] = useUploadMySignatureMutation();
|
||||
const [remove, { isLoading: isDeleting }] = useDeleteMySignatureMutation();
|
||||
|
||||
return (
|
||||
<SignaturePad
|
||||
currentUrl={data?.url ?? null}
|
||||
isLoading={isLoading}
|
||||
isUploading={isUploading}
|
||||
isDeleting={isDeleting}
|
||||
onUpload={(file) => upload(file).unwrap()}
|
||||
onDelete={() => remove().unwrap()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
IconMapPin,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSignature,
|
||||
IconShieldLock,
|
||||
IconSun,
|
||||
IconUser,
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
import { useSaveMyAddressMutation } from '../api/address-api';
|
||||
import { toAddressPayload } from '../types/address';
|
||||
import { OperationsFormContent } from '../components/OperationsFormContent';
|
||||
import { MySignaturePad } from '../components/SignaturePad';
|
||||
import { SEAFARER_PROFILE_REQUIREMENT } from '../components/RequireSeafarerProfile';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
@@ -76,6 +78,7 @@ const VALID_TABS = [
|
||||
'profile',
|
||||
'address',
|
||||
'operations',
|
||||
'signature',
|
||||
'security',
|
||||
'preferences',
|
||||
];
|
||||
@@ -633,6 +636,9 @@ export function ProfilePage() {
|
||||
>
|
||||
{t('profile.tabs.operations')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="signature" leftSection={<IconSignature size={18} />}>
|
||||
{t('profile.tabs.signature')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
||||
{t('profile.tabs.security')}
|
||||
</Tabs.Tab>
|
||||
@@ -808,6 +814,13 @@ export function ProfilePage() {
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Signature (printed on issued documents) ---- */}
|
||||
<Tabs.Panel value="signature" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<MySignaturePad />
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Security ---- */}
|
||||
<Tabs.Panel value="security" pt="md">
|
||||
<Stack gap="lg">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GENDER_OPTIONS,
|
||||
HAIR_COLOR_OPTIONS,
|
||||
MARITAL_STATUS_OPTIONS,
|
||||
RANK_TIER_OPTIONS,
|
||||
isEthiopianNationality,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useLocalized,
|
||||
@@ -130,6 +131,18 @@ export function ApplicantDetailsStep(p: StepProps) {
|
||||
options={departmentOptions}
|
||||
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
|
||||
/>
|
||||
<SelectField
|
||||
{...p}
|
||||
name="tier"
|
||||
label="Certificate Limitation"
|
||||
required
|
||||
options={RANK_TIER_OPTIONS}
|
||||
description={
|
||||
p.form.department === 'ENGINE'
|
||||
? 'Above covers ships of 3000 kW propulsion power or more; Below covers 750–3000 kW. Every Certificate of Competency you apply for is issued under this limit.'
|
||||
: 'Above covers ships of 3000 gross tonnage or more; Below covers 500–3000 GT. Every Certificate of Competency you apply for is issued under this limit.'
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -59,7 +59,7 @@ const STEPS = [
|
||||
*/
|
||||
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'],
|
||||
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||
['placeOfBirth', 'department', 'tier', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
||||
[],
|
||||
['declarationAccepted'],
|
||||
|
||||
145
apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx
Normal file
145
apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
CopyButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconFingerprint,
|
||||
IconIdBadge2,
|
||||
IconInfoCircle,
|
||||
IconScan,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { useGetMyBiometricEnrollmentsQuery } from '@ema-platform/api';
|
||||
|
||||
const MODALITY_LABEL: Record<string, string> = {
|
||||
FINGERPRINT: 'Fingerprint',
|
||||
FACE: 'Face',
|
||||
};
|
||||
|
||||
/**
|
||||
* View-only: the seafarer's BSID and what is enrolled against it. Capture
|
||||
* stays counter-side with a scanner — there is no self-enrollment flow here.
|
||||
*/
|
||||
export function BiometricsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
|
||||
// The BSID lives on the profile, stamped by staff once a capture is
|
||||
// confirmed — it is not a property of any one enrollment, so it is read
|
||||
// from the profile rather than from the rows below.
|
||||
const { profile, isLoading: profileLoading } = useCurrentProfile();
|
||||
const bsid = profile?.bsid ?? null;
|
||||
|
||||
const rows = enrollments ?? [];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="xs">
|
||||
<IconFingerprint size={22} />
|
||||
<Title order={2}>{t('biometrics.title', 'Biometrics')}</Title>
|
||||
</Group>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
{t(
|
||||
'biometrics.pageIntro',
|
||||
'Fingerprint and face enrollment happen in person at an EMA counter. This page shows what is on file for you.',
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb={rows.length || isLoading ? 'lg' : 0} align="flex-start">
|
||||
<ThemeIcon size={40} radius="md" color="indigo" variant="light">
|
||||
<IconIdBadge2 size={20} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{t('biometrics.bsidLabel', 'Biometric Subject ID')}
|
||||
</Text>
|
||||
{profileLoading ? (
|
||||
<Loader size="xs" mt={6} />
|
||||
) : bsid ? (
|
||||
<CopyButton value={bsid} timeout={1500}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? t('biometrics.copied', 'Copied') : t('biometrics.copy', 'Copy')}
|
||||
withArrow
|
||||
>
|
||||
<UnstyledButton onClick={copy}>
|
||||
<Group gap={6} align="center">
|
||||
<Text ff="monospace" fw={700} fz="lg">
|
||||
{bsid}
|
||||
</Text>
|
||||
{copied ? <IconCheck size={15} /> : <IconCopy size={15} />}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed" mt={2}>
|
||||
{t(
|
||||
'biometrics.bsidPending',
|
||||
'Not issued yet. Your BSID is generated once your enrolment is confirmed at the counter.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : rows.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
{t('biometrics.empty', 'No biometric enrollment on file yet.')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{rows.map((e) => (
|
||||
<Card key={e.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light">
|
||||
<IconScan size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">
|
||||
{MODALITY_LABEL[e.modality] ?? e.modality}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Enrolled {new Date(e.enrolledAt).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color="teal" variant="light">
|
||||
{e.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -22,11 +22,15 @@ import {
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconPrinter,
|
||||
IconRefresh,
|
||||
IconReplace,
|
||||
IconShield,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
@@ -34,6 +38,8 @@ import {
|
||||
useGetMySeafarerDocumentsQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useLazyGetMySeafarerDocumentDownloadQuery,
|
||||
useRenewSeafarerDocumentMutation,
|
||||
useReplaceSeafarerDocumentMutation,
|
||||
type SeafarerDocument,
|
||||
type SeafarerDocumentStatus,
|
||||
} from "@ema-platform/api";
|
||||
@@ -73,9 +79,20 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const [bypass, { isLoading: bypassing }] = useBypassDocumentPaymentMutation();
|
||||
const [getDownload, { isFetching: downloading }] = useLazyGetMySeafarerDocumentDownloadQuery();
|
||||
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
|
||||
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
|
||||
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
||||
const activeStep = stageIndexFor(document.status);
|
||||
|
||||
async function renewOrReplace(action: () => ReturnType<typeof renew>) {
|
||||
try {
|
||||
await action().unwrap();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
notifications.show({ color: "red", title: "Request failed", message: extractErrorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
try {
|
||||
const { url } = await getDownload(document.id).unwrap();
|
||||
@@ -102,9 +119,16 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
|
||||
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
||||
</Badge>
|
||||
<Group gap="xs">
|
||||
{document.requestKind !== "NEW" && (
|
||||
<Badge color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[document.requestKind]} variant="outline" size="lg">
|
||||
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
|
||||
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
|
||||
@@ -165,9 +189,29 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
||||
{document.issueDate && ` on ${formatDate(document.issueDate)}`}
|
||||
{document.expiryDate && `, valid until ${formatDate(document.expiryDate)}`}.
|
||||
</span>
|
||||
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
||||
Download PDF
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
||||
Download PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
loading={renewing}
|
||||
onClick={() => renewOrReplace(() => renew(document.id))}
|
||||
>
|
||||
Renew
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconReplace size={14} />}
|
||||
loading={replacing}
|
||||
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
|
||||
>
|
||||
Report Lost/Damaged
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -59,6 +59,7 @@ export const am: Translations = {
|
||||
seaRecords: 'የባህር መዝገቦቼ',
|
||||
seaService: 'የባህር አገልግሎት',
|
||||
medical: 'የሕክምና የምስክር ወረቀት',
|
||||
biometrics: 'ባዮሜትሪክ',
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
||||
@@ -192,6 +193,7 @@ export const am: Translations = {
|
||||
search: 'ፈልግ',
|
||||
searchPlaceholder: 'ቁጥር ወይም አመልካች',
|
||||
status: 'ሁኔታ',
|
||||
kind: 'ዓይነት',
|
||||
any: 'ማንኛውም',
|
||||
from: 'ከ',
|
||||
to: 'እስከ',
|
||||
@@ -215,6 +217,9 @@ export const am: Translations = {
|
||||
applicant: 'አመልካች',
|
||||
progress: 'ደረጃ',
|
||||
applicationNumber: 'የማመልከቻ ቁጥር',
|
||||
kindNew: 'አዲስ',
|
||||
kindRenewal: 'እድሳት',
|
||||
kindReissue: 'ምትክ',
|
||||
},
|
||||
actions: {
|
||||
continue: 'ቀጥል',
|
||||
@@ -224,6 +229,10 @@ export const am: Translations = {
|
||||
view: 'ይመልከቱ',
|
||||
bypass: 'ክፍያ ዝለል',
|
||||
renew: 'አድስ',
|
||||
discard: 'አጥፋ',
|
||||
discardConfirm: 'ረቂቅ {{number}} ይጥፋ? እስካሁን የተሞላው ሁሉ ይጠፋል።',
|
||||
discarded: 'ረቂቁ ጠፍቷል',
|
||||
discardFailed: 'ረቂቁን ማጥፋት አልተቻለም',
|
||||
},
|
||||
notice: {
|
||||
paymentReceived:
|
||||
@@ -342,9 +351,31 @@ export const am: Translations = {
|
||||
profile: 'መገለጫ',
|
||||
address: 'አድራሻ',
|
||||
operations: 'የስራ ዘርፍ',
|
||||
signature: 'ፊርማ',
|
||||
security: 'ደህንነት',
|
||||
preferences: 'ምርጫዎች',
|
||||
},
|
||||
signature: {
|
||||
title: 'የፊርማ ናሙና',
|
||||
description: 'አንድ ጊዜ ይሳሉ ወይም ይጫኑ፤ በሚሰጡዎት ሰነዶች ላይ ይታተማል።',
|
||||
reissueNotice:
|
||||
'ፊርማዎን መቀየር አስቀድሞ የተሰጠን ሰነድ አይለውጥም — ከአሁን በኋላ በሚሰጡ ሰነዶች ላይ ብቻ ይሠራል።',
|
||||
current: 'የተመዘገበ ፊርማ',
|
||||
currentAlt: 'የተቀመጠ ፊርማዎ',
|
||||
none: 'እስካሁን የተመዘገበ ፊርማ የለም።',
|
||||
modeDraw: 'ይሳሉ',
|
||||
modeUpload: 'ይጫኑ',
|
||||
save: 'ፊርማ አስቀምጥ',
|
||||
clear: 'አጽዳ',
|
||||
choose: 'ምስል ይምረጡ',
|
||||
fileHint: 'PNG ወይም JPEG፣ እስከ 2 ሜባ።',
|
||||
remove: 'አስወግድ',
|
||||
saved: 'ፊርማ ተቀምጧል።',
|
||||
removed: 'ፊርማ ተወግዷል።',
|
||||
badType: 'PNG እና JPEG ምስሎች ብቻ ይፈቀዳሉ።',
|
||||
tooLarge: 'ምስሉ ከ2 ሜባ ይበልጣል።',
|
||||
drawFailed: 'ሥዕሉን ማንበብ አልተቻለም። እባክዎ እንደገና ይሞክሩ።',
|
||||
},
|
||||
maritimeSection: {
|
||||
title: 'የባህር ሙያ መገለጫ',
|
||||
subtitle: 'የባህር ሙያ ዝርዝሮችዎ',
|
||||
@@ -612,6 +643,28 @@ export const am: Translations = {
|
||||
createOne: "አንድ ይፍጠሩ",
|
||||
},
|
||||
|
||||
fayda: {
|
||||
continueWith: "በፋይዳ ይቀጥሉ",
|
||||
orFillManually: "ወይም መረጃዎን ራስዎ ይሙሉ",
|
||||
verifiedTitle: "በፋይዳ ተረጋግጧል",
|
||||
verifiedBody: "ፋይዳ ያረጋገጣቸውን መረጃዎች ሞልተናል። እባክዎ የቀሩትን መስኮች ያሟሉ።",
|
||||
discard: "እነዚህን መረጃዎች አጥፍቼ ቅጹን ራሴ እሞላለሁ",
|
||||
fieldVerified: "ከፋይዳ",
|
||||
fieldConflict: "በሌላ መለያ ተይዟል",
|
||||
conflictBody:
|
||||
"አንዳንድ የተረጋገጡ መረጃዎች አስቀድሞ የሌላ መለያ ናቸው። የተመለከቱትን መስኮች ይቀይሩ ወይም ይግቡ።",
|
||||
brandTitle: "በፋይዳ በማረጋገጥ ላይ",
|
||||
brandSubtitle: "ማንነትዎን እስክናረጋግጥ ድረስ አንድ አፍታ።",
|
||||
verifying: "የፋይዳ ማንነትዎን በማረጋገጥ ላይ…",
|
||||
failedTitle: "ማረጋገጡ አልተጠናቀቀም",
|
||||
backToSignup: "ወደ ምዝገባ ተመለስ",
|
||||
cancelled: "የፋይዳ ማረጋገጫው ተሰርዟል። አሁንም በእጅ መመዝገብ ይችላሉ።",
|
||||
rejected: "ፋይዳ ማንነትዎን ማረጋገጥ አልቻለም። እባክዎ እንደገና ይሞክሩ።",
|
||||
invalidCallback: "ይህ የማረጋገጫ ሊንክ አልተሟላም። እባክዎ እንደገና ይጀምሩ።",
|
||||
sessionLost: "የማረጋገጫ ክፍለ ጊዜዎ አልፏል። እባክዎ እንደገና ይጀምሩ።",
|
||||
stateMismatch: "ይህ ማረጋገጫ ሊታመን አልቻለም። እባክዎ እንደገና ይጀምሩ።",
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
|
||||
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
|
||||
@@ -787,6 +840,7 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
licensing: {
|
||||
certificateSuperseded: 'ሰርተፍኬቱ ተተክቷል',
|
||||
vesselPicker: {
|
||||
placeholder: 'የተመዘገበ መርከብ ይምረጡ',
|
||||
},
|
||||
@@ -811,6 +865,16 @@ export const am: Translations = {
|
||||
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
|
||||
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
|
||||
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
|
||||
reportDamaged: 'ጉዳት ሪፖርት ያድርጉ / ምትክ ይጠይቁ',
|
||||
reissueFailed: 'የምትክ ጥያቄ መጀመር አልተቻለም',
|
||||
status: {
|
||||
ACTIVE: 'የፀና',
|
||||
EXPIRED: 'ጊዜው ያለፈበት',
|
||||
SUSPENDED: 'የታገደ',
|
||||
CANCELLED: 'የተሰረዘ',
|
||||
SUPERSEDED: 'በአዲስ የምስክር ወረቀት የተተካ',
|
||||
},
|
||||
statusReason: 'ምክንያት፦ {{reason}}',
|
||||
},
|
||||
catalogue: {
|
||||
emptyTitle: 'የሚሰሩበትን የስራ ዘርፍ ይንገሩን',
|
||||
@@ -832,9 +896,20 @@ export const am: Translations = {
|
||||
evaluationOnly: 'ግምገማ ብቻ',
|
||||
startApplication: 'ማመልከቻ ጀምር',
|
||||
addToOperations: 'ወደ ስራ ዘርፎቼ ጨምር',
|
||||
lockedHint:
|
||||
'ከተመዘገቡ የስራ ዘርፎችዎ ውስጥ ስላልሆነ እስካሁን ማመልከት አይችሉም።',
|
||||
},
|
||||
},
|
||||
|
||||
pickup: {
|
||||
title: 'የሰነድ መረከቢያ',
|
||||
scheduledFor: 'ሰነድዎን ለመረከብ በ{{date}} ({{period}}) ወደ ቢሮ ይምጡ።',
|
||||
setByOffice: 'ይህ ቀጠሮ በፈቃድ ጽ/ቤቱ ተይዟል።',
|
||||
awaitingSchedule: 'ክፍያዎ ከተረጋገጠ በኋላ ፈቃድ ጽ/ቤቱ የመረከቢያ ቀን ይይዝልዎታል።',
|
||||
morning: 'ጠዋት',
|
||||
afternoon: 'ከሰዓት በኋላ',
|
||||
},
|
||||
|
||||
certificates: {
|
||||
title: "የእኔ የምስክር ወረቀቶች",
|
||||
loading: "የምስክር ወረቀቶች በመጫን ላይ…",
|
||||
@@ -1291,4 +1366,73 @@ export const am: Translations = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
documents: {
|
||||
title: 'ሰነዶቼ',
|
||||
subtitle: 'EMA ያወጣልዎት እያንዳንዱ ሰነድ፣ እንዲሁም ከመዝገቦችዎ ጋር የተያያዙ ፋይሎች።',
|
||||
tabs: {
|
||||
license: 'ፈቃዶች',
|
||||
medical: 'ሕክምና',
|
||||
seaService: 'የባህር አገልግሎት',
|
||||
personal: 'የግል መረጃ',
|
||||
},
|
||||
issuedTitle: 'በ EMA የተሰጡ ሰነዶች',
|
||||
licensesTitle: 'የምስክር ወረቀቶች እና ፈቃዶች',
|
||||
kind: {
|
||||
SEAMAN_BOOK: 'የመርከበኛ መጽሐፍ',
|
||||
BTC_BASIC_TRAINING: 'የመሠረታዊ ስልጠና የምስክር ወረቀት (BTC)',
|
||||
},
|
||||
documentStatus: {
|
||||
AWAITING_REGISTRATION: 'ምዝገባ በመጠበቅ ላይ',
|
||||
PAYMENT_PENDING: 'ክፍያ በመጠበቅ ላይ',
|
||||
PAID: 'ተከፍሏል',
|
||||
PAYMENT_CONFIRMED: 'ሰነድ በመዘጋጀት ላይ',
|
||||
SCHEDULED: 'የመውሰጃ ቀጠሮ ተይዟል',
|
||||
ISSUED: 'ተሰጥቷል',
|
||||
REJECTED: 'ተቀባይነት አላገኘም',
|
||||
CANCELLED: 'ተሰርዟል',
|
||||
},
|
||||
view: 'ይመልከቱ',
|
||||
notIssued: 'እስካሁን አልተሰጠም',
|
||||
openFailed: 'ሰነዱን መክፈት አልተቻለም',
|
||||
files: {
|
||||
none: 'ምንም የተያያዘ ፋይል የለም።',
|
||||
},
|
||||
preview: {
|
||||
unsupported: 'ይህ የፋይል አይነት እዚህ ሊታይ አይችልም። ለማውረድ በአዲስ ትር ይክፈቱት።',
|
||||
openInNewTab: 'በአዲስ ትር ክፈት',
|
||||
close: 'ዝጋ',
|
||||
},
|
||||
empty: {
|
||||
licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።',
|
||||
medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።',
|
||||
seaService: 'እስካሁን በመዝገብ ላይ የባህር አገልግሎት መዝገብ የለም።',
|
||||
},
|
||||
personal: {
|
||||
description: 'የማንነትና የትምህርት ሰነዶችዎ። እዚህ አንድ ጊዜ ይስቀሉ፤ በመዝገብዎ ላይ ይቆያሉ።',
|
||||
empty: 'እስካሁን የተዋቀረ የግል ሰነድ የለም።',
|
||||
uploaded: 'ተሰቅሏል',
|
||||
missing: 'አልተሰቀለም',
|
||||
fileCount: '{{count}} ከ {{max}}',
|
||||
fileCountUnlimited_one: '{{count}} ፋይል',
|
||||
fileCountUnlimited_other: '{{count}} ፋይሎች',
|
||||
slotFull: 'ይህ ሰነድ ተሟልቷል። ለመቀየር አንዱን ፋይል ይተኩ ወይም ያስወግዱ።',
|
||||
uploading: 'በመስቀል ላይ… {{percent}}%',
|
||||
delete: 'ፋይል አስወግድ',
|
||||
confirmDelete: {
|
||||
title: 'ይህን ፋይል ያስወግዱ?',
|
||||
body: '"{{name}}"ን ያስወግዱ? በኋላ እንደገና መስቀል ይችላሉ።',
|
||||
confirm: 'አስወግድ',
|
||||
},
|
||||
errors: {
|
||||
unknown: 'ፋይሉ ሊቀመጥ አልቻለም። እንደገና ይሞክሩ።',
|
||||
unknown_document_key: 'ይህ ሰነድ አሁን አይሰበሰብም።',
|
||||
unsupported_document_type: 'ይህ የፋይል አይነት እዚህ አይፈቀድም። የተፈቀዱት፦ {{allowed}}።',
|
||||
document_too_large: 'ፋይሉ በጣም ትልቅ ነው።',
|
||||
document_file_required: 'የሚሰቀል ፋይል ይምረጡ።',
|
||||
slot_full: 'ይህ ሰነድ ቀድሞውኑ {{maxFiles}} ፋይል(ሎች) ይዟል። በምትኩ አንዱን ይተኩ።',
|
||||
document_file_not_found: 'ይህ ፋይል በመዝገብዎ ላይ የለም።',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ export const en = {
|
||||
seaRecords: 'My Sea Records',
|
||||
seaService: 'Sea Service',
|
||||
medical: 'Medical Certificate',
|
||||
biometrics: 'Biometrics',
|
||||
myApplication: 'My Application',
|
||||
vesselRegistration: 'Vessel Registration',
|
||||
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
||||
@@ -192,6 +193,7 @@ export const en = {
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Number or applicant',
|
||||
status: 'Status',
|
||||
kind: 'Type',
|
||||
any: 'Any',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
@@ -215,6 +217,9 @@ export const en = {
|
||||
applicant: 'Applicant',
|
||||
progress: 'Progress',
|
||||
applicationNumber: 'Application №',
|
||||
kindNew: 'New',
|
||||
kindRenewal: 'Renewal',
|
||||
kindReissue: 'Replacement',
|
||||
},
|
||||
actions: {
|
||||
continue: 'Continue',
|
||||
@@ -224,6 +229,11 @@ export const en = {
|
||||
view: 'View',
|
||||
bypass: 'Bypass payment',
|
||||
renew: 'Renew',
|
||||
discard: 'Discard',
|
||||
discardConfirm:
|
||||
'Delete draft {{number}}? Anything filled in so far is lost.',
|
||||
discarded: 'Draft discarded',
|
||||
discardFailed: 'Could not discard draft',
|
||||
},
|
||||
notice: {
|
||||
paymentReceived:
|
||||
@@ -346,9 +356,32 @@ export const en = {
|
||||
profile: 'Profile',
|
||||
address: 'Address',
|
||||
operations: 'Operations',
|
||||
signature: 'Signature',
|
||||
security: 'Security',
|
||||
preferences: 'Preferences',
|
||||
},
|
||||
signature: {
|
||||
title: 'Specimen signature',
|
||||
description:
|
||||
'Drawn or uploaded once and printed on the documents issued to you.',
|
||||
reissueNotice:
|
||||
'Changing your signature does not alter a document already issued — it applies to whatever is issued from now on.',
|
||||
current: 'Signature on file',
|
||||
currentAlt: 'Your stored signature',
|
||||
none: 'No signature on file yet.',
|
||||
modeDraw: 'Draw',
|
||||
modeUpload: 'Upload',
|
||||
save: 'Save signature',
|
||||
clear: 'Clear',
|
||||
choose: 'Choose image',
|
||||
fileHint: 'PNG or JPEG, up to 2 MB.',
|
||||
remove: 'Remove',
|
||||
saved: 'Signature saved.',
|
||||
removed: 'Signature removed.',
|
||||
badType: 'Only PNG and JPEG images are accepted.',
|
||||
tooLarge: 'That image is larger than 2 MB.',
|
||||
drawFailed: 'Could not read the drawing. Please try again.',
|
||||
},
|
||||
maritimeSection: {
|
||||
title: 'Maritime Profile',
|
||||
subtitle: 'Your professional maritime details',
|
||||
@@ -616,6 +649,28 @@ export const en = {
|
||||
createOne: 'Create one',
|
||||
},
|
||||
|
||||
fayda: {
|
||||
continueWith: 'Continue with Fayda',
|
||||
orFillManually: 'or fill in your details',
|
||||
verifiedTitle: 'Verified with Fayda',
|
||||
verifiedBody: 'We filled in the details Fayda confirmed. Please complete the remaining fields.',
|
||||
discard: 'Clear these details and fill the form myself',
|
||||
fieldVerified: 'From Fayda',
|
||||
fieldConflict: 'Already used by another account',
|
||||
conflictBody:
|
||||
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
|
||||
brandTitle: 'Verifying with Fayda',
|
||||
brandSubtitle: 'One moment while we confirm your identity.',
|
||||
verifying: 'Verifying your Fayda identity\u2026',
|
||||
failedTitle: 'Verification incomplete',
|
||||
backToSignup: 'Back to sign up',
|
||||
cancelled: 'Fayda verification was cancelled. You can still sign up manually.',
|
||||
rejected: 'Fayda could not verify your identity. Please try again.',
|
||||
invalidCallback: 'This verification link is incomplete. Please start again.',
|
||||
sessionLost: 'Your verification session has expired. Please start again.',
|
||||
stateMismatch: 'This verification could not be trusted. Please start again.',
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: 'Username must be at least 3 characters',
|
||||
nameEnRequired: 'Name (English) is required',
|
||||
@@ -791,6 +846,7 @@ export const en = {
|
||||
},
|
||||
|
||||
licensing: {
|
||||
certificateSuperseded: 'Certificate superseded',
|
||||
vesselPicker: {
|
||||
placeholder: 'Select a registered vessel',
|
||||
},
|
||||
@@ -815,6 +871,16 @@ export const en = {
|
||||
renewDays_one: 'Renew — expires in {{count}} day',
|
||||
renewDays_other: 'Renew — expires in {{count}} days',
|
||||
renewFailed: 'Could not start the renewal',
|
||||
reportDamaged: 'Report damaged / request replacement',
|
||||
reissueFailed: 'Could not start the replacement request',
|
||||
status: {
|
||||
ACTIVE: 'Active',
|
||||
EXPIRED: 'Expired',
|
||||
SUSPENDED: 'Suspended',
|
||||
CANCELLED: 'Cancelled',
|
||||
SUPERSEDED: 'Replaced by a newer certificate',
|
||||
},
|
||||
statusReason: 'Reason: {{reason}}',
|
||||
},
|
||||
catalogue: {
|
||||
emptyTitle: 'Tell us what you operate as',
|
||||
@@ -836,9 +902,20 @@ export const en = {
|
||||
evaluationOnly: 'Evaluation only',
|
||||
startApplication: 'Start application',
|
||||
addToOperations: 'Add to my operations',
|
||||
lockedHint:
|
||||
'Not one of your declared operations, so it cannot be applied for yet.',
|
||||
},
|
||||
},
|
||||
|
||||
pickup: {
|
||||
title: 'Document Pickup',
|
||||
scheduledFor: 'Visit the office on {{date}} ({{period}}) to collect your document.',
|
||||
setByOffice: 'This appointment was scheduled by the licensing office.',
|
||||
awaitingSchedule: 'The licensing office will assign a pickup date once your payment is confirmed.',
|
||||
morning: 'Morning',
|
||||
afternoon: 'Afternoon',
|
||||
},
|
||||
|
||||
certificates: {
|
||||
title: 'My Certificates',
|
||||
loading: 'Loading Certificates…',
|
||||
@@ -1297,6 +1374,78 @@ export const en = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
documents: {
|
||||
title: 'My documents',
|
||||
subtitle:
|
||||
'Every document EMA has issued you, and the files attached to your records.',
|
||||
tabs: {
|
||||
license: 'Licences',
|
||||
medical: 'Medical',
|
||||
seaService: 'Sea Service',
|
||||
personal: 'Personal Data',
|
||||
},
|
||||
issuedTitle: 'EMA-issued documents',
|
||||
licensesTitle: 'Certificates and licences',
|
||||
kind: {
|
||||
SEAMAN_BOOK: 'Seaman Book',
|
||||
BTC_BASIC_TRAINING: 'Basic Training Certificate (BTC)',
|
||||
},
|
||||
documentStatus: {
|
||||
AWAITING_REGISTRATION: 'Awaiting registration',
|
||||
PAYMENT_PENDING: 'Payment pending',
|
||||
PAID: 'Paid',
|
||||
PAYMENT_CONFIRMED: 'Preparing document',
|
||||
SCHEDULED: 'Pickup scheduled',
|
||||
ISSUED: 'Issued',
|
||||
REJECTED: 'Rejected',
|
||||
CANCELLED: 'Cancelled',
|
||||
},
|
||||
view: 'View',
|
||||
notIssued: 'Not issued yet',
|
||||
openFailed: 'Could not open the document',
|
||||
files: {
|
||||
none: 'No files attached.',
|
||||
},
|
||||
preview: {
|
||||
unsupported:
|
||||
'This file type cannot be shown here. Open it in a new tab to download it.',
|
||||
openInNewTab: 'Open in a new tab',
|
||||
close: 'Close',
|
||||
},
|
||||
empty: {
|
||||
licenses: 'No certificates or licences have been issued to you yet.',
|
||||
medical: 'No medical certificates on file yet.',
|
||||
seaService: 'No sea-service records on file yet.',
|
||||
},
|
||||
personal: {
|
||||
description:
|
||||
'Your identity and education documents. Upload them once here and they stay on your record.',
|
||||
empty: 'No personal documents are configured yet.',
|
||||
uploaded: 'Uploaded',
|
||||
missing: 'Not uploaded',
|
||||
fileCount: '{{count}} of {{max}}',
|
||||
fileCountUnlimited_one: '{{count}} file',
|
||||
fileCountUnlimited_other: '{{count}} files',
|
||||
slotFull: 'This document is complete. Replace or remove a file to change it.',
|
||||
uploading: 'Uploading… {{percent}}%',
|
||||
delete: 'Remove file',
|
||||
confirmDelete: {
|
||||
title: 'Remove this file?',
|
||||
body: 'Remove "{{name}}"? You can upload it again afterwards.',
|
||||
confirm: 'Remove',
|
||||
},
|
||||
errors: {
|
||||
unknown: 'The file could not be saved. Try again.',
|
||||
unknown_document_key: 'This document is no longer being collected.',
|
||||
unsupported_document_type: 'That file type is not accepted here. Allowed: {{allowed}}.',
|
||||
document_too_large: 'That file is too large.',
|
||||
document_file_required: 'Choose a file to upload.',
|
||||
slot_full: 'This document already holds {{maxFiles}} file(s). Replace one instead.',
|
||||
document_file_not_found: 'That file is no longer on your record.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IconArrowsExchange,
|
||||
IconBell,
|
||||
IconBook2,
|
||||
IconFingerprint,
|
||||
IconFolderOpen,
|
||||
IconHeadset,
|
||||
IconHome2,
|
||||
@@ -131,6 +132,13 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
||||
icon: IconShieldCheck,
|
||||
permissions: [P.VIEW_OWN_CERTIFICATES],
|
||||
},
|
||||
{
|
||||
to: "/seafarer/biometrics",
|
||||
label: "Biometrics",
|
||||
i18nKey: "nav.biometrics",
|
||||
icon: IconFingerprint,
|
||||
permissions: [P.VIEW_OWN_BIOMETRICS],
|
||||
},
|
||||
{
|
||||
to: "/exams",
|
||||
label: "Examinations",
|
||||
@@ -206,6 +214,7 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||
"/seaman-book": { i18nKey: "nav.seamanBook" },
|
||||
"/basic-training-certificate": { i18nKey: "nav.btc" },
|
||||
"/certificates": { i18nKey: "nav.certificates" },
|
||||
"/seafarer/biometrics": { i18nKey: "nav.biometrics" },
|
||||
"/exams": { i18nKey: "nav.exams" },
|
||||
"/endorsements": { i18nKey: "nav.endorsements" },
|
||||
"/documents": { i18nKey: "nav.documents" },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LandingRoute } from "./components/LandingRoute";
|
||||
import {
|
||||
LoginPage,
|
||||
SignupPage,
|
||||
FaydaCallbackPage,
|
||||
OTPVerificationPage,
|
||||
ForgotPasswordPage,
|
||||
SetPasswordPage,
|
||||
@@ -28,6 +29,7 @@ import { OperationsOnboardingPage } from "./features/onboarding/pages/Operations
|
||||
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
|
||||
import { BiometricsPage } from "./features/seafarer/pages/Biometrics";
|
||||
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
||||
@@ -67,6 +69,16 @@ export const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/signup", element: <SignupPage /> },
|
||||
|
||||
// Where Fayda returns the applicant. Public by necessity — they have no
|
||||
// account yet. It redeems the code and hands control back to /signup.
|
||||
//
|
||||
// Two paths for one page: whichever is registered with Fayda has to match the
|
||||
// API's FAYDA_REDIRECT_URI exactly, and the value being registered first is a
|
||||
// bare /callback. The descriptive path is kept so the route still reads as
|
||||
// part of signup once that can be changed.
|
||||
{ path: "/signup/fayda/callback", element: <FaydaCallbackPage /> },
|
||||
{ path: "/callback", element: <FaydaCallbackPage /> },
|
||||
|
||||
// Completes the forgot-password flow; the reset message links here. The
|
||||
// IAM package generates `/reset-password` links, `/set-password` is the
|
||||
// first-time-credential variant — one page serves both.
|
||||
@@ -205,6 +217,14 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/seafarer/biometrics",
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.VIEW_OWN_BIOMETRICS]}>
|
||||
<BiometricsPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{ path: "/seafarer/records", element: <Navigate to="/seafarer/sea-service" replace /> },
|
||||
{
|
||||
path: "/exams",
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
// Env lives at the workspace root, shared with the backoffice — without this
|
||||
// Vite looks in apps/portal and VITE_BASE_API_URL silently falls back to its
|
||||
// built-in default.
|
||||
envDir: '../../',
|
||||
cacheDir: '../../node_modules/.vite/apps/portal',
|
||||
server: { port: 4200, host: 'localhost' },
|
||||
// server: {
|
||||
// port: 4200,
|
||||
// proxy: {
|
||||
// '/api': {
|
||||
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
|
||||
// changeOrigin: true,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
preview: { port: 4200, host: 'localhost' },
|
||||
envDir: "../../",
|
||||
cacheDir: "../../node_modules/.vite/apps/portal",
|
||||
server: { port: 4200, host: "localhost" },
|
||||
// server: {
|
||||
// port: 4200,
|
||||
// proxy: {
|
||||
// '/api': {
|
||||
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
|
||||
// changeOrigin: true,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
preview: { port: 4200, host: "localhost" },
|
||||
plugins: [react(), nxViteTsPaths()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
|
||||
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
|
||||
},
|
||||
build: {
|
||||
outDir: '../../dist/apps/portal',
|
||||
outDir: "../../dist/apps/portal",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user