mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-01 18:13:28 +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:
36
.env.example
Normal file
36
.env.example
Normal file
@@ -0,0 +1,36 @@
|
||||
# emaui environment
|
||||
#
|
||||
# Vite reads this from the workspace root, not from the app directory:
|
||||
# apps/portal/vite.config.mts and apps/backoffice/vite.config.mts both set
|
||||
# `envDir: '../../'`. So copy this file to ./.env here, at the repo root.
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# Only VITE_-prefixed values reach the browser, and everything that does is
|
||||
# public — it ships inside the built bundle. Never put a secret here. The Fayda
|
||||
# client id, private key and endpoints live in the API's environment only; the
|
||||
# frontend never speaks to Fayda directly.
|
||||
|
||||
# Base URL of the emaapi backend, including the /api prefix.
|
||||
# The portal runs on 4200 and the local API runs on 3000.
|
||||
VITE_BASE_API_URL=http://localhost:3000/api
|
||||
|
||||
# Serve fixture data instead of calling the API. Any value other than "true"
|
||||
# uses the real backend.
|
||||
VITE_USE_MOCKS=false
|
||||
|
||||
# --- Docker Compose only -----------------------------------------------------
|
||||
# Host ports published by docker-compose.yml. It also expects per-app env files
|
||||
# at apps/portal/.env and apps/backoffice/.env, which can each be a copy of this
|
||||
# file. Ignored when running the Vite dev servers, which serve the portal on
|
||||
# 4200 and the backoffice on 4201.
|
||||
# EMA_PORTAL_PORT=8021
|
||||
# EMA_BACKOFFICE_PORT=8022
|
||||
|
||||
# --- Fayda note --------------------------------------------------------------
|
||||
# There is nothing to configure here for Fayda. The portal serves the callback
|
||||
# page at /callback and /signup/fayda/callback, and whichever path is registered
|
||||
# with Fayda must match the API's FAYDA_REDIRECT_URI exactly.
|
||||
#
|
||||
# Register http://localhost:4200/callback with Fayda. Nothing
|
||||
# extra to run — `nx serve portal` already binds the right port.
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,6 +13,8 @@ coverage/
|
||||
# env
|
||||
.env
|
||||
.env.*
|
||||
# ...but the checked-in template must survive that rule.
|
||||
!.env.example
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
48
README.md
48
README.md
@@ -6,20 +6,20 @@ A fully scaffolded Nx monorepo housing two Vite + React 19 SPAs (Backoffice and
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Tool | Version |
|
||||
|------|---------|
|
||||
| React | 19 |
|
||||
| Nx | 22 |
|
||||
| Vite | 7 |
|
||||
| TypeScript | 5.9 |
|
||||
| Redux Toolkit | 2.11 |
|
||||
| Mantine | 8.3 |
|
||||
| React Router | 7 |
|
||||
| TanStack Query | 5 |
|
||||
| React Hook Form | 7 |
|
||||
| Zod | 4 |
|
||||
| Tailwind CSS | 3.4 |
|
||||
| Vitest | 4 |
|
||||
| Tool | Version |
|
||||
| --------------- | ------- |
|
||||
| React | 19 |
|
||||
| Nx | 22 |
|
||||
| Vite | 7 |
|
||||
| TypeScript | 5.9 |
|
||||
| Redux Toolkit | 2.11 |
|
||||
| Mantine | 8.3 |
|
||||
| React Router | 7 |
|
||||
| TanStack Query | 5 |
|
||||
| React Hook Form | 7 |
|
||||
| Zod | 4 |
|
||||
| Tailwind CSS | 3.4 |
|
||||
| Vitest | 4 |
|
||||
|
||||
---
|
||||
|
||||
@@ -37,16 +37,19 @@ emaui/
|
||||
```
|
||||
|
||||
### libs/api
|
||||
|
||||
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or storage.
|
||||
- `session/` — `resolveTokenFromStorage()` reads the `auth-token` cookie first, falling back to `localStorage` for legacy pre-migration sessions. `resolveSessionContext()` merges Redux state token with storage fallback.
|
||||
- `query-and-mutation/` — Generic `useApiQuery` / `useApiMutation` wrappers for one-off API calls without defining a dedicated endpoint file.
|
||||
|
||||
### libs/ui
|
||||
|
||||
- `ConfirmModal` — Reusable Mantine modal for destructive-action confirmation.
|
||||
- `ApiErrorAlert` — Extracts a human-readable message from RTK Query error shapes or Error objects.
|
||||
- `notify` — Thin wrapper around `@mantine/notifications` with `.success`, `.error`, `.info`, `.warning` helpers.
|
||||
|
||||
### libs/shared
|
||||
|
||||
- `ema-theme` — Mantine v8 `createTheme()` with `emaPrimary` (blue) and `emaSecondary` (warm) color tuples, Inter font, and custom shadow scale.
|
||||
|
||||
---
|
||||
@@ -86,14 +89,14 @@ npm run dev:all
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
|
||||
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
|
||||
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
|
||||
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
|
||||
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
|
||||
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
|
||||
| Variable | Required | Default | Description |
|
||||
| ----------------------------- | ------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
|
||||
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
|
||||
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
|
||||
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
|
||||
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
|
||||
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
|
||||
|
||||
---
|
||||
|
||||
@@ -112,6 +115,7 @@ The `Dockerfile` uses multi-stage builds with named targets (`portal` / `backoff
|
||||
## Adding a New Feature
|
||||
|
||||
1. Create the feature folder under the relevant app:
|
||||
|
||||
```
|
||||
apps/backoffice/src/app/features/<feature-name>/
|
||||
├── types/ # TypeScript interfaces
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ export * from './lib/features/location';
|
||||
export * from './lib/features/seafarer';
|
||||
export * from './lib/features/seafarer-registration';
|
||||
export * from './lib/features/seafarer-document';
|
||||
export * from './lib/features/personal-document';
|
||||
export * from './lib/features/biometric-enrollment';
|
||||
export * from './lib/features/vessel';
|
||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';
|
||||
|
||||
@@ -2,10 +2,15 @@ import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
|
||||
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
|
||||
import { resolveSessionContext } from "../session";
|
||||
|
||||
/**
|
||||
* The one place the backend URL is resolved: VITE_BASE_API_URL from the env,
|
||||
* falling back to the local dev API (3001 — the portal itself owns 3000 for
|
||||
* the Fayda redirect). Import this; do not re-derive it.
|
||||
*/
|
||||
export const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.[
|
||||
"VITE_BASE_API_URL"
|
||||
] ?? "http://localhost:3000/api";
|
||||
]?.trim() || "http://localhost:3000/api";
|
||||
|
||||
let _onTokenExpired: (() => Promise<string>) | null = null;
|
||||
let _onAuthFailure: (() => void) | null = null;
|
||||
|
||||
@@ -172,6 +172,15 @@ const handlers: MockHandler[] = [
|
||||
return detail ? clone(detail) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
pattern: /^\/license-applications\/([\w-]+)$/,
|
||||
respond: (_req, match) => {
|
||||
delete mockApplications[match[1]];
|
||||
delete mockApplicationDetails[match[1]];
|
||||
return { deleted: true };
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
pattern: /^\/license-applications\/([\w-]+)\/sections\/([\w-]+)$/,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type { BiometricEnrollment, EnrollBiometric } from './biometric-enrollment.types';
|
||||
|
||||
const TAG = 'BiometricEnrollment' as const;
|
||||
const forProfile = (profileId: string) => ({ type: TAG, id: profileId }) as const;
|
||||
|
||||
/**
|
||||
* Scanner capture (fingerprint, face) stored per profile. No applicant-facing
|
||||
* endpoint — enrollment happens at a counter with a scanner.
|
||||
*/
|
||||
export const biometricEnrollmentApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: [TAG] })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
enrollBiometric: builder.mutation<BiometricEnrollment, EnrollBiometric>({
|
||||
query: (body) => ({ url: '/biometric-enrollments', method: 'POST', body }),
|
||||
invalidatesTags: (r, error) => (error || !r ? [] : [forProfile(r.profileId)]),
|
||||
}),
|
||||
|
||||
getBiometricEnrollments: builder.query<BiometricEnrollment[], string>({
|
||||
query: (profileId) => ({ url: `/biometric-enrollments/profile/${profileId}` }),
|
||||
providesTags: (_r, _e, profileId) => [forProfile(profileId)],
|
||||
}),
|
||||
|
||||
/** Self-service, view-only: the caller's own live enrollments. */
|
||||
getMyBiometricEnrollments: builder.query<BiometricEnrollment[], void>({
|
||||
query: () => ({ url: '/biometric-enrollments/mine' }),
|
||||
providesTags: [{ type: TAG, id: 'MINE' }],
|
||||
}),
|
||||
|
||||
/** Dev/test only — the API reports false in production. */
|
||||
getBiometricSimulateCapabilities: builder.query<{ simulateEnabled: boolean }, void>({
|
||||
query: () => ({ url: '/biometric-enrollments/simulate/capabilities' }),
|
||||
}),
|
||||
|
||||
revokeBiometricEnrollment: builder.mutation<
|
||||
BiometricEnrollment,
|
||||
{ id: string; profileId: string; reason: string }
|
||||
>({
|
||||
query: ({ id, reason }) => ({
|
||||
url: `/biometric-enrollments/${id}/revoke`,
|
||||
method: 'POST',
|
||||
body: { reason },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { profileId }) => (error ? [] : [forProfile(profileId)]),
|
||||
}),
|
||||
|
||||
/** Stamps the profile's BSID once enrollment is confirmed. Requires an active enrollment. */
|
||||
generateBsid: builder.mutation<{ id: string; bsid: string | null }, string>({
|
||||
query: (profileId) => ({
|
||||
url: `/biometric-enrollments/profile/${profileId}/generate-bsid`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: (_r, error, profileId) => (error ? [] : [forProfile(profileId)]),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useEnrollBiometricMutation,
|
||||
useGetBiometricEnrollmentsQuery,
|
||||
useGetMyBiometricEnrollmentsQuery,
|
||||
useGetBiometricSimulateCapabilitiesQuery,
|
||||
useRevokeBiometricEnrollmentMutation,
|
||||
useGenerateBsidMutation,
|
||||
} = biometricEnrollmentApi;
|
||||
@@ -0,0 +1,32 @@
|
||||
export type BiometricModality = 'FINGERPRINT' | 'FACE';
|
||||
export type BiometricEnrollmentStatus = 'ACTIVE' | 'REVOKED';
|
||||
|
||||
export interface BiometricEnrollment {
|
||||
id: string;
|
||||
profileId: string;
|
||||
modality: BiometricModality;
|
||||
templateFormat: string;
|
||||
qualityScore: number | null;
|
||||
deviceId: string | null;
|
||||
status: BiometricEnrollmentStatus;
|
||||
enrolledById: string;
|
||||
enrolledAt: string;
|
||||
consentAt: string;
|
||||
revokedReason: string | null;
|
||||
revokedById: string | null;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EnrollBiometric {
|
||||
profileId: string;
|
||||
modality: BiometricModality;
|
||||
/** Vendor SDK template, base64. Never the raw scan image. */
|
||||
template: string;
|
||||
templateFormat: string;
|
||||
qualityScore?: number;
|
||||
deviceId?: string;
|
||||
/** ISO 8601 — when the subject consented to capture. */
|
||||
consentAt: string;
|
||||
}
|
||||
2
libs/api/src/lib/features/biometric-enrollment/index.ts
Normal file
2
libs/api/src/lib/features/biometric-enrollment/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './biometric-enrollment.types';
|
||||
export * from './biometric-enrollment-api';
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
InitiatePaymentResult,
|
||||
IssuedLicense,
|
||||
Inspection,
|
||||
IssuancePeriod,
|
||||
LicenseApplication,
|
||||
LicenseCategoryDefinition,
|
||||
LicenseStatus,
|
||||
@@ -21,11 +22,16 @@ import type {
|
||||
LicenseTypeRequirements,
|
||||
OperatorType,
|
||||
AssignableOfficer,
|
||||
CertificateCategory,
|
||||
CompletionEffect,
|
||||
DocumentDecision,
|
||||
DocumentReview,
|
||||
ExportResult,
|
||||
LicenseTemplate,
|
||||
Paginated,
|
||||
PickupAppointment,
|
||||
PickupOffice,
|
||||
PickupSlot,
|
||||
QueueCounts,
|
||||
QueueFilter,
|
||||
Rank,
|
||||
@@ -33,10 +39,14 @@ import type {
|
||||
RemarkTargetType,
|
||||
SavedQueueView,
|
||||
SchemaIssue,
|
||||
ServiceKind,
|
||||
TemplateFieldPlacement,
|
||||
TemplateLogoPlacement,
|
||||
TemplatePageOptions,
|
||||
TemplateVariable,
|
||||
PersonalDocumentFilter,
|
||||
PersonalDocumentGroup,
|
||||
WorkflowProfile,
|
||||
} from './licensing.types';
|
||||
|
||||
/**
|
||||
@@ -63,6 +73,16 @@ function serialiseQueueFilter(
|
||||
return params;
|
||||
}
|
||||
|
||||
/** Sends only the facets that are set; `search=` would match nothing. */
|
||||
function dropEmpty(filter: object): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (value === undefined || value === null || value === '' || value === false) continue;
|
||||
params[key] = value;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
const TAGS = [
|
||||
'LicenseType',
|
||||
'OperatorType',
|
||||
@@ -75,8 +95,13 @@ const TAGS = [
|
||||
'SavedView',
|
||||
'LicenseTemplate',
|
||||
'DocumentRequirement',
|
||||
'PickupOffice',
|
||||
'PickupAppointment',
|
||||
'Department',
|
||||
'Rank',
|
||||
// Owned by the personal-document slice; named here so declaring a mode of
|
||||
// operation can invalidate the vault, whose slots depend on it.
|
||||
'PersonalDocument',
|
||||
] as const;
|
||||
|
||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||
@@ -121,9 +146,17 @@ export const licensingApi = baseApi
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
// The catalogue is filtered by this, so it has to refetch too.
|
||||
// The catalogue is filtered by this, so it has to refetch too — and so
|
||||
// is the personal document vault, which asks for the documents the
|
||||
// declared modes of operation need.
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('OperatorType'), listTag('LicenseType')],
|
||||
error
|
||||
? []
|
||||
: [
|
||||
listTag('OperatorType'),
|
||||
listTag('LicenseType'),
|
||||
listTag('PersonalDocument'),
|
||||
],
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -153,6 +186,12 @@ export const licensingApi = baseApi
|
||||
feeNewApplication?: number | null;
|
||||
feeRenewal?: number | null;
|
||||
feeCurrency?: string;
|
||||
// The examined-certificate stages. Unlike the two above, these are
|
||||
// read live off the licence type rather than snapshotted, so the
|
||||
// server refuses to clear one a candidate is currently waiting on.
|
||||
feeEligibility?: number | null;
|
||||
feeExamination?: number | null;
|
||||
feeCertificate?: number | null;
|
||||
}
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
@@ -164,6 +203,53 @@ export const licensingApi = baseApi
|
||||
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
||||
}),
|
||||
|
||||
/**
|
||||
* How a licence type behaves: its workflow, eligibility gates, renewal
|
||||
* policy and applicant rules — everything that used to be settable only
|
||||
* by editing a seed file.
|
||||
*
|
||||
* Three of these (`workflowProfile`, `completionEffect`,
|
||||
* `requiresExamination`) decide the course an application runs, and are
|
||||
* read live rather than snapshotted. The server answers 409
|
||||
* `license_type_in_use` when changing one would strand applications that
|
||||
* have not yet had their approval decision.
|
||||
*/
|
||||
updateLicenseBehavior: builder.mutation<
|
||||
LicenseType,
|
||||
{
|
||||
id: string;
|
||||
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;
|
||||
validityMonths?: number;
|
||||
validityDays?: number | null;
|
||||
capitalThreshold?: number | null;
|
||||
renewalWindowDays?: number;
|
||||
expiryReminderDays?: number[];
|
||||
requiresOperatorMode?: boolean;
|
||||
allowMultipleOpenDrafts?: boolean;
|
||||
requiresIssuanceScheduling?: boolean;
|
||||
uniqueFormKeyPath?: string | null;
|
||||
slaHours?: number | null;
|
||||
}
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-types/${id}/behavior`,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
||||
}),
|
||||
|
||||
/** Validity is edited beside the certificate design, not with the fees. */
|
||||
updateLicenseValidity: builder.mutation<
|
||||
LicenseType,
|
||||
@@ -234,9 +320,30 @@ export const licensingApi = baseApi
|
||||
providesTags: () => [listTag('DocumentRequirement')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Personal document slots, grouped by key and paged by the server.
|
||||
*
|
||||
* Its own endpoint rather than filtering `getDocumentRequirements` in the
|
||||
* browser: one document can be configured against several licence types,
|
||||
* so a page of rows would split a document in half and misreport its
|
||||
* scope. The server groups first, then pages.
|
||||
*/
|
||||
getPersonalDocuments: builder.query<
|
||||
Paginated<PersonalDocumentGroup>,
|
||||
PersonalDocumentFilter | void
|
||||
>({
|
||||
query: (filter) => ({
|
||||
url: '/document-requirements/personal',
|
||||
params: dropEmpty(filter ?? {}),
|
||||
}),
|
||||
providesTags: () => [listTag('DocumentRequirement')],
|
||||
}),
|
||||
|
||||
createDocumentRequirement: builder.mutation<
|
||||
DocumentRequirement,
|
||||
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
|
||||
// No `licenseTypeId` means a personal document, required for every
|
||||
// licence and served from the applicant's own vault.
|
||||
Partial<DocumentRequirement> & { key: string; name: DocumentRequirement['name'] }
|
||||
>({
|
||||
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||
@@ -337,6 +444,11 @@ export const licensingApi = baseApi
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
|
||||
}),
|
||||
|
||||
discardApplication: builder.mutation<{ deleted: boolean }, string>({
|
||||
query: (id) => ({ url: `/license-applications/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
|
||||
}),
|
||||
|
||||
getMyApplications: builder.query<Paginated<LicenseApplication>, void>({
|
||||
query: () => ({ url: '/license-applications/mine' }),
|
||||
providesTags: () => [listTag('LicenseApplication')],
|
||||
@@ -958,12 +1070,12 @@ export const licensingApi = baseApi
|
||||
|
||||
scheduleIssuance: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; scheduledDate: string }
|
||||
{ id: string; scheduledDate: string; scheduledPeriod: IssuancePeriod }
|
||||
>({
|
||||
query: ({ id, scheduledDate }) => ({
|
||||
query: ({ id, scheduledDate, scheduledPeriod }) => ({
|
||||
url: `/license-application-review/${id}/schedule-issuance`,
|
||||
method: 'POST',
|
||||
body: { scheduledDate },
|
||||
body: { scheduledDate, scheduledPeriod },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
@@ -978,6 +1090,104 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
// ------------------------------------------------------------- pickup
|
||||
getPickupOffices: builder.query<PickupOffice[], void>({
|
||||
query: () => ({ url: '/pickup/offices' }),
|
||||
providesTags: [listTag('PickupOffice')],
|
||||
}),
|
||||
|
||||
getPickupSlots: builder.query<
|
||||
PickupSlot[],
|
||||
{ officeId: string; from: string; to: string }
|
||||
>({
|
||||
query: ({ officeId, from, to }) => ({
|
||||
url: `/pickup/offices/${officeId}/slots`,
|
||||
params: { from, to },
|
||||
}),
|
||||
}),
|
||||
|
||||
schedulePickup: builder.mutation<
|
||||
PickupAppointment,
|
||||
{ applicationId: string; officeId: string; date: string; slotStartTime: string }
|
||||
>({
|
||||
query: (body) => ({ url: '/pickup/appointments', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error, { applicationId }) =>
|
||||
error
|
||||
? []
|
||||
: [
|
||||
itemTag('LicenseApplication', applicationId),
|
||||
listTag('ApplicationQueue'),
|
||||
listTag('PickupAppointment'),
|
||||
],
|
||||
}),
|
||||
|
||||
reschedulePickup: builder.mutation<
|
||||
PickupAppointment,
|
||||
{ appointmentId: string; officeId: string; date: string; slotStartTime: string }
|
||||
>({
|
||||
query: ({ appointmentId, ...body }) => ({
|
||||
url: `/pickup/appointments/${appointmentId}/reschedule`,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { appointmentId }) =>
|
||||
error ? [] : [itemTag('PickupAppointment', appointmentId), listTag('PickupAppointment')],
|
||||
}),
|
||||
|
||||
getPickupAppointmentsForApplication: builder.query<PickupAppointment[], string>({
|
||||
query: (applicationId) => ({
|
||||
url: `/pickup/applications/${applicationId}/appointments`,
|
||||
}),
|
||||
providesTags: (_r, _e, applicationId) => [itemTag('PickupAppointment', applicationId)],
|
||||
}),
|
||||
|
||||
getPickupWorklist: builder.query<
|
||||
PickupAppointment[],
|
||||
{ date: string; officeId?: string }
|
||||
>({
|
||||
query: ({ date, officeId }) => ({
|
||||
url: '/pickup/appointments',
|
||||
params: officeId ? { date, officeId } : { date },
|
||||
}),
|
||||
providesTags: [listTag('PickupAppointment')],
|
||||
}),
|
||||
|
||||
checkInPickup: builder.mutation<PickupAppointment, string>({
|
||||
query: (id) => ({ url: `/pickup/appointments/${id}/check-in`, method: 'PATCH' }),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
|
||||
}),
|
||||
|
||||
markPickupIssued: builder.mutation<PickupAppointment, string>({
|
||||
query: (id) => ({ url: `/pickup/appointments/${id}/issued`, method: 'PATCH' }),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
|
||||
}),
|
||||
|
||||
markPickupNoShow: builder.mutation<PickupAppointment, string>({
|
||||
query: (id) => ({ url: `/pickup/appointments/${id}/no-show`, method: 'PATCH' }),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
|
||||
}),
|
||||
|
||||
createPickupOffice: builder.mutation<PickupOffice, Partial<PickupOffice>>({
|
||||
query: (body) => ({ url: '/pickup/offices', method: 'POST', body }),
|
||||
invalidatesTags: [listTag('PickupOffice')],
|
||||
}),
|
||||
|
||||
updatePickupOffice: builder.mutation<
|
||||
PickupOffice,
|
||||
{ id: string } & Partial<PickupOffice>
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/pickup/offices/${id}`,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('PickupOffice', id), listTag('PickupOffice')],
|
||||
}),
|
||||
|
||||
// --------------------------------------------------------- inspection
|
||||
scheduleInspection: builder.mutation<
|
||||
Inspection,
|
||||
@@ -993,6 +1203,30 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
|
||||
}),
|
||||
|
||||
/** Moves a booked visit: another day, slot, inspector or place. */
|
||||
rescheduleInspection: builder.mutation<
|
||||
Inspection,
|
||||
{
|
||||
inspectionId: string;
|
||||
applicationId: string;
|
||||
scheduledDate: string;
|
||||
timeSlot: 'MORNING' | 'AFTERNOON';
|
||||
inspectorId?: string;
|
||||
location?: string;
|
||||
reason?: string;
|
||||
}
|
||||
>({
|
||||
query: ({ inspectionId, scheduledDate, timeSlot, inspectorId, location, reason }) => ({
|
||||
url: `/inspections/${inspectionId}/schedule`,
|
||||
method: 'PATCH',
|
||||
// applicationId is for cache invalidation only; the visit knows its
|
||||
// own application.
|
||||
body: { scheduledDate, timeSlot, inspectorId, location, reason },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { applicationId }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
|
||||
}),
|
||||
|
||||
getInspections: builder.query<Inspection[], string>({
|
||||
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
|
||||
providesTags: () => [listTag('Inspection')],
|
||||
@@ -1046,10 +1280,12 @@ export const {
|
||||
useGetLicenseTypesQuery,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
useUpdateLicenseBehaviorMutation,
|
||||
useUpdateFormSchemaMutation,
|
||||
useValidateFormSchemaMutation,
|
||||
useGetFormSchemaPaletteQuery,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useGetPersonalDocumentsQuery,
|
||||
useCreateDocumentRequirementMutation,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
@@ -1067,6 +1303,7 @@ export const {
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
useCreateApplicationMutation,
|
||||
useGetExamStateForApplicationQuery,
|
||||
useDiscardApplicationMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetApplicationQuery,
|
||||
useInitiatePaymentMutation,
|
||||
@@ -1127,7 +1364,19 @@ export const {
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
useGetPickupOfficesQuery,
|
||||
useGetPickupSlotsQuery,
|
||||
useSchedulePickupMutation,
|
||||
useReschedulePickupMutation,
|
||||
useGetPickupAppointmentsForApplicationQuery,
|
||||
useGetPickupWorklistQuery,
|
||||
useCheckInPickupMutation,
|
||||
useMarkPickupIssuedMutation,
|
||||
useMarkPickupNoShowMutation,
|
||||
useCreatePickupOfficeMutation,
|
||||
useUpdatePickupOfficeMutation,
|
||||
useScheduleInspectionMutation,
|
||||
useRescheduleInspectionMutation,
|
||||
useGetInspectionsQuery,
|
||||
useRecordInspectionResultMutation,
|
||||
useGetNotificationsQuery,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
|
||||
import type {
|
||||
ApplicationKind,
|
||||
Bilingual,
|
||||
FamilyKind,
|
||||
FieldCondition,
|
||||
@@ -11,9 +13,10 @@ import type {
|
||||
ValidationIssue,
|
||||
} from './licensing.types';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
/** A section with no `applicationKinds` applies to every kind, as before that field existed. */
|
||||
function sectionAppliesToKind(section: FormSectionConfig, kind: ApplicationKind): boolean {
|
||||
return !section.applicationKinds?.length || section.applicationKinds.includes(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a document straight to the API.
|
||||
@@ -63,6 +66,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
|
||||
UNDER_EVALUATION: 'Under Evaluation',
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
@@ -95,6 +99,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
SUBMITTED: 'blue',
|
||||
UNDER_REVIEW: 'indigo',
|
||||
AWAITING_BIOMETRICS: 'indigo',
|
||||
UNDER_EVALUATION: 'indigo',
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
INSPECTION_PENDING: 'cyan',
|
||||
@@ -134,6 +139,7 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
DRAFT: 5,
|
||||
SUBMITTED: 15,
|
||||
UNDER_REVIEW: 30,
|
||||
AWAITING_BIOMETRICS: 30,
|
||||
UNDER_EVALUATION: 45,
|
||||
RESUBMIT_REQUIRED: 30,
|
||||
INSPECTION_PENDING: 55,
|
||||
@@ -396,6 +402,15 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
inspection_not_passed:
|
||||
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
|
||||
license_type_inactive: 'This licence type is not currently accepting applications.',
|
||||
|
||||
// Licence-type configuration guards. Each of these refuses a change that
|
||||
// would leave applications already in progress unable to move.
|
||||
license_type_in_use:
|
||||
'Applications of this type are already in progress, and this setting decides the course they run. Wait until those have been decided, or change something else.',
|
||||
stage_fee_in_use:
|
||||
'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
|
||||
form_schema_missing_protected_paths:
|
||||
'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
|
||||
};
|
||||
|
||||
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
|
||||
@@ -451,12 +466,28 @@ export function buildWizardSteps(
|
||||
* instead of showing an empty page.
|
||||
*/
|
||||
hasStaff?: boolean;
|
||||
/**
|
||||
* Whether this application has any document requirements to upload.
|
||||
* False for a Damaged/Reissue application, which asks nothing beyond the
|
||||
* Damage Information step — showing an empty Documents page would be a
|
||||
* page to click past for nothing.
|
||||
*/
|
||||
hasDocuments?: boolean;
|
||||
/** Active UI language. Components get this from `useLocalized`; this is a
|
||||
* pure function, so the caller passes `i18n.language` through. */
|
||||
language?: string;
|
||||
/**
|
||||
* The application's kind — NEW unless the caller is renewing or
|
||||
* reissuing. A section scoped to a different kind via
|
||||
* `applicationKinds` is left out entirely, the same as a `showWhen`
|
||||
* that never holds.
|
||||
*/
|
||||
applicationKind?: ApplicationKind;
|
||||
},
|
||||
): WizardStep[] {
|
||||
const kind = options?.applicationKind ?? 'NEW';
|
||||
const visible = [...sections]
|
||||
.filter((section) => sectionAppliesToKind(section, kind))
|
||||
.filter((section) => conditionHolds(section.showWhen, formData))
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
|
||||
@@ -511,7 +542,9 @@ export function buildWizardSteps(
|
||||
...(options?.hasStaff === false
|
||||
? []
|
||||
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
|
||||
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
|
||||
...(options?.hasDocuments === false
|
||||
? []
|
||||
: [{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] } as WizardStep]),
|
||||
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
|
||||
];
|
||||
}
|
||||
@@ -597,6 +630,49 @@ interface ConditionLike {
|
||||
anyOf?: ConditionLike[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Section keys a condition reads, recursing `anyOf`.
|
||||
*
|
||||
* `FieldCondition.field` is a `sectionKey.fieldKey` path, so the prefix names
|
||||
* the section whose answer decides the condition.
|
||||
*/
|
||||
export function conditionSections(
|
||||
condition: FieldCondition | undefined | null,
|
||||
): string[] {
|
||||
if (!condition) return [];
|
||||
if (condition.anyOf) return condition.anyOf.flatMap(conditionSections);
|
||||
if (!condition.field) return [];
|
||||
const [sectionKey] = condition.field.split('.');
|
||||
return sectionKey ? [sectionKey] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections whose visibility hangs on an answer in one of `flagged`.
|
||||
*
|
||||
* Mirrors the server's `sectionsDependingOn`: an officer flagging the section
|
||||
* that holds the vessel category is asking for an answer that decides which
|
||||
* fields in other sections are required, so those sections have to open too —
|
||||
* otherwise the applicant sees newly-required fields they cannot edit and
|
||||
* cannot resubmit.
|
||||
*/
|
||||
export function sectionsDependingOn(
|
||||
sections: FormSectionConfig[],
|
||||
flagged: Set<string>,
|
||||
): Set<string> {
|
||||
const dependent = new Set<string>();
|
||||
if (flagged.size === 0) return dependent;
|
||||
|
||||
for (const section of sections) {
|
||||
if (flagged.has(section.key)) continue;
|
||||
const reads = [
|
||||
section.showWhen,
|
||||
...(section.fields ?? []).map((field) => field.showWhen),
|
||||
].flatMap(conditionSections);
|
||||
if (reads.some((key) => flagged.has(key))) dependent.add(section.key);
|
||||
}
|
||||
return dependent;
|
||||
}
|
||||
|
||||
export function conditionHolds(
|
||||
condition: FieldCondition | undefined | null,
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
|
||||
@@ -25,6 +25,9 @@ export type LicenseStatus =
|
||||
| "DRAFT"
|
||||
| "SUBMITTED"
|
||||
| "UNDER_REVIEW"
|
||||
// Seafarer registration only: same slot UNDER_REVIEW occupies elsewhere,
|
||||
// but approval is blocked until the applicant's profile has a BSID.
|
||||
| "AWAITING_BIOMETRICS"
|
||||
| "UNDER_EVALUATION"
|
||||
// Employee filed their review; parked with the team leader for a decision.
|
||||
| "REVIEW_REPORTED"
|
||||
@@ -60,7 +63,10 @@ export type LicenseStatus =
|
||||
| "EXAM_PASSED"
|
||||
| "EXAM_FAILED";
|
||||
|
||||
export type ApplicationKind = "NEW" | "RENEWAL";
|
||||
export type ApplicationKind = "NEW" | "RENEWAL" | "REISSUE";
|
||||
|
||||
/** Half-day window a team leader books an applicant's document pickup into. */
|
||||
export type IssuancePeriod = "MORNING" | "AFTERNOON";
|
||||
|
||||
export type FormFieldType =
|
||||
| "TEXT"
|
||||
@@ -117,6 +123,12 @@ export interface FormSectionConfig {
|
||||
group?: string;
|
||||
/** Position of the group in the stepper; lowest value in a group wins. */
|
||||
groupOrder?: number;
|
||||
/**
|
||||
* Restricts this section to specific application kinds — e.g. the
|
||||
* Damaged/Reissue "Damage Information" step. Undefined or empty means
|
||||
* every kind.
|
||||
*/
|
||||
applicationKinds?: ApplicationKind[];
|
||||
}
|
||||
|
||||
/** Grouping the portal organises the licence catalogue by. */
|
||||
@@ -175,6 +187,11 @@ export interface LicenseType {
|
||||
feeCurrency: string;
|
||||
capitalThreshold: string | number | null;
|
||||
validityMonths: number;
|
||||
/**
|
||||
* A term in days instead of months, for licences shorter than a month can
|
||||
* express. Wins over `validityMonths` when set; null keeps calendar months.
|
||||
*/
|
||||
validityDays?: number | null;
|
||||
/**
|
||||
* Target turnaround in hours. Null means this type is not tracked against
|
||||
* an SLA, which the grid renders as "—" rather than as instantly overdue.
|
||||
@@ -204,11 +221,39 @@ export interface LicenseType {
|
||||
// --------------------------------------------------- examined certificates
|
||||
/** Approval establishes eligibility; the certificate is earned by exam. */
|
||||
requiresExamination?: boolean;
|
||||
/** Assessment fee, due on submission before any officer review. */
|
||||
feeEligibility?: string | number | null;
|
||||
/** Fee per sitting. Falls back to `feeNewApplication` when null. */
|
||||
feeExamination?: string | number | null;
|
||||
/** Fee to issue after a pass. Falls back to `feeNewApplication` when null. */
|
||||
feeCertificate?: string | number | null;
|
||||
|
||||
// ------------------------------------------------------- behaviour config
|
||||
// Editable from the backoffice Behavior tab. Optional because the API has
|
||||
// only recently begun returning them and older mocks/fixtures omit them.
|
||||
|
||||
/** Licence or registration. Catalogue metadata; no behaviour hangs off it. */
|
||||
serviceKind?: ServiceKind;
|
||||
/** Platform side effect fired when an application of this type completes. */
|
||||
completionEffect?: CompletionEffect | null;
|
||||
/** Only ACTIVE registered seafarers may apply. */
|
||||
requiresSeafarerRegistration?: boolean;
|
||||
/** Submission requires a current, unexpired medical certificate. */
|
||||
requiresValidMedical?: boolean;
|
||||
/** Minimum VERIFIED sea time in days at submission. Null means no floor. */
|
||||
minSeaTimeDays?: number | null;
|
||||
/** Whether several open drafts may exist at once, for per-asset registrations. */
|
||||
allowMultipleOpenDrafts?: boolean;
|
||||
/** Days before expiry that renewal opens. */
|
||||
renewalWindowDays?: number;
|
||||
/** Days before expiry to remind the holder, most distant first. */
|
||||
expiryReminderDays?: number[];
|
||||
/**
|
||||
* Dotted `formData` path whose answer may appear on only one live
|
||||
* application of this type. Null means no such rule.
|
||||
*/
|
||||
uniqueFormKeyPath?: string | null;
|
||||
|
||||
// ---------------------------------------------------------- STCW mapping
|
||||
certificateCategory?: CertificateCategory | null;
|
||||
stcwControlled?: boolean;
|
||||
@@ -252,7 +297,12 @@ export interface StcwCapacityRow {
|
||||
|
||||
export interface DocumentRequirement {
|
||||
id: string;
|
||||
licenseTypeId: string;
|
||||
/**
|
||||
* Null for a personal document — one every applicant keeps in their own
|
||||
* vault regardless of what they apply for, rather than a slot on one
|
||||
* licence's application form.
|
||||
*/
|
||||
licenseTypeId: string | null;
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description?: Bilingual;
|
||||
@@ -263,6 +313,14 @@ export interface DocumentRequirement {
|
||||
maxSizeMb: number;
|
||||
requiresValidityDates: boolean;
|
||||
allowMultiple: boolean;
|
||||
/**
|
||||
* True for a personal document — one the applicant keeps in their own vault
|
||||
* — rather than an upload slot on an application form. Orthogonal to
|
||||
* `licenseTypeId`, which still says which licences it applies to.
|
||||
*/
|
||||
isPersonal: boolean;
|
||||
/** Files the slot accepts: 1, 2 for a front and back, null for unlimited. */
|
||||
maxFiles: number | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
@@ -346,6 +404,7 @@ export interface LicenseApplication {
|
||||
issuedLicenseId: string | null;
|
||||
/** Set once an officer schedules pickup for a document requiring in-person handover. */
|
||||
scheduledIssuanceDate: string | null;
|
||||
scheduledIssuancePeriod: IssuancePeriod | null;
|
||||
scheduledBy: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -437,6 +496,13 @@ export interface ApplicationApplicant {
|
||||
|
||||
export interface ApplicationDetail {
|
||||
application: LicenseApplication;
|
||||
/**
|
||||
* Current status of the license this application issued, independent of
|
||||
* the application's own (permanently historical) status — a later
|
||||
* reissue/renewal can supersede the license without changing what this
|
||||
* application itself accomplished. Null when nothing has been issued yet.
|
||||
*/
|
||||
issuedLicenseStatus: "ACTIVE" | "EXPIRED" | "SUSPENDED" | "CANCELLED" | "SUPERSEDED" | null;
|
||||
relatedApplications?: LicenseApplication[];
|
||||
/** Null when the applicant has no profile row (never expected in practice). */
|
||||
applicant: ApplicationApplicant | null;
|
||||
@@ -463,6 +529,50 @@ export interface Inspection {
|
||||
findings: string | null;
|
||||
}
|
||||
|
||||
export type PickupAppointmentStatus =
|
||||
| "SCHEDULED"
|
||||
| "CHECKED_IN"
|
||||
| "ISSUED"
|
||||
| "NO_SHOW"
|
||||
| "RESCHEDULED"
|
||||
| "CANCELLED";
|
||||
|
||||
export interface PickupOffice {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
/** 0=Sunday .. 6=Saturday. */
|
||||
workingDays: number[];
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
slotDurationMinutes: number;
|
||||
maxApplicantsPerSlot: number;
|
||||
rescheduleMinNoticeHours: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface PickupSlot {
|
||||
date: string;
|
||||
slotStartTime: string;
|
||||
capacity: number;
|
||||
booked: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
export interface PickupAppointment {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
appointmentNumber: string;
|
||||
officeId: string;
|
||||
date: string;
|
||||
slotStartTime: string;
|
||||
status: PickupAppointmentStatus;
|
||||
rescheduledFromId: string | null;
|
||||
rescheduleCount: number;
|
||||
checkedInAt: string | null;
|
||||
checkedInById: string | null;
|
||||
}
|
||||
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
subject: Bilingual;
|
||||
@@ -479,6 +589,7 @@ export interface QueueFilter {
|
||||
licenseTypeId?: string;
|
||||
search?: string;
|
||||
status?: LicenseStatus[];
|
||||
kind?: ApplicationKind;
|
||||
/** Officer uuid, or the literal 'unassigned'. */
|
||||
assignee?: string;
|
||||
submittedFrom?: string;
|
||||
@@ -501,6 +612,15 @@ export type QueueSortField =
|
||||
/** Review → evaluation → (inspection) → approval, or the short registration course. */
|
||||
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
|
||||
|
||||
/** A permission to operate, or a registration granting a status and a number. */
|
||||
export type ServiceKind = "LICENSE" | "REGISTRATION";
|
||||
|
||||
/** Platform side effect fired when an application reaches COMPLETED. */
|
||||
export type CompletionEffect =
|
||||
| "REGISTER_SEAFARER"
|
||||
| "REGISTER_VESSEL"
|
||||
| "OPEN_SEAFARER_DOCUMENTS";
|
||||
|
||||
/** Row counts behind the queue's saved-view tabs. */
|
||||
export interface QueueCounts {
|
||||
unassigned: number;
|
||||
@@ -744,10 +864,40 @@ export interface IssuedLicense {
|
||||
* configuration.
|
||||
*/
|
||||
renewable?: boolean;
|
||||
/** Whether a Damaged/Reissue replacement may be requested for this licence. */
|
||||
reissuable?: boolean;
|
||||
verificationCode: string;
|
||||
certificateFileKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One personal document as the backoffice manages it: every configured row
|
||||
* sharing a key, which is one slot in the applicant's vault. Several rows mean
|
||||
* the document is scoped to several licence types.
|
||||
*/
|
||||
export interface PersonalDocumentGroup {
|
||||
key: string;
|
||||
rows: DocumentRequirement[];
|
||||
}
|
||||
|
||||
export interface PersonalDocumentFilter {
|
||||
/** Matches the key and the name in either locale. */
|
||||
search?: string;
|
||||
/** A licence type also matches the documents every licence asks for. */
|
||||
licenseTypeId?: string;
|
||||
/** Narrows to the documents configured against no licence type at all. */
|
||||
globalOnly?: boolean;
|
||||
sortBy?: 'sortOrder' | 'key' | 'name';
|
||||
sortDir?: 'ASC' | 'DESC';
|
||||
take?: number;
|
||||
skip?: number;
|
||||
/** Which locale `sortBy: "name"` sorts on. */
|
||||
locale?: 'en' | 'am';
|
||||
}
|
||||
|
||||
// No `EligibleExam`: it typed the schedule-exam picker's options, and
|
||||
// picking a sitting for a named applicant is not something the back office
|
||||
// does any more — candidates register for a published sitting themselves.
|
||||
|
||||
/**
|
||||
* Where a certificate application stands in the examination leg, derived
|
||||
|
||||
3
libs/api/src/lib/features/personal-document/index.ts
Normal file
3
libs/api/src/lib/features/personal-document/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './personal-document.types';
|
||||
export * from './personal-document.upload';
|
||||
export * from './personal-document-api';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type { PersonalDocumentSlot } from './personal-document.types';
|
||||
|
||||
const TAG = 'PersonalDocument' as const;
|
||||
const LIST = { type: TAG, id: 'LIST' } as const;
|
||||
|
||||
/**
|
||||
* The applicant's own documents — identity card, photograph, education —
|
||||
* kept against their profile rather than any one application, so they survive
|
||||
* having no seafarer registration yet.
|
||||
*
|
||||
* Reads and deletes live here; the two uploads do not. `fetch` — what
|
||||
* `fetchBaseQuery` runs on — cannot report how much of a request body has gone
|
||||
* up, so they use XHR instead (`personal-document.upload.ts`) and the page
|
||||
* refetches this query when one finishes.
|
||||
*/
|
||||
export const personalDocumentApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: [TAG] })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getMyPersonalDocuments: builder.query<{ slots: PersonalDocumentSlot[] }, void>({
|
||||
query: () => ({ url: '/profiles/me/documents' }),
|
||||
providesTags: () => [LIST],
|
||||
}),
|
||||
|
||||
deletePersonalDocumentFile: builder.mutation<{ deleted: boolean }, string>({
|
||||
query: (fileId) => ({
|
||||
url: `/profiles/me/documents/files/${fileId}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetMyPersonalDocumentsQuery,
|
||||
useDeletePersonalDocumentFileMutation,
|
||||
} = personalDocumentApi;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AttachmentFile, Bilingual } from '../licensing/licensing.types';
|
||||
|
||||
/**
|
||||
* One slot in the applicant's personal document vault, with whatever they
|
||||
* have put in it.
|
||||
*
|
||||
* The slot itself is configuration: a document requirement that names no
|
||||
* licence type applies to every licence, so the backoffice adds and retires
|
||||
* these without a release. That is why the label, the accepted types and the
|
||||
* limits arrive from the API rather than living in the portal.
|
||||
*/
|
||||
export interface PersonalDocumentSlot {
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description: Bilingual | null;
|
||||
/** How many files the slot holds; null means as many as the holder has. */
|
||||
maxFiles: number | null;
|
||||
allowedMimeTypes: string[];
|
||||
maxSizeMb: number;
|
||||
sortOrder: number;
|
||||
files: AttachmentFile[];
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import type { PersonalDocumentSlot } from './personal-document.types';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
|
||||
/** What the server says when it refuses a file — see ProfileDocumentsService. */
|
||||
export interface PersonalDocumentError {
|
||||
message?: string;
|
||||
[detail: string]: unknown;
|
||||
}
|
||||
|
||||
export type PersonalDocumentUploadResult =
|
||||
| { ok: true; slot: PersonalDocumentSlot }
|
||||
| { ok: false; error: PersonalDocumentError };
|
||||
|
||||
/**
|
||||
* Uploads one file and reports how far it has got.
|
||||
*
|
||||
* XHR rather than `fetch`, and therefore outside RTK Query: `fetch` has no
|
||||
* upload progress event, so a request body of any size is a spinner with
|
||||
* nothing behind it. That is tolerable for a 5 MB scan and not for the video a
|
||||
* slot can now be opened to, where the difference between "uploading" and
|
||||
* "uploading, 12%" is the difference between waiting and reloading the page.
|
||||
*
|
||||
* The caller refetches the vault afterwards; nothing here touches the cache.
|
||||
*/
|
||||
function upload(
|
||||
path: string,
|
||||
method: 'POST' | 'PUT',
|
||||
body: FormData,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<PersonalDocumentUploadResult> {
|
||||
return new Promise((resolve) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open(method, `${BASE_API_URL}${path}`);
|
||||
|
||||
const token = resolveTokenFromStorage();
|
||||
if (token) request.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
|
||||
request.upload.onprogress = (event) => {
|
||||
// Not every browser knows the total for a streamed body; without it a
|
||||
// percentage would be invented, so the caller keeps its spinner.
|
||||
if (!event.lengthComputable || !onProgress) return;
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
|
||||
request.onload = () => {
|
||||
const parsed = parseBody(request.responseText);
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
resolve({ ok: true, slot: parsed as PersonalDocumentSlot });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: false, error: toError(parsed, request.status) });
|
||||
};
|
||||
|
||||
// A dropped connection and a cancelled request both land here; neither
|
||||
// carries a server message, so the caller falls back to its own wording.
|
||||
request.onerror = () => resolve({ ok: false, error: {} });
|
||||
request.onabort = () => resolve({ ok: false, error: {} });
|
||||
|
||||
request.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
function parseBody(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nest wraps a thrown `BadRequestException({ message, ... })` as
|
||||
* `{ message: { message, ... } }`, and a plain string message as
|
||||
* `{ message: "slot_full" }`. Both are flattened to the object the UI
|
||||
* translates by its `message` key.
|
||||
*/
|
||||
function toError(parsed: unknown, status: number): PersonalDocumentError {
|
||||
const message = (parsed as { message?: unknown } | null)?.message;
|
||||
if (typeof message === 'object' && message !== null) {
|
||||
return message as PersonalDocumentError;
|
||||
}
|
||||
if (typeof message === 'string') return { message };
|
||||
return { message: `http_${status}` };
|
||||
}
|
||||
|
||||
/** Adds one file to a personal document slot. */
|
||||
export function uploadPersonalDocumentFile(params: {
|
||||
documentKey: string;
|
||||
file: File;
|
||||
onProgress?: (percent: number) => void;
|
||||
}): Promise<PersonalDocumentUploadResult> {
|
||||
const body = new FormData();
|
||||
body.append('documentKey', params.documentKey);
|
||||
body.append('file', params.file, params.file.name);
|
||||
return upload('/profiles/me/documents', 'POST', body, params.onProgress);
|
||||
}
|
||||
|
||||
/** Swaps one file for another in the same slot. */
|
||||
export function replacePersonalDocumentFile(params: {
|
||||
fileId: string;
|
||||
file: File;
|
||||
onProgress?: (percent: number) => void;
|
||||
}): Promise<PersonalDocumentUploadResult> {
|
||||
const body = new FormData();
|
||||
body.append('file', params.file, params.file.name);
|
||||
return upload(
|
||||
`/profiles/me/documents/files/${params.fileId}`,
|
||||
'PUT',
|
||||
body,
|
||||
params.onProgress,
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SeafarerDocument,
|
||||
SeafarerDocumentDetail,
|
||||
SeafarerDocumentKind,
|
||||
SeafarerDocumentRequestKind,
|
||||
SeafarerDocumentRow,
|
||||
SeafarerDocumentStatus,
|
||||
} from './seafarer-document.types';
|
||||
@@ -14,6 +15,7 @@ const item = (id: string) => ({ type: TAG, id }) as const;
|
||||
|
||||
export interface SeafarerDocumentListFilter {
|
||||
kind?: SeafarerDocumentKind;
|
||||
requestKind?: SeafarerDocumentRequestKind;
|
||||
status?: SeafarerDocumentStatus;
|
||||
search?: string;
|
||||
take?: number;
|
||||
@@ -63,6 +65,16 @@ export const seafarerDocumentApi = baseApi
|
||||
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
renewSeafarerDocument: builder.mutation<SeafarerDocument, string>({
|
||||
query: (id) => ({ url: `/seafarer-documents/${id}/renew`, method: 'POST' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
|
||||
replaceSeafarerDocument: builder.mutation<SeafarerDocument, string>({
|
||||
query: (id) => ({ url: `/seafarer-documents/${id}/replace`, method: 'POST' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
|
||||
// --------------------------------------------------------------- review
|
||||
listSeafarerDocuments: builder.query<
|
||||
{ total: number; items: SeafarerDocumentRow[] },
|
||||
@@ -121,6 +133,8 @@ export const {
|
||||
useInitiateDocumentPaymentMutation,
|
||||
useGetDocumentPaymentQuery,
|
||||
useBypassDocumentPaymentMutation,
|
||||
useRenewSeafarerDocumentMutation,
|
||||
useReplaceSeafarerDocumentMutation,
|
||||
useListSeafarerDocumentsQuery,
|
||||
useGetSeafarerDocumentReviewQuery,
|
||||
useLazyGetSeafarerDocumentReviewDownloadQuery,
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import type { SeafarerDocumentKind, SeafarerDocumentStatus } from './seafarer-document.types';
|
||||
import type {
|
||||
SeafarerDocumentKind,
|
||||
SeafarerDocumentRequestKind,
|
||||
SeafarerDocumentStatus,
|
||||
} from './seafarer-document.types';
|
||||
|
||||
export const SEAFARER_DOCUMENT_KIND_LABELS: Record<SeafarerDocumentKind, string> = {
|
||||
SEAMAN_BOOK: 'Seaman Book',
|
||||
BTC_BASIC_TRAINING: 'Basic Training Certificate',
|
||||
};
|
||||
|
||||
export const SEAFARER_DOCUMENT_REQUEST_KIND_LABELS: Record<SeafarerDocumentRequestKind, string> = {
|
||||
NEW: 'New',
|
||||
RENEWAL: 'Renewal',
|
||||
REPLACEMENT: 'Replacement',
|
||||
};
|
||||
|
||||
export const SEAFARER_DOCUMENT_REQUEST_KIND_COLORS: Record<SeafarerDocumentRequestKind, string> = {
|
||||
NEW: 'gray',
|
||||
RENEWAL: 'blue',
|
||||
REPLACEMENT: 'orange',
|
||||
};
|
||||
|
||||
export const SEAFARER_DOCUMENT_STATUS_LABELS: Record<SeafarerDocumentStatus, string> = {
|
||||
AWAITING_REGISTRATION: 'Awaiting Registration',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
|
||||
@@ -12,14 +12,19 @@ export type SeafarerDocumentStatus =
|
||||
| 'REJECTED'
|
||||
| 'CANCELLED';
|
||||
|
||||
/** A Seaman Book or BTC request — opened by a seafarer registration. */
|
||||
/** NEW comes from a seafarer registration; RENEWAL/REPLACEMENT are applicant-initiated. */
|
||||
export type SeafarerDocumentRequestKind = 'NEW' | 'RENEWAL' | 'REPLACEMENT';
|
||||
|
||||
/** A Seaman Book or BTC request — opened by a seafarer registration, or by the applicant as a renewal/replacement. */
|
||||
export interface SeafarerDocument {
|
||||
id: string;
|
||||
kind: SeafarerDocumentKind;
|
||||
requestKind: SeafarerDocumentRequestKind;
|
||||
requestNumber: string;
|
||||
applicantUserId: string;
|
||||
profileId: string | null;
|
||||
seafarerRegistrationId: string | null;
|
||||
previousDocumentId: string | null;
|
||||
status: SeafarerDocumentStatus;
|
||||
feeAmount: number | null;
|
||||
feeCurrency: string;
|
||||
|
||||
@@ -10,9 +10,17 @@ const TAG = 'SeafarerRegistration' as const;
|
||||
const LIST = { type: TAG, id: 'LIST' } as const;
|
||||
const item = (id: string) => ({ type: TAG, id }) as const;
|
||||
|
||||
export type SeafarerRegistrationSortField =
|
||||
| 'submittedAt'
|
||||
| 'registrationNumber'
|
||||
| 'lastName'
|
||||
| 'status';
|
||||
|
||||
export interface SeafarerRegistrationListFilter {
|
||||
status?: SeafarerRegistrationStatus;
|
||||
search?: string;
|
||||
sortBy?: SeafarerRegistrationSortField;
|
||||
sortDir?: 'ASC' | 'DESC';
|
||||
take?: number;
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,19 @@ export const DEPARTMENT_OPTIONS = [
|
||||
{ value: 'CATERING', label: 'Catering' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The STCW limitation a Certificate of Competency is issued under.
|
||||
*
|
||||
* One choice, worded generically, because the threshold it means differs by
|
||||
* department — gross tonnage on deck, propulsion power in the engine room. The
|
||||
* certificate states the department-specific wording; the applicant only picks
|
||||
* which side of the line they serve.
|
||||
*/
|
||||
export const RANK_TIER_OPTIONS = [
|
||||
{ value: 'ABOVE', label: 'Above' },
|
||||
{ value: 'BELOW', label: 'Below' },
|
||||
];
|
||||
|
||||
export const HAIR_COLOR_OPTIONS = [
|
||||
{ value: 'BLACK', label: 'Black' },
|
||||
{ value: 'BROWN', label: 'Brown' },
|
||||
@@ -117,6 +130,8 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
||||
export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
RESUBMIT_REQUIRED: 'Corrections Requested',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
@@ -139,6 +154,8 @@ export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
|
||||
> = {
|
||||
DRAFT: 'neutral',
|
||||
SUBMITTED: 'info',
|
||||
AWAITING_BIOMETRICS: 'pending',
|
||||
UNDER_REVIEW: 'info',
|
||||
RESUBMIT_REQUIRED: 'pending',
|
||||
APPROVED: 'success',
|
||||
REJECTED: 'danger',
|
||||
@@ -158,6 +175,7 @@ export const SEAFARER_REGISTRATION_FIELD_LABELS: Record<keyof SeafarerRegistrati
|
||||
passportNumber: 'Passport Number',
|
||||
passportExpiry: 'Passport Expiry Date',
|
||||
department: 'Department',
|
||||
tier: 'Certificate Limitation',
|
||||
locationId: 'Location',
|
||||
permanentAddress: 'Permanent Address',
|
||||
currentAddress: 'Current Address',
|
||||
@@ -192,7 +210,7 @@ export const SEAFARER_REGISTRATION_SECTIONS: {
|
||||
{
|
||||
key: 'identity',
|
||||
title: 'Identity',
|
||||
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department'],
|
||||
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department', 'tier'],
|
||||
},
|
||||
{
|
||||
key: 'address',
|
||||
@@ -220,6 +238,7 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
|
||||
gender: GENDER_OPTIONS,
|
||||
maritalStatus: MARITAL_STATUS_OPTIONS,
|
||||
department: DEPARTMENT_OPTIONS,
|
||||
tier: RANK_TIER_OPTIONS,
|
||||
hairColor: HAIR_COLOR_OPTIONS,
|
||||
eyeColor: EYE_COLOR_OPTIONS,
|
||||
bloodType: BLOOD_TYPE_OPTIONS,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
|
||||
|
||||
/** Above/Below — the STCW limitation a Certificate of Competency is issued under. */
|
||||
export type RankTier = 'ABOVE' | 'BELOW';
|
||||
|
||||
export type SeafarerRegistrationStatus =
|
||||
| 'DRAFT'
|
||||
// Filed, waiting on the counter-side biometric capture that produces the
|
||||
// BSID approval is blocked on.
|
||||
| 'AWAITING_BIOMETRICS'
|
||||
// BSID issued — the file is a reviewer's to decide.
|
||||
| 'UNDER_REVIEW'
|
||||
// Retained for registrations filed before biometrics moved ahead of review.
|
||||
| 'SUBMITTED'
|
||||
| 'RESUBMIT_REQUIRED'
|
||||
| 'APPROVED'
|
||||
@@ -30,6 +39,11 @@ export interface SeafarerRegistrationAnswers {
|
||||
passportNumber: string | null;
|
||||
passportExpiry: string | null;
|
||||
department: SeafarerDepartment | null;
|
||||
/**
|
||||
* The STCW ship-size limitation this seafarer's CoCs are issued under.
|
||||
* Read with `department` to pick the CoC ladder; CoP carries no tier.
|
||||
*/
|
||||
tier: RankTier | null;
|
||||
locationId: string | null;
|
||||
permanentAddress: string | null;
|
||||
currentAddress: string | null;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export const SESSION_HEADER_KEYS = {
|
||||
tenantId: 'x-tenant-id',
|
||||
organizationUnitId: 'x-organization-unit-id',
|
||||
currentPositionId: 'x-current-position-id',
|
||||
currentProjectId: 'x-current-project-id',
|
||||
tenantId: "x-tenant-id",
|
||||
organizationUnitId: "x-organization-unit-id",
|
||||
currentPositionId: "x-current-position-id",
|
||||
currentProjectId: "x-current-project-id",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Which app this bundle is, so it reads its own session and no one else's.
|
||||
*
|
||||
* Set by each app's store via `configureSessionScope`. Cookies ignore the
|
||||
* port, so `localhost:4200` and `localhost:4201` share one jar: without a
|
||||
* port, so `localhost:3000` and `localhost:4201` share one jar: without a
|
||||
* scope the backoffice would happily authenticate as whoever last signed into
|
||||
* the portal, and render a staff console with an applicant's permissions.
|
||||
*/
|
||||
@@ -22,7 +22,7 @@ export function configureSessionScope(prefix: string): void {
|
||||
}
|
||||
|
||||
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
|
||||
const LEGACY_TOKEN_KEY = 'auth-token';
|
||||
const LEGACY_TOKEN_KEY = "auth-token";
|
||||
|
||||
export function resolveTokenFromStorage(): string | undefined {
|
||||
// Only this app's key, then the legacy unprefixed one. Never another app's:
|
||||
|
||||
@@ -6,6 +6,7 @@ export { AuthBootstrap } from "./lib/components/AuthBootstrap";
|
||||
export { useIdleTimer } from "./lib/hooks/useIdleTimer";
|
||||
export { LoginPage } from "./lib/pages/LoginPage";
|
||||
export { SignupPage } from "./lib/pages/SignupPage";
|
||||
export { FaydaCallbackPage } from "./lib/pages/FaydaCallbackPage";
|
||||
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
|
||||
export { SetPasswordPage } from "./lib/pages/SetPasswordPage";
|
||||
export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage";
|
||||
|
||||
@@ -6,9 +6,7 @@ import { hydrateAuth, logout, setToken, setUser } from '../store/auth.slice';
|
||||
import { refreshAccessToken } from '../utils/refresh-token';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
import { BASE_API_URL } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Restores the signed-in session before the router renders.
|
||||
|
||||
121
libs/auth/src/lib/pages/FaydaCallbackPage.tsx
Normal file
121
libs/auth/src/lib/pages/FaydaCallbackPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Button, Group, Loader, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { useErrorHandler } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { faydaSession, type FaydaResult } from '../utils/fayda-session';
|
||||
|
||||
/**
|
||||
* Where Fayda returns the applicant.
|
||||
*
|
||||
* It creates no account and holds no credentials — it hands the authorization
|
||||
* code to the API, stashes the normalised result, and sends the applicant back
|
||||
* to the signup form they started on.
|
||||
*/
|
||||
export function FaydaCallbackPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const { handleError } = useErrorHandler();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [callbackTrigger] = useApiMutation<FaydaResult>();
|
||||
|
||||
// React 18 mounts effects twice in development, and the authorization code is
|
||||
// single-use — the second redemption would fail and show a spurious error.
|
||||
const redeemed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (redeemed.current) return;
|
||||
redeemed.current = true;
|
||||
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const providerError = params.get('error');
|
||||
const request = faydaSession.takeRequest();
|
||||
|
||||
if (providerError) {
|
||||
setError(
|
||||
providerError === 'access_denied'
|
||||
? t('fayda.cancelled', 'Fayda verification was cancelled. You can still sign up manually.')
|
||||
: t('fayda.rejected', 'Fayda could not verify your identity. Please try again.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setError(t('fayda.invalidCallback', 'This verification link is incomplete. Please start again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
setError(
|
||||
t('fayda.sessionLost', 'Your verification session has expired. Please start again.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.state !== state) {
|
||||
setError(t('fayda.stateMismatch', 'This verification could not be trusted. Please start again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
callbackTrigger({
|
||||
url: '/auth/register-with-fayda',
|
||||
method: 'POST',
|
||||
// `verify` returns the identity without creating an account — the
|
||||
// existing signup endpoint still does that.
|
||||
body: { action: 'verify', code, state, transactionToken: request.transactionToken },
|
||||
})
|
||||
.unwrap()
|
||||
.then((result) => {
|
||||
faydaSession.saveResult(result);
|
||||
// replace: the callback URL carries a spent code, so it must not come
|
||||
// back on Back.
|
||||
navigate('/signup', { replace: true });
|
||||
})
|
||||
.catch((err: unknown) => setError(handleError(err)));
|
||||
// Runs once on mount; the guard above makes that explicit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle={t('fayda.brandTitle', 'Verifying with Fayda')}
|
||||
brandSubtitle={t('fayda.brandSubtitle', 'One moment while we confirm your identity.')}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{error ? (
|
||||
<>
|
||||
<Title order={2} fz={26}>
|
||||
{t('fayda.failedTitle', 'Verification incomplete')}
|
||||
</Title>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconArrowLeft size={18} />}
|
||||
onClick={() => navigate('/signup', { replace: true })}
|
||||
>
|
||||
{t('fayda.backToSignup', 'Back to sign up')}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Group gap="sm">
|
||||
<Loader size="sm" />
|
||||
<Text c="dimmed">{t('fayda.verifying', 'Verifying your Fayda identity…')}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Divider,
|
||||
PasswordInput,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -14,11 +15,14 @@ import {
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconAt,
|
||||
IconId,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconRosetteDiscountCheck,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -33,6 +37,7 @@ import { AuthShell } from '../components/AuthShell';
|
||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
import { faydaSession, type FaydaResult } from '../utils/fayda-session';
|
||||
|
||||
interface SignupPayload {
|
||||
email: string;
|
||||
@@ -62,6 +67,56 @@ export function SignupPage() {
|
||||
}>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
|
||||
// Fayda is optional: the form below works exactly as before without it.
|
||||
const [fayda, setFayda] = useState<FaydaResult | null>(() => faydaSession.peekResult());
|
||||
const [faydaStarting, setFaydaStarting] = useState(false);
|
||||
const [startTrigger] = useApiMutation<{
|
||||
authorizationUrl: string;
|
||||
state: string;
|
||||
transactionToken: string;
|
||||
expiresIn: number;
|
||||
}>();
|
||||
const [linkTrigger] = useApiMutation<{ phoneNumberVerified?: boolean }>();
|
||||
|
||||
const verified = (field: string) => fayda?.verifiedFields.includes(field) ?? false;
|
||||
const conflicted = (field: string) => fayda?.conflicts.includes(field) ?? false;
|
||||
|
||||
/**
|
||||
* Per-field provenance, so it is obvious which values came from Fayda and
|
||||
* which are still the applicant's to supply. Verified fields stay editable —
|
||||
* a conflicting email has to be changeable for the form to be completable at
|
||||
* all.
|
||||
*/
|
||||
const faydaMark = (field: string): { description?: React.ReactNode } => {
|
||||
if (conflicted(field)) {
|
||||
return {
|
||||
// component="span" on these badges: the description slot renders
|
||||
// inside a <p>, where Badge's default <div> is invalid HTML.
|
||||
description: (
|
||||
<Badge component="span" size="xs" variant="light" color="orange">
|
||||
{t('fayda.fieldConflict', 'Already used by another account')}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (verified(field)) {
|
||||
return {
|
||||
description: (
|
||||
<Badge
|
||||
component="span"
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<IconRosetteDiscountCheck size={11} />}
|
||||
>
|
||||
{t('fayda.fieldVerified', 'From Fayda')}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
@@ -118,6 +173,42 @@ export function SignupPage() {
|
||||
defaultValues: { userType: 'individual' },
|
||||
});
|
||||
|
||||
// Fills what Fayda vouched for and leaves the rest — username and password
|
||||
// are always the applicant's to choose, and Fayda supplies neither.
|
||||
useEffect(() => {
|
||||
if (!fayda) return;
|
||||
const { email, phoneNumber: phone, nameEn, nameAm } = fayda.identity;
|
||||
if (email) setValue('email', email);
|
||||
if (phone) setValue('phoneNumber', phone);
|
||||
if (nameEn) setValue('nameEn', nameEn);
|
||||
if (nameAm) setValue('nameAm', nameAm);
|
||||
}, [fayda, setValue]);
|
||||
|
||||
const startFayda = async () => {
|
||||
setServerError(null);
|
||||
setFaydaStarting(true);
|
||||
try {
|
||||
// Same endpoint the registration itself uses; `start` only opens the
|
||||
// attempt and hands back where to send the user.
|
||||
const { authorizationUrl, transactionToken, state } = await startTrigger({
|
||||
url: '/auth/register-with-fayda',
|
||||
method: 'POST',
|
||||
body: { action: 'start' },
|
||||
}).unwrap();
|
||||
|
||||
faydaSession.saveRequest({ transactionToken, state });
|
||||
window.location.assign(authorizationUrl);
|
||||
} catch (err: unknown) {
|
||||
setFaydaStarting(false);
|
||||
setServerError(handleError(err));
|
||||
}
|
||||
};
|
||||
|
||||
const clearFayda = () => {
|
||||
faydaSession.clearResult();
|
||||
setFayda(null);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const payload: SignupPayload = {
|
||||
@@ -147,7 +238,28 @@ export function SignupPage() {
|
||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
if (data.isPhoneNumberVerified) {
|
||||
// Records the Fayda-verified identity on the new account: marks the
|
||||
// phone verified when it is the one Fayda vouched for, and fills the
|
||||
// still-empty profile fields. Best-effort — the account already works,
|
||||
// and the token can be presented again on a retry.
|
||||
let faydaPhoneVerified = false;
|
||||
if (fayda?.verificationToken) {
|
||||
try {
|
||||
const applied = await linkTrigger({
|
||||
url: '/profiles/me/fayda',
|
||||
method: 'POST',
|
||||
body: { verificationToken: fayda.verificationToken },
|
||||
}).unwrap();
|
||||
faydaPhoneVerified = Boolean(applied?.phoneNumberVerified);
|
||||
} catch {
|
||||
/* deliberately ignored — signup already succeeded */
|
||||
}
|
||||
}
|
||||
faydaSession.clearResult();
|
||||
|
||||
// Fayda already verified this exact number via its own OTP; asking for
|
||||
// a second OTP on the same number is theatre.
|
||||
if (data.isPhoneNumberVerified || faydaPhoneVerified) {
|
||||
navigate(loginRedirectPath);
|
||||
} else {
|
||||
navigate('/otp-verify', {
|
||||
@@ -212,6 +324,53 @@ export function SignupPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fayda ? (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="teal"
|
||||
icon={<IconRosetteDiscountCheck size={18} />}
|
||||
title={t('fayda.verifiedTitle', 'Verified with Fayda')}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'fayda.verifiedBody',
|
||||
'We filled in the details Fayda confirmed. Please complete the remaining fields.',
|
||||
)}
|
||||
</Text>
|
||||
<Anchor size="sm" component="button" type="button" onClick={clearFayda}>
|
||||
{t('fayda.discard', 'Clear these details and fill the form myself')}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="md"
|
||||
fullWidth
|
||||
loading={faydaStarting}
|
||||
leftSection={<IconId size={18} />}
|
||||
onClick={startFayda}
|
||||
>
|
||||
{t('fayda.continueWith', 'Continue with Fayda')}
|
||||
</Button>
|
||||
<Divider
|
||||
label={t('fayda.orFillManually', 'or fill in your details')}
|
||||
labelPosition="center"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{fayda && fayda.conflicts.length > 0 && (
|
||||
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={18} />}>
|
||||
{t(
|
||||
'fayda.conflictBody',
|
||||
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
@@ -220,6 +379,7 @@ export function SignupPage() {
|
||||
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameEn?.message}
|
||||
{...faydaMark('nameEn')}
|
||||
{...register('nameEn')}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -227,6 +387,7 @@ export function SignupPage() {
|
||||
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameAm?.message}
|
||||
{...faydaMark('nameAm')}
|
||||
{...register('nameAm')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
@@ -237,6 +398,7 @@ export function SignupPage() {
|
||||
placeholder={t('signup.emailPlaceholder', 'you@example.com')}
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={errors.email?.message}
|
||||
{...faydaMark('email')}
|
||||
{...register('email')}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -255,6 +417,7 @@ export function SignupPage() {
|
||||
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
|
||||
onBlur={() => trigger('phoneNumber')}
|
||||
error={errors.phoneNumber?.message}
|
||||
{...faydaMark('phoneNumber')}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
|
||||
@@ -47,6 +47,8 @@ export const LICENSE_PERMISSIONS = {
|
||||
MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status",
|
||||
VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry",
|
||||
VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records",
|
||||
ENROLL_BIOMETRICS: "can:enroll:biometrics",
|
||||
VIEW_BIOMETRICS: "can:View:biometrics",
|
||||
VIEW_VESSEL_REGISTRY: "can:View:vessel-registry",
|
||||
MANAGE_VESSEL_STATUS: "can:manage:vessel-status",
|
||||
APPROVE_QUESTION: "can:approve:exam-question",
|
||||
@@ -81,6 +83,7 @@ export const PORTAL_PERMISSIONS = {
|
||||
APPLY_EXAM: "can:apply:exam",
|
||||
VIEW_OWN_EXAM: "can:View:own-exam",
|
||||
VIEW_OWN_CERTIFICATES: "can:View:own-certificates",
|
||||
VIEW_OWN_BIOMETRICS: "can:View:own-biometrics",
|
||||
APPLY_VESSEL_REGISTRATION: "can:apply:vessel-registration",
|
||||
VIEW_OWN_VESSELS: "can:View:own-vessels",
|
||||
REPORT_VESSEL_INCIDENT: "can:report:own-vessel-incident",
|
||||
|
||||
@@ -84,6 +84,8 @@ export interface CurrentProfile {
|
||||
* registration is approved, null before that.
|
||||
*/
|
||||
seafarerNumber?: string | null;
|
||||
/** Biometric Subject ID — stamped by staff once biometric enrollment is confirmed. */
|
||||
bsid?: string | null;
|
||||
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
|
||||
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
|
||||
seafarerStatusReason?: string | null;
|
||||
|
||||
87
libs/auth/src/lib/utils/fayda-session.ts
Normal file
87
libs/auth/src/lib/utils/fayda-session.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The Fayda round trip leaves the app entirely, so the little state that has to
|
||||
* survive it lives in sessionStorage: same tab, same origin, gone when the tab
|
||||
* closes.
|
||||
*
|
||||
* Nothing secret is kept here. The `transactionToken` is signed by the API and
|
||||
* useless without it — the PKCE verifier, the nonce and the client key never
|
||||
* leave the backend.
|
||||
*/
|
||||
|
||||
const REQUEST_KEY = 'fayda:request';
|
||||
const RESULT_KEY = 'fayda:result';
|
||||
|
||||
export interface FaydaRequest {
|
||||
transactionToken: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface FaydaPrefill {
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
nameEn?: string;
|
||||
nameAm?: string;
|
||||
/** Shown for context only — the signup form has no field for these. */
|
||||
gender?: string;
|
||||
address?: string;
|
||||
birthdate?: string;
|
||||
nationality?: string;
|
||||
faydaNumber?: string;
|
||||
}
|
||||
|
||||
/** Shape of `POST /auth/register-with-fayda` with `action: "verify"`. */
|
||||
export interface FaydaResult {
|
||||
identity: FaydaPrefill;
|
||||
faydaVerified: boolean;
|
||||
/** Signup fields Fayda vouched for. */
|
||||
verifiedFields: string[];
|
||||
/** Prefilled fields already taken by another account. */
|
||||
conflicts: string[];
|
||||
/**
|
||||
* Encrypted proof of the verification, presented to POST /profiles/me/fayda
|
||||
* after signup so the account and profile record what Fayda vouched for.
|
||||
*/
|
||||
verificationToken: string;
|
||||
}
|
||||
|
||||
// Private browsing and locked-down browsers can throw on access, and a failure
|
||||
// here should degrade to "no Fayda prefill", never break the signup page.
|
||||
function read<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: unknown): void {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* nothing to do — the flow reports a generic failure instead */
|
||||
}
|
||||
}
|
||||
|
||||
function clear(key: string): void {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export const faydaSession = {
|
||||
saveRequest: (request: FaydaRequest) => write(REQUEST_KEY, request),
|
||||
takeRequest: (): FaydaRequest | null => {
|
||||
const request = read<FaydaRequest>(REQUEST_KEY);
|
||||
// Single use: a stale token would otherwise be replayed against a fresh
|
||||
// callback and fail with a confusing "session expired".
|
||||
clear(REQUEST_KEY);
|
||||
return request;
|
||||
},
|
||||
|
||||
saveResult: (result: FaydaResult) => write(RESULT_KEY, result),
|
||||
peekResult: (): FaydaResult | null => read<FaydaResult>(RESULT_KEY),
|
||||
clearResult: () => clear(RESULT_KEY),
|
||||
};
|
||||
@@ -1,9 +1,6 @@
|
||||
import { authStorage } from "./auth-storage";
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.[
|
||||
"VITE_BASE_API_URL"
|
||||
] ?? "http://localhost:3000/api";
|
||||
import { BASE_API_URL } from "@ema-platform/api";
|
||||
|
||||
interface RefreshResponse {
|
||||
token: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./lib/input/BilingualInput";
|
||||
export * from "./lib/input/AmharicDatePicker";
|
||||
export * from "./lib/feedback/ConfirmModal";
|
||||
export * from "./lib/feedback/PdfPreviewModal";
|
||||
export * from "./lib/feedback/FilePreviewModal";
|
||||
export * from "./lib/feedback/ModalFooter";
|
||||
export * from "./lib/feedback/ApiErrorAlert";
|
||||
export * from "./lib/feedback/notify";
|
||||
@@ -24,6 +25,8 @@ export * from "./lib/layout/SkipLink";
|
||||
export * from "./lib/input/PasswordRequirements";
|
||||
export * from "./lib/input/CountrySelect";
|
||||
export * from "./lib/input/PhoneInput";
|
||||
export * from "./lib/input/canvas-point";
|
||||
export * from "./lib/components/SignaturePad";
|
||||
export * from "./lib/input/phone";
|
||||
export * from "./lib/data/AdvancedTable";
|
||||
export * from "./lib/data/WaitingFor";
|
||||
|
||||
316
libs/ui/src/lib/components/SignaturePad.tsx
Normal file
316
libs/ui/src/lib/components/SignaturePad.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconWriting,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '../feedback/notify';
|
||||
import { useErrorHandler } from '../feedback/use-error-handler';
|
||||
import { toCanvasPoint } from '../input/canvas-point';
|
||||
|
||||
/** Mirrors the API's own limits (`ProfileService.saveSignature`). */
|
||||
const ACCEPTED = ['image/png', 'image/jpeg'];
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The drawing surface's backing-store size.
|
||||
*
|
||||
* Fixed rather than matched to the rendered element: this is what gets printed
|
||||
* on a Seaman Book, so the stored image must not vary with the width of the
|
||||
* browser window it happened to be drawn in. The canvas is displayed at
|
||||
* whatever width the layout gives it and scaled to these dimensions.
|
||||
*/
|
||||
const PAD_WIDTH = 800;
|
||||
const PAD_HEIGHT = 260;
|
||||
|
||||
/**
|
||||
* Captures a specimen signature, drawn or uploaded.
|
||||
*
|
||||
* Drawing is a plain canvas with pointer events — one element and ~40 lines,
|
||||
* where a signature-pad dependency would be a package to keep patched. Pointer
|
||||
* events (not mouse + touch separately) cover mouse, finger and stylus in one
|
||||
* set of handlers.
|
||||
*/
|
||||
export interface SignaturePadProps {
|
||||
/** Short-lived link to the signature on file, or null when there is none. */
|
||||
currentUrl: string | null;
|
||||
isLoading: boolean;
|
||||
isUploading: boolean;
|
||||
isDeleting: boolean;
|
||||
onUpload: (file: File) => Promise<unknown>;
|
||||
onDelete: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function SignaturePad({
|
||||
currentUrl,
|
||||
isLoading,
|
||||
isUploading,
|
||||
isDeleting,
|
||||
onUpload,
|
||||
onDelete,
|
||||
}: SignaturePadProps) {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const drawing = useRef(false);
|
||||
// Whether anything has actually been drawn — a blank canvas still encodes to
|
||||
// a valid PNG, so without this "Save" would happily store an empty image.
|
||||
const [hasInk, setHasInk] = useState(false);
|
||||
const [mode, setMode] = useState<'draw' | 'upload'>('draw');
|
||||
|
||||
const busy = isUploading || isDeleting;
|
||||
|
||||
const context = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = '#111';
|
||||
return ctx;
|
||||
}, []);
|
||||
|
||||
// The stored signature is flattened onto white before upload, so a canvas
|
||||
// left transparent would print as a black box on some renderers.
|
||||
const clear = useCallback(() => {
|
||||
const ctx = context();
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, PAD_WIDTH, PAD_HEIGHT);
|
||||
setHasInk(false);
|
||||
}, [context]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'draw') clear();
|
||||
}, [mode, clear]);
|
||||
|
||||
const pointAt = (event: React.PointerEvent<HTMLCanvasElement>) =>
|
||||
toCanvasPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
{ width: PAD_WIDTH, height: PAD_HEIGHT },
|
||||
);
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const ctx = context();
|
||||
if (!ctx) return;
|
||||
// Keeps strokes tracking the pointer when it leaves the canvas mid-signature
|
||||
// rather than ending the line at the edge.
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drawing.current = true;
|
||||
const { x, y } = pointAt(event);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
// A tap with no movement should still leave a mark (a dot on an "i").
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
setHasInk(true);
|
||||
};
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawing.current) return;
|
||||
const ctx = context();
|
||||
if (!ctx) return;
|
||||
const { x, y } = pointAt(event);
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
drawing.current = false;
|
||||
};
|
||||
|
||||
const save = async (file: File) => {
|
||||
try {
|
||||
await onUpload(file);
|
||||
notify.success(t('profile.signature.saved'));
|
||||
if (mode === 'draw') clear();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDrawing = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !hasInk) return;
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
notify.error(t('profile.signature.drawFailed'));
|
||||
return;
|
||||
}
|
||||
void save(new File([blob], 'signature.png', { type: 'image/png' }));
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
// Validated here as well as server-side so the reason is immediate and the
|
||||
// user is not made to wait on an upload that is going to be rejected.
|
||||
const onFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
// Lets the same file be picked again after a rejection.
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
if (!ACCEPTED.includes(file.type)) {
|
||||
notify.error(t('profile.signature.badType'));
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
notify.error(t('profile.signature.tooLarge'));
|
||||
return;
|
||||
}
|
||||
void save(file);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await onDelete();
|
||||
notify.success(t('profile.signature.removed'));
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={5}>{t('profile.signature.title')}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('profile.signature.description')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t('profile.signature.reissueNotice')}
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : currentUrl ? (
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('profile.signature.current')}
|
||||
</Text>
|
||||
<Image
|
||||
src={currentUrl}
|
||||
alt={t('profile.signature.currentAlt')}
|
||||
fit="contain"
|
||||
h={120}
|
||||
bg="white"
|
||||
/>
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleDelete}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t('profile.signature.remove')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('profile.signature.none')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as 'draw' | 'upload')}
|
||||
data={[
|
||||
{ value: 'draw', label: t('profile.signature.modeDraw') },
|
||||
{ value: 'upload', label: t('profile.signature.modeUpload') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{mode === 'draw' ? (
|
||||
<Stack gap="sm">
|
||||
<Box
|
||||
component="canvas"
|
||||
ref={canvasRef}
|
||||
width={PAD_WIDTH}
|
||||
height={PAD_HEIGHT}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: `${PAD_WIDTH} / ${PAD_HEIGHT}`,
|
||||
border: '1px dashed var(--mantine-color-gray-4)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
background: '#fff',
|
||||
// Stops the browser panning/zooming the page mid-stroke on touch.
|
||||
touchAction: 'none',
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
/>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconWriting size={16} />}
|
||||
onClick={saveDrawing}
|
||||
loading={isUploading}
|
||||
disabled={!hasInk || busy}
|
||||
>
|
||||
{t('profile.signature.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={clear}
|
||||
disabled={!hasInk || busy}
|
||||
>
|
||||
{t('profile.signature.clear')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
component="label"
|
||||
variant="light"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
loading={isUploading}
|
||||
disabled={busy}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('profile.signature.choose')}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept={ACCEPTED.join(',')}
|
||||
onChange={onFile}
|
||||
/>
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('profile.signature.fileHint')}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
169
libs/ui/src/lib/feedback/FilePreviewModal.tsx
Normal file
169
libs/ui/src/lib/feedback/FilePreviewModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Anchor, Button, Group, Modal, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { IconExternalLink, IconFileUnknown } from '@tabler/icons-react';
|
||||
|
||||
/** How a file is shown, once its type is known. */
|
||||
type PreviewKind = 'image' | 'video' | 'audio' | 'embed' | 'unsupported';
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'svg'];
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'm4a'];
|
||||
const EMBED_EXTENSIONS = ['pdf', 'txt', 'csv', 'json', 'xml'];
|
||||
|
||||
/**
|
||||
* What the browser can actually render, decided from the mime type where there
|
||||
* is one and the URL's extension where there is not.
|
||||
*
|
||||
* Presigned links carry the storage key in the path, so the extension survives
|
||||
* even when the caller only has a URL. `image/tiff` and `image/heic` are
|
||||
* deliberately treated as images: Safari renders both, and everywhere else the
|
||||
* `<img>` fails visibly rather than an iframe offering a silent download.
|
||||
*/
|
||||
export function resolvePreviewKind(url: string, mimeType?: string | null): PreviewKind {
|
||||
const mime = mimeType?.toLowerCase() ?? '';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime === 'application/pdf' || mime.startsWith('text/')) return 'embed';
|
||||
// Word, Excel and the rest: nothing renders them inline, and an iframe would
|
||||
// quietly start a download instead of previewing anything.
|
||||
if (mime) return 'unsupported';
|
||||
|
||||
const extension = extensionOf(url);
|
||||
if (!extension) return 'embed';
|
||||
if (IMAGE_EXTENSIONS.includes(extension)) return 'image';
|
||||
if (VIDEO_EXTENSIONS.includes(extension)) return 'video';
|
||||
if (AUDIO_EXTENSIONS.includes(extension)) return 'audio';
|
||||
if (EMBED_EXTENSIONS.includes(extension)) return 'embed';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
function extensionOf(url: string): string | null {
|
||||
// Presigned URLs carry a query string; the path is the part with the name.
|
||||
const path = url.split(/[?#]/)[0];
|
||||
const name = path.slice(path.lastIndexOf('/') + 1);
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a stored file gets opened anywhere in the app.
|
||||
*
|
||||
* Never `window.open` / `target="_blank"` a file that can be shown here —
|
||||
* route it through this modal so the reviewer never loses their place to a new
|
||||
* tab. What a slot accepts is configuration now, so this had to grow past the
|
||||
* PDF it started as: a national ID arrives as a photograph, evidence arrives
|
||||
* as video, and an academic record sometimes arrives as the Word file its
|
||||
* institution issued. The last of those genuinely cannot be rendered by a
|
||||
* browser, so it gets an honest panel and a link out rather than an iframe
|
||||
* that silently downloads it.
|
||||
*/
|
||||
export function FilePreviewModal({
|
||||
opened,
|
||||
onClose,
|
||||
url,
|
||||
title = 'Document',
|
||||
mimeType,
|
||||
/** Overrides the detected kind — for a blob URL with no extension. */
|
||||
kind,
|
||||
labels,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
title?: string;
|
||||
mimeType?: string | null;
|
||||
kind?: PreviewKind;
|
||||
/** Supplied by the app so this stays out of the i18n bundles. */
|
||||
labels?: { unsupported?: string; openInNewTab?: string; close?: string };
|
||||
}) {
|
||||
const resolved = kind ?? resolvePreviewKind(url, mimeType);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="80%"
|
||||
centered
|
||||
trapFocus
|
||||
returnFocus
|
||||
styles={
|
||||
resolved === 'image' || resolved === 'video'
|
||||
? // A photograph on a white sheet loses its own edges; the dark mat
|
||||
// is what tells the eye where the file ends.
|
||||
{ body: { background: 'var(--mantine-color-dark-8)', padding: 0 } }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{url && resolved === 'image' && (
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
style={{
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '85vh',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{url && resolved === 'video' && (
|
||||
// Controls only, no autoplay: a review screen that starts making noise
|
||||
// on open is a review screen people mute and then miss the audio on.
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
preload="metadata"
|
||||
style={{ display: 'block', width: '100%', maxHeight: '85vh' }}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)}
|
||||
|
||||
{url && resolved === 'audio' && (
|
||||
<Stack p="md">
|
||||
<audio src={url} controls style={{ width: '100%' }}>
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{url && resolved === 'embed' && (
|
||||
<iframe
|
||||
src={url}
|
||||
title={title}
|
||||
style={{ width: '100%', height: '85vh', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{url && resolved === 'unsupported' && (
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<ThemeIcon size={56} radius="xl" variant="light" color="gray">
|
||||
<IconFileUnknown size={28} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed" ta="center" maw={420}>
|
||||
{labels?.unsupported ??
|
||||
'This file type cannot be shown here. Open it in a new tab to download it.'}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
component="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="light"
|
||||
leftSection={<IconExternalLink size={15} />}
|
||||
>
|
||||
{labels?.openInNewTab ?? 'Open in a new tab'}
|
||||
</Button>
|
||||
<Anchor component="button" type="button" fz="sm" onClick={onClose}>
|
||||
{labels?.close ?? 'Close'}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal } from '@mantine/core';
|
||||
import { FilePreviewModal } from './FilePreviewModal';
|
||||
|
||||
interface PdfPreviewModalProps {
|
||||
opened: boolean;
|
||||
@@ -8,9 +8,14 @@ interface PdfPreviewModalProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a PDF gets opened anywhere in the app. Never `window.open` /
|
||||
* `target="_blank"` a PDF directly — route it through this modal instead, so
|
||||
* the reviewer never loses their place to a new tab.
|
||||
* A PDF viewer, kept as its own name because most callers only ever open a
|
||||
* PDF and say so at the call site.
|
||||
*
|
||||
* The rendering lives in {@link FilePreviewModal}, which also handles images,
|
||||
* video and the file types no browser can show. Callers that know the mime
|
||||
* type should use that directly; the ones here pass a URL alone and get the
|
||||
* same iframe they always had, since a link with no `.something` on the end
|
||||
* resolves to the embed view.
|
||||
*/
|
||||
export function PdfPreviewModal({
|
||||
opened,
|
||||
@@ -19,22 +24,6 @@ export function PdfPreviewModal({
|
||||
title = 'Document',
|
||||
}: PdfPreviewModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="80%"
|
||||
centered
|
||||
trapFocus
|
||||
returnFocus
|
||||
>
|
||||
{url && (
|
||||
<iframe
|
||||
src={url}
|
||||
title={title}
|
||||
style={{ width: '100%', height: '85vh', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
<FilePreviewModal opened={opened} onClose={onClose} url={url} title={title} />
|
||||
);
|
||||
}
|
||||
|
||||
30
libs/ui/src/lib/input/canvas-point.spec.ts
Normal file
30
libs/ui/src/lib/input/canvas-point.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { toCanvasPoint } from './canvas-point';
|
||||
|
||||
/**
|
||||
* Guards the scaling between a canvas's on-screen size and its backing store.
|
||||
* Getting this wrong offsets strokes from the cursor — worse the further from
|
||||
* the origin — which stays invisible until someone actually tries to sign.
|
||||
*/
|
||||
describe('toCanvasPoint', () => {
|
||||
const size = { width: 800, height: 260 };
|
||||
// Half scale: 400px wide on screen, 800 in the backing store.
|
||||
const rect = { left: 100, top: 50, width: 400, height: 130 };
|
||||
|
||||
it('maps the top-left corner to the origin', () => {
|
||||
expect(toCanvasPoint(100, 50, rect, size)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('maps the bottom-right corner to the full backing-store size', () => {
|
||||
expect(toCanvasPoint(500, 180, rect, size)).toEqual({ x: 800, y: 260 });
|
||||
});
|
||||
|
||||
it('scales a midpoint rather than using raw client pixels', () => {
|
||||
// Raw offset would be (200, 65) — half of the correct answer.
|
||||
expect(toCanvasPoint(300, 115, rect, size)).toEqual({ x: 400, y: 130 });
|
||||
});
|
||||
|
||||
it('is unscaled when the element is already the backing-store size', () => {
|
||||
const exact = { left: 0, top: 0, width: 800, height: 260 };
|
||||
expect(toCanvasPoint(123, 45, exact, size)).toEqual({ x: 123, y: 45 });
|
||||
});
|
||||
});
|
||||
20
libs/ui/src/lib/input/canvas-point.ts
Normal file
20
libs/ui/src/lib/input/canvas-point.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Pointer position in canvas coordinates.
|
||||
*
|
||||
* A canvas is displayed at whatever width the layout gives it, but drawn into a
|
||||
* fixed backing store, so a click at the right-hand edge of a 400px-wide
|
||||
* element has to land at x=width, not x=400. Skipping this scaling is the
|
||||
* classic canvas bug: strokes appear offset from the cursor, worsening the
|
||||
* further from the origin you draw.
|
||||
*/
|
||||
export function toCanvasPoint(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
size: { width: number; height: number },
|
||||
) {
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * size.width,
|
||||
y: ((clientY - rect.top) / rect.height) * size.height,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user