mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 11:55:43 +00:00
11
.env.example
11
.env.example
@@ -12,10 +12,8 @@
|
|||||||
# frontend never speaks to Fayda directly.
|
# frontend never speaks to Fayda directly.
|
||||||
|
|
||||||
# Base URL of the emaapi backend, including the /api prefix.
|
# Base URL of the emaapi backend, including the /api prefix.
|
||||||
# 3001, not 3000: the portal itself takes 3000 in development, because that is
|
# The portal runs on 4200 and the local API runs on 3000.
|
||||||
# the port in the Fayda redirect URI registered for local testing. Set PORT=3001
|
VITE_BASE_API_URL=http://localhost:3000/api
|
||||||
# in emaapi's .env to match.
|
|
||||||
VITE_BASE_API_URL=http://localhost:3001/api
|
|
||||||
|
|
||||||
# Serve fixture data instead of calling the API. Any value other than "true"
|
# Serve fixture data instead of calling the API. Any value other than "true"
|
||||||
# uses the real backend.
|
# uses the real backend.
|
||||||
@@ -25,7 +23,7 @@ VITE_USE_MOCKS=false
|
|||||||
# Host ports published by docker-compose.yml. It also expects per-app env files
|
# 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
|
# 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
|
# file. Ignored when running the Vite dev servers, which serve the portal on
|
||||||
# 3000 and the backoffice on 4201.
|
# 4200 and the backoffice on 4201.
|
||||||
# EMA_PORTAL_PORT=8021
|
# EMA_PORTAL_PORT=8021
|
||||||
# EMA_BACKOFFICE_PORT=8022
|
# EMA_BACKOFFICE_PORT=8022
|
||||||
|
|
||||||
@@ -34,6 +32,5 @@ VITE_USE_MOCKS=false
|
|||||||
# page at /callback and /signup/fayda/callback, and whichever path is registered
|
# page at /callback and /signup/fayda/callback, and whichever path is registered
|
||||||
# with Fayda must match the API's FAYDA_REDIRECT_URI exactly.
|
# with Fayda must match the API's FAYDA_REDIRECT_URI exactly.
|
||||||
#
|
#
|
||||||
# The value being registered first is http://localhost:3001/callback, so the
|
# Register http://localhost:4200/callback with Fayda. Nothing
|
||||||
# portal's dev server now listens on 3000 and emaapi moves to 3001. Nothing
|
|
||||||
# extra to run — `nx serve portal` already binds the right port.
|
# extra to run — `nx serve portal` already binds the right port.
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ npm run dev:all
|
|||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
| ----------------------------- | ------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------- | ------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `VITE_BASE_API_URL` | Yes | `http://localhost:3001` | Base URL for all API requests |
|
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
|
||||||
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
|
| `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_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 |
|
| `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 |
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconAlertTriangle, IconFingerprint, IconPrinter, IconScan, IconSearch, IconX } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconFingerprint, IconScan, IconSearch, IconX } from '@tabler/icons-react';
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
openAuthedDocument,
|
|
||||||
useEnrollBiometricMutation,
|
useEnrollBiometricMutation,
|
||||||
useGenerateBsidMutation,
|
useGenerateBsidMutation,
|
||||||
useGetBiometricEnrollmentsQuery,
|
useGetBiometricEnrollmentsQuery,
|
||||||
@@ -52,12 +51,19 @@ function fakeTemplate(): string {
|
|||||||
return btoa(String.fromCharCode(...bytes));
|
return btoa(String.fromCharCode(...bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pick a registered seafarer to enroll — approved registrations carry a profileId. */
|
/**
|
||||||
|
* 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 }) {
|
function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debounced] = useDebouncedValue(search, 300);
|
const [debounced] = useDebouncedValue(search, 300);
|
||||||
const { data, isFetching } = useListSeafarerRegistrationsQuery({
|
const { data, isFetching } = useListSeafarerRegistrationsQuery({
|
||||||
status: 'APPROVED',
|
status: 'AWAITING_BIOMETRICS',
|
||||||
search: debounced || undefined,
|
search: debounced || undefined,
|
||||||
take: 10,
|
take: 10,
|
||||||
});
|
});
|
||||||
@@ -65,7 +71,7 @@ function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }
|
|||||||
return (
|
return (
|
||||||
<Card withBorder radius="md" p="md">
|
<Card withBorder radius="md" p="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search seafarer by name, ID or registration number…"
|
placeholder="Search seafarers awaiting enrolment…"
|
||||||
leftSection={<IconSearch size={14} />}
|
leftSection={<IconSearch size={14} />}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
@@ -78,14 +84,16 @@ function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }
|
|||||||
<Table.Tr key={r.id} onClick={() => onPick(r)} style={{ cursor: 'pointer' }}>
|
<Table.Tr key={r.id} onClick={() => onPick(r)} style={{ cursor: 'pointer' }}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz="sm" fw={600}>{applicantName(r)}</Text>
|
<Text fz="sm" fw={600}>{applicantName(r)}</Text>
|
||||||
<Text fz="xs" c="dimmed" ff="monospace">{r.seafarerNumber}</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.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
{!isFetching && (data?.items ?? []).length === 0 && (
|
{!isFetching && (data?.items ?? []).length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz="sm" c="dimmed">No registered seafarer matches.</Text>
|
<Text fz="sm" c="dimmed">No seafarer is waiting on enrolment.</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -116,7 +124,6 @@ export function BiometricEnrollmentPage() {
|
|||||||
const [bsid, setBsid] = useState<string | null>(null);
|
const [bsid, setBsid] = useState<string | null>(null);
|
||||||
const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation();
|
const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation();
|
||||||
const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation();
|
const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation();
|
||||||
const [printing, setPrinting] = useState(false);
|
|
||||||
|
|
||||||
const hasActive = useMemo(
|
const hasActive = useMemo(
|
||||||
() => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m),
|
() => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m),
|
||||||
@@ -161,26 +168,11 @@ export function BiometricEnrollmentPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePrint() {
|
|
||||||
if (!profileId) return;
|
|
||||||
setPrinting(true);
|
|
||||||
try {
|
|
||||||
await openAuthedDocument(
|
|
||||||
`/biometric-enrollments/profile/${profileId}/certificate`,
|
|
||||||
`biometric-enrollment-${profileId}.pdf`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
notify.error(extractErrorMessage(err, 'Could not open the certificate.'));
|
|
||||||
} finally {
|
|
||||||
setPrinting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md" py="md">
|
<Container size="md" py="md">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Biometric Enrollment"
|
title="Biometric Enrollment"
|
||||||
subtitle="Capture a fingerprint or face template for a registered seafarer, and print the enrollment slip."
|
subtitle="Capture a fingerprint or face template for a seafarer awaiting enrolment, then issue their BSID."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!selected ? (
|
{!selected ? (
|
||||||
@@ -196,7 +188,7 @@ export function BiometricEnrollmentPage() {
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600}>{applicantName(selected)}</Text>
|
<Text fw={600}>{applicantName(selected)}</Text>
|
||||||
<Text fz="xs" c="dimmed" ff="monospace">{selected.seafarerNumber}</Text>
|
<Text fz="xs" c="dimmed" ff="monospace">{selected.registrationNumber}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -260,18 +252,7 @@ export function BiometricEnrollmentPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card withBorder radius="md" p="md">
|
<Card withBorder radius="md" p="md">
|
||||||
<Group justify="space-between" mb="sm">
|
<Text fz="sm" fw={600} mb="sm">On file</Text>
|
||||||
<Text fz="sm" fw={600}>On file</Text>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="xs"
|
|
||||||
leftSection={<IconPrinter size={14} />}
|
|
||||||
onClick={handlePrint}
|
|
||||||
loading={printing}
|
|
||||||
>
|
|
||||||
Print certificate
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const SCOPES: { value: NumberFormatScope; label: string }[] = [
|
|||||||
{ value: 'SEAFARER_NUMBER', label: 'Seafarer Number' },
|
{ value: 'SEAFARER_NUMBER', label: 'Seafarer Number' },
|
||||||
{ value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' },
|
{ value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' },
|
||||||
{ value: 'BTC_NUMBER', label: 'BTC Number' },
|
{ value: 'BTC_NUMBER', label: 'BTC Number' },
|
||||||
|
{ value: 'BSID', label: 'Biometric Subject ID (BSID)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const scopeLabel = (scope: NumberFormatScope) =>
|
const scopeLabel = (scope: NumberFormatScope) =>
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ export interface UpdateProfessionPayload {
|
|||||||
export type NumberFormatScope =
|
export type NumberFormatScope =
|
||||||
| 'SEAFARER_NUMBER'
|
| 'SEAFARER_NUMBER'
|
||||||
| 'SEAMAN_BOOK_NUMBER'
|
| 'SEAMAN_BOOK_NUMBER'
|
||||||
| 'BTC_NUMBER';
|
| 'BTC_NUMBER'
|
||||||
|
| 'BSID';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shape of a generated identifier — prefix, optional year, separator and
|
* The shape of a generated identifier — prefix, optional year, separator and
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
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 type { TFunction } from "i18next";
|
||||||
import {
|
import {
|
||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
applicantOrCompanyName,
|
applicantOrCompanyName,
|
||||||
localized,
|
localized,
|
||||||
|
type ApplicationKind,
|
||||||
type LicenseApplication,
|
type LicenseApplication,
|
||||||
type QueueFilter,
|
type QueueFilter,
|
||||||
} from "@ema-platform/api";
|
} from "@ema-platform/api";
|
||||||
|
|
||||||
|
const KIND_COLOR: Record<ApplicationKind, string> = {
|
||||||
|
NEW: "blue",
|
||||||
|
RENEWAL: "teal",
|
||||||
|
REISSUE: "orange",
|
||||||
|
};
|
||||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||||
import { dateDisplayer } from "@ema-platform/shared";
|
import { dateDisplayer } from "@ema-platform/shared";
|
||||||
import { computeSla } from "../../sla";
|
import { computeSla } from "../../sla";
|
||||||
@@ -112,9 +119,19 @@ export function licenseQueueColumns(
|
|||||||
{
|
{
|
||||||
header: t("queue.typeCol", "Type"),
|
header: t("queue.typeCol", "Type"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm">
|
<Group gap={6} wrap="nowrap">
|
||||||
{localized(row.original.licenseType?.name, locale) || "—"}
|
<Text size="sm">
|
||||||
</Text>
|
{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,
|
useGetQueueCountsQuery,
|
||||||
useGetQueueQuery,
|
useGetQueueQuery,
|
||||||
useLazyExportApplicationsQuery,
|
useLazyExportApplicationsQuery,
|
||||||
|
type ApplicationKind,
|
||||||
type LicenseApplication,
|
type LicenseApplication,
|
||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
type LicenseType,
|
type LicenseType,
|
||||||
@@ -437,6 +438,7 @@ export function LicenseQueuePage() {
|
|||||||
const hasFacets = Boolean(
|
const hasFacets = Boolean(
|
||||||
urlFilter.status?.length ||
|
urlFilter.status?.length ||
|
||||||
urlFilter.licenseTypeId ||
|
urlFilter.licenseTypeId ||
|
||||||
|
urlFilter.kind ||
|
||||||
urlFilter.assignee ||
|
urlFilter.assignee ||
|
||||||
urlFilter.submittedFrom ||
|
urlFilter.submittedFrom ||
|
||||||
debouncedSearch,
|
debouncedSearch,
|
||||||
@@ -589,6 +591,19 @@ export function LicenseQueuePage() {
|
|||||||
w={220}
|
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
|
<AmharicDatePicker
|
||||||
label={t("queue.submittedFrom", "Submitted from")}
|
label={t("queue.submittedFrom", "Submitted from")}
|
||||||
value={urlFilter.submittedFrom ?? ""}
|
value={urlFilter.submittedFrom ?? ""}
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ export function LicenseReviewPage() {
|
|||||||
const [rescheduleReason, setRescheduleReason] = useState("");
|
const [rescheduleReason, setRescheduleReason] = useState("");
|
||||||
const [issuanceOpen, setIssuanceOpen] = useState(false);
|
const [issuanceOpen, setIssuanceOpen] = useState(false);
|
||||||
const [issuanceDate, setIssuanceDate] = useState("");
|
const [issuanceDate, setIssuanceDate] = useState("");
|
||||||
|
const [issuancePeriod, setIssuancePeriod] = useState<"MORNING" | "AFTERNOON">(
|
||||||
|
"MORNING",
|
||||||
|
);
|
||||||
const [resultOpen, setResultOpen] = useState(false);
|
const [resultOpen, setResultOpen] = useState(false);
|
||||||
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||||
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
||||||
@@ -884,6 +887,11 @@ export function LicenseReviewPage() {
|
|||||||
<Badge color={STATUS_COLORS[status]} variant="light">
|
<Badge color={STATUS_COLORS[status]} variant="light">
|
||||||
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{data.issuedLicenseStatus === "SUPERSEDED" && (
|
||||||
|
<Badge color="gray" variant="light">
|
||||||
|
{t("review.certificateSuperseded", "Certificate superseded")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{app.adjustmentRound > 0 && (
|
{app.adjustmentRound > 0 && (
|
||||||
<Badge color="orange" variant="light" size="sm">
|
<Badge color="orange" variant="light" size="sm">
|
||||||
{t("review.round", {
|
{t("review.round", {
|
||||||
@@ -1585,6 +1593,14 @@ export function LicenseReviewPage() {
|
|||||||
value={issuanceDate}
|
value={issuanceDate}
|
||||||
onChange={setIssuanceDate}
|
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>
|
<ModalFooter>
|
||||||
{/* Span-wrapped like DecisionBar's ActionButton — Mantine strips
|
{/* Span-wrapped like DecisionBar's ActionButton — Mantine strips
|
||||||
pointer events from a disabled control, and a disabled button
|
pointer events from a disabled control, and a disabled button
|
||||||
@@ -1605,6 +1621,7 @@ export function LicenseReviewPage() {
|
|||||||
await scheduleIssuance({
|
await scheduleIssuance({
|
||||||
id,
|
id,
|
||||||
scheduledDate: issuanceDate,
|
scheduledDate: issuanceDate,
|
||||||
|
scheduledPeriod: issuancePeriod,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
setIssuanceOpen(false);
|
setIssuanceOpen(false);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
IconMail,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
|
IconSignature,
|
||||||
IconShieldLock,
|
IconShieldLock,
|
||||||
IconSun,
|
IconSun,
|
||||||
IconUser,
|
IconUser,
|
||||||
@@ -43,12 +44,18 @@ import { z } from 'zod';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
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 type { AuthUser } from '@ema-platform/auth';
|
||||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||||
import { setLayoutMode } from '../../../store/preferences.slice';
|
import { setLayoutMode } from '../../../store/preferences.slice';
|
||||||
import type { LayoutMode } from '../../../store/preferences.slice';
|
import type { LayoutMode } from '../../../store/preferences.slice';
|
||||||
|
import { MySignaturePad } from '../components/MySignaturePad';
|
||||||
import classes from './ProfilePage.module.css';
|
import classes from './ProfilePage.module.css';
|
||||||
|
|
||||||
function getInitials(name: string, fallback: string) {
|
function getInitials(name: string, fallback: string) {
|
||||||
@@ -77,6 +84,10 @@ export function ProfilePage() {
|
|||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||||
const { handleError } = useErrorHandler();
|
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 [updateTrigger] = useApiMutation<AuthUser>();
|
||||||
const [meTrigger] = useApiMutation<AuthUser>();
|
const [meTrigger] = useApiMutation<AuthUser>();
|
||||||
@@ -318,6 +329,11 @@ export function ProfilePage() {
|
|||||||
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
|
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
|
||||||
{t('profile.tabs.profile')}
|
{t('profile.tabs.profile')}
|
||||||
</Tabs.Tab>
|
</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} />}>
|
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
||||||
{t('profile.tabs.security')}
|
{t('profile.tabs.security')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -405,6 +421,15 @@ export function ProfilePage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Tabs.Panel>
|
</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 ---- */}
|
{/* ---- Security ---- */}
|
||||||
<Tabs.Panel value="security" pt="md">
|
<Tabs.Panel value="security" pt="md">
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { IconSearch } from '@tabler/icons-react';
|
|||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||||
|
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
|
||||||
|
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
|
||||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||||
useListSeafarerDocumentsQuery,
|
useListSeafarerDocumentsQuery,
|
||||||
type SeafarerDocumentKind,
|
type SeafarerDocumentKind,
|
||||||
|
type SeafarerDocumentRequestKind,
|
||||||
type SeafarerDocumentRow,
|
type SeafarerDocumentRow,
|
||||||
type SeafarerDocumentStatus,
|
type SeafarerDocumentStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
@@ -22,6 +25,10 @@ const STATUS_FILTERS = (
|
|||||||
['PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'SCHEDULED', 'ISSUED', 'REJECTED'] as SeafarerDocumentStatus[]
|
['PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'SCHEDULED', 'ISSUED', 'REJECTED'] as SeafarerDocumentStatus[]
|
||||||
).map((value) => ({ value, label: SEAFARER_DOCUMENT_STATUS_LABELS[value] }));
|
).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
|
* 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.
|
* 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 navigate = useNavigate();
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
const [status, setStatus] = useState<SeafarerDocumentStatus | null>(null);
|
const [status, setStatus] = useState<SeafarerDocumentStatus | null>(null);
|
||||||
|
const [requestKind, setRequestKind] = useState<SeafarerDocumentRequestKind | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
@@ -38,6 +46,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
|||||||
const { data, isLoading, isFetching, refetch } = useListSeafarerDocumentsQuery({
|
const { data, isLoading, isFetching, refetch } = useListSeafarerDocumentsQuery({
|
||||||
kind,
|
kind,
|
||||||
status: status ?? undefined,
|
status: status ?? undefined,
|
||||||
|
requestKind: requestKind ?? undefined,
|
||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
skip: page * pageSize,
|
skip: page * pageSize,
|
||||||
@@ -75,6 +84,15 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
|||||||
</div>
|
</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',
|
header: 'Fee',
|
||||||
accessorKey: 'feeAmount',
|
accessorKey: 'feeAmount',
|
||||||
@@ -148,6 +166,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
|||||||
clearable
|
clearable
|
||||||
w={200}
|
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}
|
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 { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||||
|
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
|
||||||
|
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
|
||||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
@@ -133,6 +135,11 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]}>
|
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]}>
|
||||||
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
||||||
</Badge>
|
</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 && (
|
{document.documentNumber && (
|
||||||
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
|
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
|
||||||
{document.documentNumber}
|
{document.documentNumber}
|
||||||
|
|||||||
@@ -63,8 +63,13 @@ export function SeafarerRegistrationReviewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { registration, attachments } = data;
|
const { registration, attachments } = data;
|
||||||
// Decided straight off the queue — no claim step.
|
// Decided straight off the queue — no claim step. AWAITING_BIOMETRICS is
|
||||||
const canDecide = registration.status === 'SUBMITTED';
|
// 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;
|
const busy = approving || rejecting || requesting;
|
||||||
|
|
||||||
async function run(action: () => Promise<unknown>, done: string) {
|
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' && (
|
{registration.status === 'RESUBMIT_REQUIRED' && (
|
||||||
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
|
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
|
||||||
{registration.reviewRemark}
|
{registration.reviewRemark}
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export const am: Translations = {
|
|||||||
btcQueue: "የBTC ወረፋ",
|
btcQueue: "የBTC ወረፋ",
|
||||||
cocQueue: "የCoC ወረፋ",
|
cocQueue: "የCoC ወረፋ",
|
||||||
copQueue: "የCoP ወረፋ",
|
copQueue: "የCoP ወረፋ",
|
||||||
|
endorsementCocQueue: "የCoC እውቅና ወረፋ",
|
||||||
|
endorsementGocQueue: "የGOC እውቅና ወረፋ",
|
||||||
endorsementQueue: "የማስተያየት ወረፋ",
|
endorsementQueue: "የማስተያየት ወረፋ",
|
||||||
vesselRegistrations: "የመርከብ ምዝገባ",
|
vesselRegistrations: "የመርከብ ምዝገባ",
|
||||||
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
||||||
@@ -97,6 +99,8 @@ export const am: Translations = {
|
|||||||
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
|
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
|
||||||
applications: "ማመልከቻዎች",
|
applications: "ማመልከቻዎች",
|
||||||
paymentConfig: "የክፍያ ውቅረት",
|
paymentConfig: "የክፍያ ውቅረት",
|
||||||
|
pickupDesk: "የመረከቢያ ዴስክ",
|
||||||
|
pickupOffices: "የመረከቢያ ቢሮዎች",
|
||||||
analytics: "ትንታኔ",
|
analytics: "ትንታኔ",
|
||||||
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
|
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
|
||||||
medicalVerification: "የሕክምና ማረጋገጫ",
|
medicalVerification: "የሕክምና ማረጋገጫ",
|
||||||
@@ -467,9 +471,31 @@ export const am: Translations = {
|
|||||||
unverified: "ያልተረጋገጠ",
|
unverified: "ያልተረጋገጠ",
|
||||||
tabs: {
|
tabs: {
|
||||||
profile: "መገለጫ",
|
profile: "መገለጫ",
|
||||||
|
signature: "ፊርማ",
|
||||||
security: "ደህንነት",
|
security: "ደህንነት",
|
||||||
preferences: "ምርጫዎች",
|
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: "በኦፊሴላዊ ኢማ ሰነዶች ላይ እንደሚታየው ስምዎ።",
|
personalHint: "በኦፊሴላዊ ኢማ ሰነዶች ላይ እንደሚታየው ስምዎ።",
|
||||||
languageTitle: "ቋንቋ",
|
languageTitle: "ቋንቋ",
|
||||||
languageHint: "በአስተዳደር ፓነል ውስጥ የሚጠቀሙትን ቋንቋ ይምረጡ።",
|
languageHint: "በአስተዳደር ፓነል ውስጥ የሚጠቀሙትን ቋንቋ ይምረጡ።",
|
||||||
@@ -870,6 +896,12 @@ export const am: Translations = {
|
|||||||
type: "ዓይነት",
|
type: "ዓይነት",
|
||||||
anyType: "ማንኛውም",
|
anyType: "ማንኛውም",
|
||||||
typeCol: "ዓይነት",
|
typeCol: "ዓይነት",
|
||||||
|
kind: "የማመልከቻ ዓይነት",
|
||||||
|
kindValues: {
|
||||||
|
NEW: "አዲስ",
|
||||||
|
RENEWAL: "እድሳት",
|
||||||
|
REISSUE: "ምትክ",
|
||||||
|
},
|
||||||
statusCol: "ሁኔታ",
|
statusCol: "ሁኔታ",
|
||||||
statusValues: {
|
statusValues: {
|
||||||
DRAFT: "ረቂቅ",
|
DRAFT: "ረቂቅ",
|
||||||
@@ -945,6 +977,7 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
review: {
|
review: {
|
||||||
|
certificateSuperseded: "ሰርተፍኬቱ ተተክቷል",
|
||||||
summary: "ማጠቃለያ",
|
summary: "ማጠቃለያ",
|
||||||
officer: "ሹም",
|
officer: "ሹም",
|
||||||
supervisor: "የበላይ ኃላፊ",
|
supervisor: "የበላይ ኃላፊ",
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export const en = {
|
|||||||
postWaiverQueue: 'Post-Waiver Queue',
|
postWaiverQueue: 'Post-Waiver Queue',
|
||||||
cocQueue: 'CoC Queue',
|
cocQueue: 'CoC Queue',
|
||||||
copQueue: 'CoP Queue',
|
copQueue: 'CoP Queue',
|
||||||
|
endorsementCocQueue: 'CoC Endorsement Queue',
|
||||||
|
endorsementGocQueue: 'GOC Endorsement Queue',
|
||||||
endorsementQueue: 'Endorsement Queue',
|
endorsementQueue: 'Endorsement Queue',
|
||||||
vesselRegistrations: 'Vessel Registration',
|
vesselRegistrations: 'Vessel Registration',
|
||||||
vesselTransfers: 'Vessel Ownership Transfer',
|
vesselTransfers: 'Vessel Ownership Transfer',
|
||||||
@@ -96,6 +98,8 @@ export const en = {
|
|||||||
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
||||||
applications: 'Applications',
|
applications: 'Applications',
|
||||||
paymentConfig: 'Payment Config',
|
paymentConfig: 'Payment Config',
|
||||||
|
pickupDesk: 'Pickup Desk',
|
||||||
|
pickupOffices: 'Pickup Offices',
|
||||||
analytics: 'Analytics',
|
analytics: 'Analytics',
|
||||||
seaServiceVerification: 'Sea Service Verification',
|
seaServiceVerification: 'Sea Service Verification',
|
||||||
medicalVerification: 'Medical Verification',
|
medicalVerification: 'Medical Verification',
|
||||||
@@ -466,9 +470,32 @@ export const en = {
|
|||||||
unverified: 'Unverified',
|
unverified: 'Unverified',
|
||||||
tabs: {
|
tabs: {
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
|
signature: 'Signature',
|
||||||
security: 'Security',
|
security: 'Security',
|
||||||
preferences: 'Preferences',
|
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.',
|
personalHint: 'Your name as it appears on official EMA documents.',
|
||||||
languageTitle: 'Language',
|
languageTitle: 'Language',
|
||||||
languageHint: 'Choose the language used across the admin panel.',
|
languageHint: 'Choose the language used across the admin panel.',
|
||||||
@@ -877,6 +904,12 @@ export const en = {
|
|||||||
type: 'Type',
|
type: 'Type',
|
||||||
anyType: 'Any',
|
anyType: 'Any',
|
||||||
typeCol: 'Type',
|
typeCol: 'Type',
|
||||||
|
kind: 'Application kind',
|
||||||
|
kindValues: {
|
||||||
|
NEW: 'New',
|
||||||
|
RENEWAL: 'Renewal',
|
||||||
|
REISSUE: 'Replacement',
|
||||||
|
},
|
||||||
statusCol: 'Status',
|
statusCol: 'Status',
|
||||||
statusValues: {
|
statusValues: {
|
||||||
DRAFT: 'Draft',
|
DRAFT: 'Draft',
|
||||||
@@ -954,6 +987,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
review: {
|
review: {
|
||||||
|
certificateSuperseded: 'Certificate superseded',
|
||||||
summary: 'Summary',
|
summary: 'Summary',
|
||||||
officer: 'Officer',
|
officer: 'Officer',
|
||||||
supervisor: 'Supervisor',
|
supervisor: 'Supervisor',
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
IconAnchor,
|
IconAnchor,
|
||||||
IconArrowsExchange,
|
IconArrowsExchange,
|
||||||
IconBook2,
|
IconBook2,
|
||||||
|
IconBuildingWarehouse,
|
||||||
|
IconCalendarEvent,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
IconClipboardText,
|
IconClipboardText,
|
||||||
@@ -42,6 +44,12 @@ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
|||||||
*/
|
*/
|
||||||
const SEAFARER_QUEUE = [P.VIEW_SEAFARER_REGISTRY, ...APPLICATION_QUEUE];
|
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.
|
* The backoffice information architecture.
|
||||||
*
|
*
|
||||||
@@ -149,7 +157,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
to: "/biometric-enrollment",
|
to: "/biometric-enrollment",
|
||||||
label: "nav.biometricEnrollment",
|
label: "nav.biometricEnrollment",
|
||||||
icon: IconFingerprint,
|
icon: IconFingerprint,
|
||||||
permissions: [P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS],
|
permissions: BIOMETRIC_ENROLLMENT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/seafarer-registrations",
|
to: "/seafarer-registrations",
|
||||||
@@ -309,6 +317,18 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
icon: IconCreditCard,
|
icon: IconCreditCard,
|
||||||
permissions: [P.VIEW_PAYMENTS],
|
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],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
SeaServiceVerificationPage,
|
SeaServiceVerificationPage,
|
||||||
} from '../features/medical-verification/pages/MedicalVerificationPage';
|
} from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
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 { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
||||||
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
|
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
|
||||||
import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage';
|
import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage';
|
||||||
@@ -53,6 +55,12 @@ import { BiometricEnrollmentPage } from '../features/biometric-enrollment/pages/
|
|||||||
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
||||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
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. */
|
/** Route gate: same keys as the route's nav item in nav-config.ts. */
|
||||||
const guard = (anyOf: string[], element: ReactNode) => (
|
const guard = (anyOf: string[], element: ReactNode) => (
|
||||||
<RequirePermission anyOf={anyOf}>{element}</RequirePermission>
|
<RequirePermission anyOf={anyOf}>{element}</RequirePermission>
|
||||||
@@ -100,8 +108,10 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
||||||
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||||
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
{ 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: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||||
{ path: 'biometric-enrollment', element: guard([P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS], <BiometricEnrollmentPage />) },
|
{ path: 'biometric-enrollment', element: guard(BIOMETRIC_ENROLLMENT, <BiometricEnrollmentPage />) },
|
||||||
// Seafarer registration is not a licence: own queue, own review.
|
// Seafarer registration is not a licence: own queue, own review.
|
||||||
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
|
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
|
||||||
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
|
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
|||||||
// port: 4201,
|
// port: 4201,
|
||||||
// proxy: {
|
// proxy: {
|
||||||
// '/api': {
|
// '/api': {
|
||||||
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
|
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
|
||||||
// changeOrigin: true,
|
// changeOrigin: true,
|
||||||
// },
|
// },
|
||||||
// },
|
// },
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconDownload, IconRefresh } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconDownload, IconRefresh } from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
useLocalized,
|
useLocalized,
|
||||||
@@ -59,6 +59,36 @@ export function useRenewLicense() {
|
|||||||
return { renewLicense, isRenewing };
|
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 {
|
function daysUntil(date: string): number {
|
||||||
const ms = new Date(date).getTime() - Date.now();
|
const ms = new Date(date).getTime() - Date.now();
|
||||||
return Math.ceil(ms / 86_400_000);
|
return Math.ceil(ms / 86_400_000);
|
||||||
@@ -68,14 +98,18 @@ export function LicenseCard({
|
|||||||
license,
|
license,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
isRenewing,
|
isRenewing,
|
||||||
|
isReissuing,
|
||||||
onDownload,
|
onDownload,
|
||||||
onRenew,
|
onRenew,
|
||||||
|
onReissue,
|
||||||
}: {
|
}: {
|
||||||
license: IssuedLicense;
|
license: IssuedLicense;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
isRenewing: boolean;
|
isRenewing: boolean;
|
||||||
|
isReissuing?: boolean;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onRenew: () => void;
|
onRenew: () => void;
|
||||||
|
onReissue?: () => void;
|
||||||
}) {
|
}) {
|
||||||
// The API computes both in the authority's timezone; the local fallbacks are
|
// The API computes both in the authority's timezone; the local fallbacks are
|
||||||
// only for a cached response from before those fields existed.
|
// only for a cached response from before those fields existed.
|
||||||
@@ -87,6 +121,7 @@ export function LicenseCard({
|
|||||||
// badge.
|
// badge.
|
||||||
const current = license.status === 'ACTIVE' && !expired;
|
const current = license.status === 'ACTIVE' && !expired;
|
||||||
const renewable = license.renewable ?? false;
|
const renewable = license.renewable ?? false;
|
||||||
|
const reissuable = license.reissuable ?? false;
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -179,6 +214,25 @@ export function LicenseCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Container,
|
Container,
|
||||||
@@ -67,6 +68,7 @@ import {
|
|||||||
PORTAL_PERMISSIONS,
|
PORTAL_PERMISSIONS,
|
||||||
RequirePermission,
|
RequirePermission,
|
||||||
useCurrentProfile,
|
useCurrentProfile,
|
||||||
|
usePermissions,
|
||||||
} from "@ema-platform/auth";
|
} from "@ema-platform/auth";
|
||||||
import { ApplicationSummary } from "../components/ApplicationSummary";
|
import { ApplicationSummary } from "../components/ApplicationSummary";
|
||||||
import {
|
import {
|
||||||
@@ -74,6 +76,7 @@ import {
|
|||||||
fillFromVessel,
|
fillFromVessel,
|
||||||
} from "../components/ConfigDrivenSection";
|
} from "../components/ConfigDrivenSection";
|
||||||
import { DocumentSlots } from "../components/DocumentSlots";
|
import { DocumentSlots } from "../components/DocumentSlots";
|
||||||
|
import { PickupSchedulingPanel } from "../components/PickupSchedulingPanel";
|
||||||
import { StaffEvidence } from "../components/StaffEvidence";
|
import { StaffEvidence } from "../components/StaffEvidence";
|
||||||
import { useAppSelector } from "../../../store/hooks";
|
import { useAppSelector } from "../../../store/hooks";
|
||||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||||
@@ -117,9 +120,14 @@ export function LicenseApplicationPage() {
|
|||||||
const { data: config, isLoading: loadingConfig } =
|
const { data: config, isLoading: loadingConfig } =
|
||||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||||
const { profile } = useCurrentProfile();
|
const { profile } = useCurrentProfile();
|
||||||
|
const { can: hasPermission, known: permissionsKnown } = usePermissions();
|
||||||
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
|
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
|
||||||
// here rather than deeper down since it's the shared source of draft state.
|
// 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 [createApplication] = useCreateApplicationMutation();
|
||||||
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
||||||
|
|
||||||
@@ -394,14 +402,20 @@ export function LicenseApplicationPage() {
|
|||||||
const staffLocked = roundIsItemised && !hasStaffRemarks;
|
const staffLocked = roundIsItemised && !hasStaffRemarks;
|
||||||
|
|
||||||
// Sections that share a group collapse onto one step, so the stepper stays
|
// 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(
|
const steps = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
|
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,
|
language: i18n.language,
|
||||||
|
applicationKind: application?.kind,
|
||||||
}),
|
}),
|
||||||
[config, draft, i18n.language],
|
[config, draft, i18n.language, application?.kind, isReissue],
|
||||||
);
|
);
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() => steps.flatMap((step) => step.sections),
|
() => steps.flatMap((step) => step.sections),
|
||||||
@@ -720,6 +734,11 @@ export function LicenseApplicationPage() {
|
|||||||
>
|
>
|
||||||
{STATUS_LABELS[application.status]}
|
{STATUS_LABELS[application.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{detail?.issuedLicenseStatus === "SUPERSEDED" && (
|
||||||
|
<Badge size="sm" variant="light" color="gray">
|
||||||
|
{t("licensing.certificateSuperseded", "Certificate superseded")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
<Group gap="md" align="center">
|
<Group gap="md" align="center">
|
||||||
@@ -759,6 +778,17 @@ export function LicenseApplicationPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{config.licenseType.requiresIssuanceScheduling &&
|
||||||
|
(application.status === "PAYMENT_CONFIRMED" ||
|
||||||
|
application.status === "SCHEDULED") && (
|
||||||
|
<Box mb="md">
|
||||||
|
<PickupSchedulingPanel
|
||||||
|
scheduledDate={application.scheduledIssuanceDate}
|
||||||
|
scheduledPeriod={application.scheduledIssuancePeriod}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{showSummary && editableWhileSubmitted && (
|
{showSummary && editableWhileSubmitted && (
|
||||||
<Alert
|
<Alert
|
||||||
color="blue"
|
color="blue"
|
||||||
|
|||||||
@@ -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 { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
@@ -6,10 +6,23 @@ import {
|
|||||||
STATUS_PROGRESS,
|
STATUS_PROGRESS,
|
||||||
applicantOrCompanyName,
|
applicantOrCompanyName,
|
||||||
localized,
|
localized,
|
||||||
|
type ApplicationKind,
|
||||||
type LicenseApplication,
|
type LicenseApplication,
|
||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
} from '@ema-platform/api';
|
} 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(
|
export function applicationColumns(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
deps: {
|
deps: {
|
||||||
@@ -23,9 +36,16 @@ export function applicationColumns(
|
|||||||
header: t('applications.table.licence'),
|
header: t('applications.table.licence'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="sm" fw={600}>
|
<Group gap={6} wrap="nowrap">
|
||||||
{localized(row.original.licenseType?.name, deps.language) || '—'}
|
<Text size="sm" fw={600}>
|
||||||
</Text>
|
{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">
|
<Text size="xs" c="dimmed">
|
||||||
{row.original.applicationNumber}
|
{row.original.applicationNumber}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import { LicenseCatalogue } from '../../components/LicenseCatalogue';
|
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 { useApplicationPayment } from '../../../payments/hooks/useApplicationPayment';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
useGetPaymentCapabilitiesQuery,
|
useGetPaymentCapabilitiesQuery,
|
||||||
useRetakeExamMutation,
|
useRetakeExamMutation,
|
||||||
|
type ApplicationKind,
|
||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
@@ -102,6 +103,7 @@ export function MyApplicationsPage() {
|
|||||||
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
|
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
|
||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||||
const { renewLicense, isRenewing } = useRenewLicense();
|
const { renewLicense, isRenewing } = useRenewLicense();
|
||||||
|
const { reissueLicense, isReissuing } = useReissueLicense();
|
||||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
|
|
||||||
@@ -207,10 +209,13 @@ export function MyApplicationsPage() {
|
|||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<LicenseStatus | null>(null);
|
const [statusFilter, setStatusFilter] = useState<LicenseStatus | null>(null);
|
||||||
|
const [kindFilter, setKindFilter] = useState<ApplicationKind | null>(null);
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
const [bucketFilter, setBucketFilter] = useState<Bucket>(null);
|
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 { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
@@ -228,6 +233,7 @@ export function MyApplicationsPage() {
|
|||||||
if (!haystack.includes(q)) return false;
|
if (!haystack.includes(q)) return false;
|
||||||
}
|
}
|
||||||
if (statusFilter && app.status !== statusFilter) 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
|
// Drafts have no submittedAt, so date filtering falls back to createdAt
|
||||||
// rather than silently excluding every draft from a date-ranged search.
|
// rather than silently excluding every draft from a date-ranged search.
|
||||||
const at = app.submittedAt ?? app.createdAt;
|
const at = app.submittedAt ?? app.createdAt;
|
||||||
@@ -245,13 +251,14 @@ export function MyApplicationsPage() {
|
|||||||
const bAt = b.submittedAt ?? b.createdAt;
|
const bAt = b.submittedAt ?? b.createdAt;
|
||||||
return aAt < bAt ? 1 : aAt > bAt ? -1 : 0;
|
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);
|
const page = paginate(items);
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setStatusFilter(null);
|
setStatusFilter(null);
|
||||||
|
setKindFilter(null);
|
||||||
setDateFrom('');
|
setDateFrom('');
|
||||||
setDateTo('');
|
setDateTo('');
|
||||||
setBucketFilter(null);
|
setBucketFilter(null);
|
||||||
@@ -385,6 +392,22 @@ export function MyApplicationsPage() {
|
|||||||
clearable
|
clearable
|
||||||
w={200}
|
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
|
<AmharicDatePicker
|
||||||
label={t('applications.filters.from')}
|
label={t('applications.filters.from')}
|
||||||
value={dateFrom}
|
value={dateFrom}
|
||||||
@@ -475,8 +498,10 @@ export function MyApplicationsPage() {
|
|||||||
license={license}
|
license={license}
|
||||||
isDownloading={isDownloadingCert}
|
isDownloading={isDownloadingCert}
|
||||||
isRenewing={isRenewing}
|
isRenewing={isRenewing}
|
||||||
|
isReissuing={isReissuing}
|
||||||
onDownload={() => downloadCertificate(license.id)}
|
onDownload={() => downloadCertificate(license.id)}
|
||||||
onRenew={() => renewLicense(license)}
|
onRenew={() => renewLicense(license)}
|
||||||
|
onReissue={() => reissueLicense(license)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|||||||
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,
|
IconMapPin,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
|
IconSignature,
|
||||||
IconShieldLock,
|
IconShieldLock,
|
||||||
IconSun,
|
IconSun,
|
||||||
IconUser,
|
IconUser,
|
||||||
@@ -67,6 +68,7 @@ import {
|
|||||||
import { useSaveMyAddressMutation } from '../api/address-api';
|
import { useSaveMyAddressMutation } from '../api/address-api';
|
||||||
import { toAddressPayload } from '../types/address';
|
import { toAddressPayload } from '../types/address';
|
||||||
import { OperationsFormContent } from '../components/OperationsFormContent';
|
import { OperationsFormContent } from '../components/OperationsFormContent';
|
||||||
|
import { MySignaturePad } from '../components/SignaturePad';
|
||||||
import { SEAFARER_PROFILE_REQUIREMENT } from '../components/RequireSeafarerProfile';
|
import { SEAFARER_PROFILE_REQUIREMENT } from '../components/RequireSeafarerProfile';
|
||||||
import classes from './ProfilePage.module.css';
|
import classes from './ProfilePage.module.css';
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ const VALID_TABS = [
|
|||||||
'profile',
|
'profile',
|
||||||
'address',
|
'address',
|
||||||
'operations',
|
'operations',
|
||||||
|
'signature',
|
||||||
'security',
|
'security',
|
||||||
'preferences',
|
'preferences',
|
||||||
];
|
];
|
||||||
@@ -633,6 +636,9 @@ export function ProfilePage() {
|
|||||||
>
|
>
|
||||||
{t('profile.tabs.operations')}
|
{t('profile.tabs.operations')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="signature" leftSection={<IconSignature size={18} />}>
|
||||||
|
{t('profile.tabs.signature')}
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
||||||
{t('profile.tabs.security')}
|
{t('profile.tabs.security')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -808,6 +814,13 @@ export function ProfilePage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Tabs.Panel>
|
</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 ---- */}
|
{/* ---- Security ---- */}
|
||||||
<Tabs.Panel value="security" pt="md">
|
<Tabs.Panel value="security" pt="md">
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
GENDER_OPTIONS,
|
GENDER_OPTIONS,
|
||||||
HAIR_COLOR_OPTIONS,
|
HAIR_COLOR_OPTIONS,
|
||||||
MARITAL_STATUS_OPTIONS,
|
MARITAL_STATUS_OPTIONS,
|
||||||
|
RANK_TIER_OPTIONS,
|
||||||
isEthiopianNationality,
|
isEthiopianNationality,
|
||||||
useGetActiveDepartmentsQuery,
|
useGetActiveDepartmentsQuery,
|
||||||
useLocalized,
|
useLocalized,
|
||||||
@@ -130,6 +131,18 @@ export function ApplicantDetailsStep(p: StepProps) {
|
|||||||
options={departmentOptions}
|
options={departmentOptions}
|
||||||
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
|
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>
|
</Grid>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const STEPS = [
|
|||||||
*/
|
*/
|
||||||
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
||||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'],
|
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'],
|
||||||
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
['placeOfBirth', 'department', 'tier', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||||
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
||||||
[],
|
[],
|
||||||
['declarationAccepted'],
|
['declarationAccepted'],
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
|
CopyButton,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -12,41 +11,20 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import {
|
import {
|
||||||
IconDownload,
|
IconCheck,
|
||||||
|
IconCopy,
|
||||||
IconFingerprint,
|
IconFingerprint,
|
||||||
|
IconIdBadge2,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconScan,
|
IconScan,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { authStorage } from '@ema-platform/auth';
|
import { useCurrentProfile } from '@ema-platform/auth';
|
||||||
import { useGetMyBiometricEnrollmentsQuery } from '@ema-platform/api';
|
import { useGetMyBiometricEnrollmentsQuery } from '@ema-platform/api';
|
||||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
|
||||||
|
|
||||||
const API_BASE =
|
|
||||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
|
||||||
'http://localhost:3000/api';
|
|
||||||
|
|
||||||
async function fetchCertificate(): Promise<Blob> {
|
|
||||||
const token = authStorage.getToken();
|
|
||||||
if (!token) throw new Error('No auth token found');
|
|
||||||
const res = await fetch(`${API_BASE}/biometric-enrollments/mine/certificate`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch certificate (${res.status})`);
|
|
||||||
return res.blob();
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadBlob(blob: Blob, filename: string) {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODALITY_LABEL: Record<string, string> = {
|
const MODALITY_LABEL: Record<string, string> = {
|
||||||
FINGERPRINT: 'Fingerprint',
|
FINGERPRINT: 'Fingerprint',
|
||||||
@@ -54,44 +32,17 @@ const MODALITY_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View-only: what's enrolled, plus a printable slip. Capture stays
|
* View-only: the seafarer's BSID and what is enrolled against it. Capture
|
||||||
* counter-side with a scanner — there is no self-enrollment flow here.
|
* stays counter-side with a scanner — there is no self-enrollment flow here.
|
||||||
*/
|
*/
|
||||||
export function BiometricsPage() {
|
export function BiometricsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
|
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
// The BSID lives on the profile, stamped by staff once a capture is
|
||||||
const [busy, setBusy] = useState(false);
|
// confirmed — it is not a property of any one enrollment, so it is read
|
||||||
|
// from the profile rather than from the rows below.
|
||||||
const openPreview = async () => {
|
const { profile, isLoading: profileLoading } = useCurrentProfile();
|
||||||
setBusy(true);
|
const bsid = profile?.bsid ?? null;
|
||||||
try {
|
|
||||||
setPreviewUrl(URL.createObjectURL(await fetchCertificate()));
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
color: 'red',
|
|
||||||
title: 'Error',
|
|
||||||
message: err instanceof Error ? err.message : 'Could not load certificate',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
downloadBlob(await fetchCertificate(), 'biometric-enrollment-certificate.pdf');
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
color: 'red',
|
|
||||||
title: 'Error',
|
|
||||||
message: err instanceof Error ? err.message : 'Could not download certificate',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows = enrollments ?? [];
|
const rows = enrollments ?? [];
|
||||||
|
|
||||||
@@ -109,6 +60,45 @@ export function BiometricsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Paper withBorder radius="lg" p="xl">
|
<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 ? (
|
{isLoading ? (
|
||||||
<Group justify="center" py="lg">
|
<Group justify="center" py="lg">
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
@@ -147,34 +137,9 @@ export function BiometricsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
leftSection={busy ? <Loader size={12} /> : <IconInfoCircle size={12} />}
|
|
||||||
onClick={openPreview}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{t('biometrics.view', 'View certificate')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconDownload size={12} />}
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{t('biometrics.download', 'Download')}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<PdfPreviewModal
|
|
||||||
opened={!!previewUrl}
|
|
||||||
onClose={() => setPreviewUrl(null)}
|
|
||||||
url={previewUrl ?? ''}
|
|
||||||
title={t('biometrics.title', 'Biometrics')}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ import {
|
|||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
|
IconRefresh,
|
||||||
|
IconReplace,
|
||||||
IconShield,
|
IconShield,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||||
|
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
|
||||||
|
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
|
||||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
@@ -34,6 +38,8 @@ import {
|
|||||||
useGetMySeafarerDocumentsQuery,
|
useGetMySeafarerDocumentsQuery,
|
||||||
useGetPaymentCapabilitiesQuery,
|
useGetPaymentCapabilitiesQuery,
|
||||||
useLazyGetMySeafarerDocumentDownloadQuery,
|
useLazyGetMySeafarerDocumentDownloadQuery,
|
||||||
|
useRenewSeafarerDocumentMutation,
|
||||||
|
useReplaceSeafarerDocumentMutation,
|
||||||
type SeafarerDocument,
|
type SeafarerDocument,
|
||||||
type SeafarerDocumentStatus,
|
type SeafarerDocumentStatus,
|
||||||
} from "@ema-platform/api";
|
} from "@ema-platform/api";
|
||||||
@@ -73,9 +79,20 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
|||||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||||
const [bypass, { isLoading: bypassing }] = useBypassDocumentPaymentMutation();
|
const [bypass, { isLoading: bypassing }] = useBypassDocumentPaymentMutation();
|
||||||
const [getDownload, { isFetching: downloading }] = useLazyGetMySeafarerDocumentDownloadQuery();
|
const [getDownload, { isFetching: downloading }] = useLazyGetMySeafarerDocumentDownloadQuery();
|
||||||
|
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
|
||||||
|
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
|
||||||
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
||||||
const activeStep = stageIndexFor(document.status);
|
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() {
|
async function download() {
|
||||||
try {
|
try {
|
||||||
const { url } = await getDownload(document.id).unwrap();
|
const { url } = await getDownload(document.id).unwrap();
|
||||||
@@ -102,9 +119,16 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
|
<Group gap="xs">
|
||||||
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
{document.requestKind !== "NEW" && (
|
||||||
</Badge>
|
<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>
|
</Group>
|
||||||
|
|
||||||
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
|
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
|
||||||
@@ -165,9 +189,29 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
|||||||
{document.issueDate && ` on ${formatDate(document.issueDate)}`}
|
{document.issueDate && ` on ${formatDate(document.issueDate)}`}
|
||||||
{document.expiryDate && `, valid until ${formatDate(document.expiryDate)}`}.
|
{document.expiryDate && `, valid until ${formatDate(document.expiryDate)}`}.
|
||||||
</span>
|
</span>
|
||||||
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
<Group gap="xs">
|
||||||
Download PDF
|
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
||||||
</Button>
|
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>
|
</Group>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const am: Translations = {
|
|||||||
seaRecords: 'የባህር መዝገቦቼ',
|
seaRecords: 'የባህር መዝገቦቼ',
|
||||||
seaService: 'የባህር አገልግሎት',
|
seaService: 'የባህር አገልግሎት',
|
||||||
medical: 'የሕክምና የምስክር ወረቀት',
|
medical: 'የሕክምና የምስክር ወረቀት',
|
||||||
|
biometrics: 'ባዮሜትሪክ',
|
||||||
myApplication: 'ማመልከቻዬ',
|
myApplication: 'ማመልከቻዬ',
|
||||||
certificates: 'የምስክር ወረቀቶች',
|
certificates: 'የምስክር ወረቀቶች',
|
||||||
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
||||||
@@ -192,6 +193,7 @@ export const am: Translations = {
|
|||||||
search: 'ፈልግ',
|
search: 'ፈልግ',
|
||||||
searchPlaceholder: 'ቁጥር ወይም አመልካች',
|
searchPlaceholder: 'ቁጥር ወይም አመልካች',
|
||||||
status: 'ሁኔታ',
|
status: 'ሁኔታ',
|
||||||
|
kind: 'ዓይነት',
|
||||||
any: 'ማንኛውም',
|
any: 'ማንኛውም',
|
||||||
from: 'ከ',
|
from: 'ከ',
|
||||||
to: 'እስከ',
|
to: 'እስከ',
|
||||||
@@ -215,6 +217,9 @@ export const am: Translations = {
|
|||||||
applicant: 'አመልካች',
|
applicant: 'አመልካች',
|
||||||
progress: 'ደረጃ',
|
progress: 'ደረጃ',
|
||||||
applicationNumber: 'የማመልከቻ ቁጥር',
|
applicationNumber: 'የማመልከቻ ቁጥር',
|
||||||
|
kindNew: 'አዲስ',
|
||||||
|
kindRenewal: 'እድሳት',
|
||||||
|
kindReissue: 'ምትክ',
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
continue: 'ቀጥል',
|
continue: 'ቀጥል',
|
||||||
@@ -333,9 +338,31 @@ export const am: Translations = {
|
|||||||
profile: 'መገለጫ',
|
profile: 'መገለጫ',
|
||||||
address: 'አድራሻ',
|
address: 'አድራሻ',
|
||||||
operations: 'የስራ ዘርፍ',
|
operations: 'የስራ ዘርፍ',
|
||||||
|
signature: 'ፊርማ',
|
||||||
security: 'ደህንነት',
|
security: 'ደህንነት',
|
||||||
preferences: 'ምርጫዎች',
|
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: {
|
maritimeSection: {
|
||||||
title: 'የባህር ሙያ መገለጫ',
|
title: 'የባህር ሙያ መገለጫ',
|
||||||
subtitle: 'የባህር ሙያ ዝርዝሮችዎ',
|
subtitle: 'የባህር ሙያ ዝርዝሮችዎ',
|
||||||
@@ -800,6 +827,7 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
licensing: {
|
licensing: {
|
||||||
|
certificateSuperseded: 'ሰርተፍኬቱ ተተክቷል',
|
||||||
vesselPicker: {
|
vesselPicker: {
|
||||||
placeholder: 'የተመዘገበ መርከብ ይምረጡ',
|
placeholder: 'የተመዘገበ መርከብ ይምረጡ',
|
||||||
},
|
},
|
||||||
@@ -824,6 +852,8 @@ export const am: Translations = {
|
|||||||
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
|
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
|
||||||
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
|
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
|
||||||
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
|
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
|
||||||
|
reportDamaged: 'ጉዳት ሪፖርት ያድርጉ / ምትክ ይጠይቁ',
|
||||||
|
reissueFailed: 'የምትክ ጥያቄ መጀመር አልተቻለም',
|
||||||
status: {
|
status: {
|
||||||
ACTIVE: 'የፀና',
|
ACTIVE: 'የፀና',
|
||||||
EXPIRED: 'ጊዜው ያለፈበት',
|
EXPIRED: 'ጊዜው ያለፈበት',
|
||||||
@@ -858,6 +888,15 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pickup: {
|
||||||
|
title: 'የሰነድ መረከቢያ',
|
||||||
|
scheduledFor: 'ሰነድዎን ለመረከብ በ{{date}} ({{period}}) ወደ ቢሮ ይምጡ።',
|
||||||
|
setByOffice: 'ይህ ቀጠሮ በፈቃድ ጽ/ቤቱ ተይዟል።',
|
||||||
|
awaitingSchedule: 'ክፍያዎ ከተረጋገጠ በኋላ ፈቃድ ጽ/ቤቱ የመረከቢያ ቀን ይይዝልዎታል።',
|
||||||
|
morning: 'ጠዋት',
|
||||||
|
afternoon: 'ከሰዓት በኋላ',
|
||||||
|
},
|
||||||
|
|
||||||
certificates: {
|
certificates: {
|
||||||
title: "የእኔ የምስክር ወረቀቶች",
|
title: "የእኔ የምስክር ወረቀቶች",
|
||||||
loading: "የምስክር ወረቀቶች በመጫን ላይ…",
|
loading: "የምስክር ወረቀቶች በመጫን ላይ…",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const en = {
|
|||||||
seaRecords: 'My Sea Records',
|
seaRecords: 'My Sea Records',
|
||||||
seaService: 'Sea Service',
|
seaService: 'Sea Service',
|
||||||
medical: 'Medical Certificate',
|
medical: 'Medical Certificate',
|
||||||
|
biometrics: 'Biometrics',
|
||||||
myApplication: 'My Application',
|
myApplication: 'My Application',
|
||||||
vesselRegistration: 'Vessel Registration',
|
vesselRegistration: 'Vessel Registration',
|
||||||
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
||||||
@@ -192,6 +193,7 @@ export const en = {
|
|||||||
search: 'Search',
|
search: 'Search',
|
||||||
searchPlaceholder: 'Number or applicant',
|
searchPlaceholder: 'Number or applicant',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
|
kind: 'Type',
|
||||||
any: 'Any',
|
any: 'Any',
|
||||||
from: 'From',
|
from: 'From',
|
||||||
to: 'To',
|
to: 'To',
|
||||||
@@ -215,6 +217,9 @@ export const en = {
|
|||||||
applicant: 'Applicant',
|
applicant: 'Applicant',
|
||||||
progress: 'Progress',
|
progress: 'Progress',
|
||||||
applicationNumber: 'Application №',
|
applicationNumber: 'Application №',
|
||||||
|
kindNew: 'New',
|
||||||
|
kindRenewal: 'Renewal',
|
||||||
|
kindReissue: 'Replacement',
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
continue: 'Continue',
|
continue: 'Continue',
|
||||||
@@ -333,9 +338,32 @@ export const en = {
|
|||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
address: 'Address',
|
address: 'Address',
|
||||||
operations: 'Operations',
|
operations: 'Operations',
|
||||||
|
signature: 'Signature',
|
||||||
security: 'Security',
|
security: 'Security',
|
||||||
preferences: 'Preferences',
|
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: {
|
maritimeSection: {
|
||||||
title: 'Maritime Profile',
|
title: 'Maritime Profile',
|
||||||
subtitle: 'Your professional maritime details',
|
subtitle: 'Your professional maritime details',
|
||||||
@@ -800,6 +828,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
licensing: {
|
licensing: {
|
||||||
|
certificateSuperseded: 'Certificate superseded',
|
||||||
vesselPicker: {
|
vesselPicker: {
|
||||||
placeholder: 'Select a registered vessel',
|
placeholder: 'Select a registered vessel',
|
||||||
},
|
},
|
||||||
@@ -824,6 +853,8 @@ export const en = {
|
|||||||
renewDays_one: 'Renew — expires in {{count}} day',
|
renewDays_one: 'Renew — expires in {{count}} day',
|
||||||
renewDays_other: 'Renew — expires in {{count}} days',
|
renewDays_other: 'Renew — expires in {{count}} days',
|
||||||
renewFailed: 'Could not start the renewal',
|
renewFailed: 'Could not start the renewal',
|
||||||
|
reportDamaged: 'Report damaged / request replacement',
|
||||||
|
reissueFailed: 'Could not start the replacement request',
|
||||||
status: {
|
status: {
|
||||||
ACTIVE: 'Active',
|
ACTIVE: 'Active',
|
||||||
EXPIRED: 'Expired',
|
EXPIRED: 'Expired',
|
||||||
@@ -858,6 +889,15 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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: {
|
certificates: {
|
||||||
title: 'My Certificates',
|
title: 'My Certificates',
|
||||||
loading: 'Loading Certificates…',
|
loading: 'Loading Certificates…',
|
||||||
|
|||||||
@@ -9,20 +9,17 @@ export default defineConfig({
|
|||||||
// built-in default.
|
// built-in default.
|
||||||
envDir: "../../",
|
envDir: "../../",
|
||||||
cacheDir: "../../node_modules/.vite/apps/portal",
|
cacheDir: "../../node_modules/.vite/apps/portal",
|
||||||
// 3000, not the usual 4200: the Fayda redirect URI registered for local
|
server: { port: 4200, host: "localhost" },
|
||||||
// testing is http://localhost:3001/callback, and the provider matches it
|
|
||||||
// exactly. The API moves to 3001 to make room.
|
|
||||||
server: { port: 3000, host: "localhost" },
|
|
||||||
// server: {
|
// server: {
|
||||||
// port: 4200,
|
// port: 4200,
|
||||||
// proxy: {
|
// proxy: {
|
||||||
// '/api': {
|
// '/api': {
|
||||||
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
|
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
|
||||||
// changeOrigin: true,
|
// changeOrigin: true,
|
||||||
// },
|
// },
|
||||||
// },
|
// },
|
||||||
// },
|
// },
|
||||||
preview: { port: 3000, host: "localhost" },
|
preview: { port: 4200, host: "localhost" },
|
||||||
plugins: [react(), nxViteTsPaths()],
|
plugins: [react(), nxViteTsPaths()],
|
||||||
resolve: {
|
resolve: {
|
||||||
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
|
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { resolveSessionContext } from "../session";
|
|||||||
export const BASE_API_URL =
|
export const BASE_API_URL =
|
||||||
(import.meta as { env?: Record<string, string> }).env?.[
|
(import.meta as { env?: Record<string, string> }).env?.[
|
||||||
"VITE_BASE_API_URL"
|
"VITE_BASE_API_URL"
|
||||||
]?.trim() || "http://localhost:3001/api";
|
]?.trim() || "http://localhost:3000/api";
|
||||||
|
|
||||||
let _onTokenExpired: (() => Promise<string>) | null = null;
|
let _onTokenExpired: (() => Promise<string>) | null = null;
|
||||||
let _onAuthFailure: (() => void) | null = null;
|
let _onAuthFailure: (() => void) | null = null;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
InitiatePaymentResult,
|
InitiatePaymentResult,
|
||||||
IssuedLicense,
|
IssuedLicense,
|
||||||
Inspection,
|
Inspection,
|
||||||
|
IssuancePeriod,
|
||||||
LicenseApplication,
|
LicenseApplication,
|
||||||
LicenseCategoryDefinition,
|
LicenseCategoryDefinition,
|
||||||
LicenseStatus,
|
LicenseStatus,
|
||||||
@@ -26,6 +27,9 @@ import type {
|
|||||||
ExportResult,
|
ExportResult,
|
||||||
LicenseTemplate,
|
LicenseTemplate,
|
||||||
Paginated,
|
Paginated,
|
||||||
|
PickupAppointment,
|
||||||
|
PickupOffice,
|
||||||
|
PickupSlot,
|
||||||
QueueCounts,
|
QueueCounts,
|
||||||
QueueFilter,
|
QueueFilter,
|
||||||
Rank,
|
Rank,
|
||||||
@@ -75,6 +79,8 @@ const TAGS = [
|
|||||||
'SavedView',
|
'SavedView',
|
||||||
'LicenseTemplate',
|
'LicenseTemplate',
|
||||||
'DocumentRequirement',
|
'DocumentRequirement',
|
||||||
|
'PickupOffice',
|
||||||
|
'PickupAppointment',
|
||||||
'Department',
|
'Department',
|
||||||
'Rank',
|
'Rank',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -980,12 +986,12 @@ export const licensingApi = baseApi
|
|||||||
|
|
||||||
scheduleIssuance: builder.mutation<
|
scheduleIssuance: builder.mutation<
|
||||||
LicenseApplication,
|
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`,
|
url: `/license-application-review/${id}/schedule-issuance`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { scheduledDate },
|
body: { scheduledDate, scheduledPeriod },
|
||||||
}),
|
}),
|
||||||
invalidatesTags: (_r, error, { id }) =>
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
@@ -1000,6 +1006,104 @@ export const licensingApi = baseApi
|
|||||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
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
|
// --------------------------------------------------------- inspection
|
||||||
scheduleInspection: builder.mutation<
|
scheduleInspection: builder.mutation<
|
||||||
Inspection,
|
Inspection,
|
||||||
@@ -1175,6 +1279,17 @@ export const {
|
|||||||
useConfirmPaymentMutation,
|
useConfirmPaymentMutation,
|
||||||
useScheduleIssuanceMutation,
|
useScheduleIssuanceMutation,
|
||||||
useIssueCertificateMutation,
|
useIssueCertificateMutation,
|
||||||
|
useGetPickupOfficesQuery,
|
||||||
|
useGetPickupSlotsQuery,
|
||||||
|
useSchedulePickupMutation,
|
||||||
|
useReschedulePickupMutation,
|
||||||
|
useGetPickupAppointmentsForApplicationQuery,
|
||||||
|
useGetPickupWorklistQuery,
|
||||||
|
useCheckInPickupMutation,
|
||||||
|
useMarkPickupIssuedMutation,
|
||||||
|
useMarkPickupNoShowMutation,
|
||||||
|
useCreatePickupOfficeMutation,
|
||||||
|
useUpdatePickupOfficeMutation,
|
||||||
useScheduleInspectionMutation,
|
useScheduleInspectionMutation,
|
||||||
useRescheduleInspectionMutation,
|
useRescheduleInspectionMutation,
|
||||||
useGetInspectionsQuery,
|
useGetInspectionsQuery,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
import { resolveTokenFromStorage } from '../../session';
|
import { resolveTokenFromStorage } from '../../session';
|
||||||
|
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
|
||||||
import type {
|
import type {
|
||||||
|
ApplicationKind,
|
||||||
Bilingual,
|
Bilingual,
|
||||||
FamilyKind,
|
FamilyKind,
|
||||||
FieldCondition,
|
FieldCondition,
|
||||||
@@ -11,7 +13,10 @@ import type {
|
|||||||
ValidationIssue,
|
ValidationIssue,
|
||||||
} from './licensing.types';
|
} from './licensing.types';
|
||||||
|
|
||||||
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
|
/** 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.
|
* Uploads a document straight to the API.
|
||||||
@@ -450,12 +455,28 @@ export function buildWizardSteps(
|
|||||||
* instead of showing an empty page.
|
* instead of showing an empty page.
|
||||||
*/
|
*/
|
||||||
hasStaff?: boolean;
|
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
|
/** Active UI language. Components get this from `useLocalized`; this is a
|
||||||
* pure function, so the caller passes `i18n.language` through. */
|
* pure function, so the caller passes `i18n.language` through. */
|
||||||
language?: string;
|
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[] {
|
): WizardStep[] {
|
||||||
|
const kind = options?.applicationKind ?? 'NEW';
|
||||||
const visible = [...sections]
|
const visible = [...sections]
|
||||||
|
.filter((section) => sectionAppliesToKind(section, kind))
|
||||||
.filter((section) => conditionHolds(section.showWhen, formData))
|
.filter((section) => conditionHolds(section.showWhen, formData))
|
||||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||||
|
|
||||||
@@ -510,7 +531,9 @@ export function buildWizardSteps(
|
|||||||
...(options?.hasStaff === false
|
...(options?.hasStaff === false
|
||||||
? []
|
? []
|
||||||
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
|
: [{ 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 },
|
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ export type LicenseStatus =
|
|||||||
| "EXAM_PASSED"
|
| "EXAM_PASSED"
|
||||||
| "EXAM_FAILED";
|
| "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 =
|
export type FormFieldType =
|
||||||
| "TEXT"
|
| "TEXT"
|
||||||
@@ -120,6 +123,12 @@ export interface FormSectionConfig {
|
|||||||
group?: string;
|
group?: string;
|
||||||
/** Position of the group in the stepper; lowest value in a group wins. */
|
/** Position of the group in the stepper; lowest value in a group wins. */
|
||||||
groupOrder?: number;
|
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. */
|
/** Grouping the portal organises the licence catalogue by. */
|
||||||
@@ -349,6 +358,7 @@ export interface LicenseApplication {
|
|||||||
issuedLicenseId: string | null;
|
issuedLicenseId: string | null;
|
||||||
/** Set once an officer schedules pickup for a document requiring in-person handover. */
|
/** Set once an officer schedules pickup for a document requiring in-person handover. */
|
||||||
scheduledIssuanceDate: string | null;
|
scheduledIssuanceDate: string | null;
|
||||||
|
scheduledIssuancePeriod: IssuancePeriod | null;
|
||||||
scheduledBy: string | null;
|
scheduledBy: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -440,6 +450,13 @@ export interface ApplicationApplicant {
|
|||||||
|
|
||||||
export interface ApplicationDetail {
|
export interface ApplicationDetail {
|
||||||
application: LicenseApplication;
|
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[];
|
relatedApplications?: LicenseApplication[];
|
||||||
/** Null when the applicant has no profile row (never expected in practice). */
|
/** Null when the applicant has no profile row (never expected in practice). */
|
||||||
applicant: ApplicationApplicant | null;
|
applicant: ApplicationApplicant | null;
|
||||||
@@ -466,6 +483,50 @@ export interface Inspection {
|
|||||||
findings: string | null;
|
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 {
|
export interface AppNotification {
|
||||||
id: string;
|
id: string;
|
||||||
subject: Bilingual;
|
subject: Bilingual;
|
||||||
@@ -482,6 +543,7 @@ export interface QueueFilter {
|
|||||||
licenseTypeId?: string;
|
licenseTypeId?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: LicenseStatus[];
|
status?: LicenseStatus[];
|
||||||
|
kind?: ApplicationKind;
|
||||||
/** Officer uuid, or the literal 'unassigned'. */
|
/** Officer uuid, or the literal 'unassigned'. */
|
||||||
assignee?: string;
|
assignee?: string;
|
||||||
submittedFrom?: string;
|
submittedFrom?: string;
|
||||||
@@ -747,6 +809,8 @@ export interface IssuedLicense {
|
|||||||
* configuration.
|
* configuration.
|
||||||
*/
|
*/
|
||||||
renewable?: boolean;
|
renewable?: boolean;
|
||||||
|
/** Whether a Damaged/Reissue replacement may be requested for this licence. */
|
||||||
|
reissuable?: boolean;
|
||||||
verificationCode: string;
|
verificationCode: string;
|
||||||
certificateFileKey: string | null;
|
certificateFileKey: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
SeafarerDocument,
|
SeafarerDocument,
|
||||||
SeafarerDocumentDetail,
|
SeafarerDocumentDetail,
|
||||||
SeafarerDocumentKind,
|
SeafarerDocumentKind,
|
||||||
|
SeafarerDocumentRequestKind,
|
||||||
SeafarerDocumentRow,
|
SeafarerDocumentRow,
|
||||||
SeafarerDocumentStatus,
|
SeafarerDocumentStatus,
|
||||||
} from './seafarer-document.types';
|
} from './seafarer-document.types';
|
||||||
@@ -14,6 +15,7 @@ const item = (id: string) => ({ type: TAG, id }) as const;
|
|||||||
|
|
||||||
export interface SeafarerDocumentListFilter {
|
export interface SeafarerDocumentListFilter {
|
||||||
kind?: SeafarerDocumentKind;
|
kind?: SeafarerDocumentKind;
|
||||||
|
requestKind?: SeafarerDocumentRequestKind;
|
||||||
status?: SeafarerDocumentStatus;
|
status?: SeafarerDocumentStatus;
|
||||||
search?: string;
|
search?: string;
|
||||||
take?: number;
|
take?: number;
|
||||||
@@ -63,6 +65,16 @@ export const seafarerDocumentApi = baseApi
|
|||||||
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
|
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
|
// --------------------------------------------------------------- review
|
||||||
listSeafarerDocuments: builder.query<
|
listSeafarerDocuments: builder.query<
|
||||||
{ total: number; items: SeafarerDocumentRow[] },
|
{ total: number; items: SeafarerDocumentRow[] },
|
||||||
@@ -121,6 +133,8 @@ export const {
|
|||||||
useInitiateDocumentPaymentMutation,
|
useInitiateDocumentPaymentMutation,
|
||||||
useGetDocumentPaymentQuery,
|
useGetDocumentPaymentQuery,
|
||||||
useBypassDocumentPaymentMutation,
|
useBypassDocumentPaymentMutation,
|
||||||
|
useRenewSeafarerDocumentMutation,
|
||||||
|
useReplaceSeafarerDocumentMutation,
|
||||||
useListSeafarerDocumentsQuery,
|
useListSeafarerDocumentsQuery,
|
||||||
useGetSeafarerDocumentReviewQuery,
|
useGetSeafarerDocumentReviewQuery,
|
||||||
useLazyGetSeafarerDocumentReviewDownloadQuery,
|
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> = {
|
export const SEAFARER_DOCUMENT_KIND_LABELS: Record<SeafarerDocumentKind, string> = {
|
||||||
SEAMAN_BOOK: 'Seaman Book',
|
SEAMAN_BOOK: 'Seaman Book',
|
||||||
BTC_BASIC_TRAINING: 'Basic Training Certificate',
|
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> = {
|
export const SEAFARER_DOCUMENT_STATUS_LABELS: Record<SeafarerDocumentStatus, string> = {
|
||||||
AWAITING_REGISTRATION: 'Awaiting Registration',
|
AWAITING_REGISTRATION: 'Awaiting Registration',
|
||||||
PAYMENT_PENDING: 'Payment Pending',
|
PAYMENT_PENDING: 'Payment Pending',
|
||||||
|
|||||||
@@ -12,14 +12,19 @@ export type SeafarerDocumentStatus =
|
|||||||
| 'REJECTED'
|
| 'REJECTED'
|
||||||
| 'CANCELLED';
|
| '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 {
|
export interface SeafarerDocument {
|
||||||
id: string;
|
id: string;
|
||||||
kind: SeafarerDocumentKind;
|
kind: SeafarerDocumentKind;
|
||||||
|
requestKind: SeafarerDocumentRequestKind;
|
||||||
requestNumber: string;
|
requestNumber: string;
|
||||||
applicantUserId: string;
|
applicantUserId: string;
|
||||||
profileId: string | null;
|
profileId: string | null;
|
||||||
seafarerRegistrationId: string | null;
|
seafarerRegistrationId: string | null;
|
||||||
|
previousDocumentId: string | null;
|
||||||
status: SeafarerDocumentStatus;
|
status: SeafarerDocumentStatus;
|
||||||
feeAmount: number | null;
|
feeAmount: number | null;
|
||||||
feeCurrency: string;
|
feeCurrency: string;
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ export const DEPARTMENT_OPTIONS = [
|
|||||||
{ value: 'CATERING', label: 'Catering' },
|
{ 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 = [
|
export const HAIR_COLOR_OPTIONS = [
|
||||||
{ value: 'BLACK', label: 'Black' },
|
{ value: 'BLACK', label: 'Black' },
|
||||||
{ value: 'BROWN', label: 'Brown' },
|
{ value: 'BROWN', label: 'Brown' },
|
||||||
@@ -117,6 +130,8 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
|||||||
export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = {
|
export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = {
|
||||||
DRAFT: 'Draft',
|
DRAFT: 'Draft',
|
||||||
SUBMITTED: 'Submitted',
|
SUBMITTED: 'Submitted',
|
||||||
|
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
|
||||||
|
UNDER_REVIEW: 'Under Review',
|
||||||
RESUBMIT_REQUIRED: 'Corrections Requested',
|
RESUBMIT_REQUIRED: 'Corrections Requested',
|
||||||
APPROVED: 'Approved',
|
APPROVED: 'Approved',
|
||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
@@ -139,6 +154,8 @@ export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
|
|||||||
> = {
|
> = {
|
||||||
DRAFT: 'neutral',
|
DRAFT: 'neutral',
|
||||||
SUBMITTED: 'info',
|
SUBMITTED: 'info',
|
||||||
|
AWAITING_BIOMETRICS: 'pending',
|
||||||
|
UNDER_REVIEW: 'info',
|
||||||
RESUBMIT_REQUIRED: 'pending',
|
RESUBMIT_REQUIRED: 'pending',
|
||||||
APPROVED: 'success',
|
APPROVED: 'success',
|
||||||
REJECTED: 'danger',
|
REJECTED: 'danger',
|
||||||
@@ -158,6 +175,7 @@ export const SEAFARER_REGISTRATION_FIELD_LABELS: Record<keyof SeafarerRegistrati
|
|||||||
passportNumber: 'Passport Number',
|
passportNumber: 'Passport Number',
|
||||||
passportExpiry: 'Passport Expiry Date',
|
passportExpiry: 'Passport Expiry Date',
|
||||||
department: 'Department',
|
department: 'Department',
|
||||||
|
tier: 'Certificate Limitation',
|
||||||
locationId: 'Location',
|
locationId: 'Location',
|
||||||
permanentAddress: 'Permanent Address',
|
permanentAddress: 'Permanent Address',
|
||||||
currentAddress: 'Current Address',
|
currentAddress: 'Current Address',
|
||||||
@@ -192,7 +210,7 @@ export const SEAFARER_REGISTRATION_SECTIONS: {
|
|||||||
{
|
{
|
||||||
key: 'identity',
|
key: 'identity',
|
||||||
title: 'Identity',
|
title: 'Identity',
|
||||||
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department'],
|
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department', 'tier'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'address',
|
key: 'address',
|
||||||
@@ -220,6 +238,7 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
|
|||||||
gender: GENDER_OPTIONS,
|
gender: GENDER_OPTIONS,
|
||||||
maritalStatus: MARITAL_STATUS_OPTIONS,
|
maritalStatus: MARITAL_STATUS_OPTIONS,
|
||||||
department: DEPARTMENT_OPTIONS,
|
department: DEPARTMENT_OPTIONS,
|
||||||
|
tier: RANK_TIER_OPTIONS,
|
||||||
hairColor: HAIR_COLOR_OPTIONS,
|
hairColor: HAIR_COLOR_OPTIONS,
|
||||||
eyeColor: EYE_COLOR_OPTIONS,
|
eyeColor: EYE_COLOR_OPTIONS,
|
||||||
bloodType: BLOOD_TYPE_OPTIONS,
|
bloodType: BLOOD_TYPE_OPTIONS,
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
|
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 =
|
export type SeafarerRegistrationStatus =
|
||||||
| 'DRAFT'
|
| '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'
|
| 'SUBMITTED'
|
||||||
| 'RESUBMIT_REQUIRED'
|
| 'RESUBMIT_REQUIRED'
|
||||||
| 'APPROVED'
|
| 'APPROVED'
|
||||||
@@ -30,6 +39,11 @@ export interface SeafarerRegistrationAnswers {
|
|||||||
passportNumber: string | null;
|
passportNumber: string | null;
|
||||||
passportExpiry: string | null;
|
passportExpiry: string | null;
|
||||||
department: SeafarerDepartment | 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;
|
locationId: string | null;
|
||||||
permanentAddress: string | null;
|
permanentAddress: string | null;
|
||||||
currentAddress: string | null;
|
currentAddress: string | null;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const SESSION_HEADER_KEYS = {
|
|||||||
* Which app this bundle is, so it reads its own session and no one else's.
|
* 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
|
* Set by each app's store via `configureSessionScope`. Cookies ignore the
|
||||||
* port, so `localhost:3001` 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
|
* scope the backoffice would happily authenticate as whoever last signed into
|
||||||
* the portal, and render a staff console with an applicant's permissions.
|
* the portal, and render a staff console with an applicant's permissions.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export * from "./lib/layout/SkipLink";
|
|||||||
export * from "./lib/input/PasswordRequirements";
|
export * from "./lib/input/PasswordRequirements";
|
||||||
export * from "./lib/input/CountrySelect";
|
export * from "./lib/input/CountrySelect";
|
||||||
export * from "./lib/input/PhoneInput";
|
export * from "./lib/input/PhoneInput";
|
||||||
|
export * from "./lib/input/canvas-point";
|
||||||
|
export * from "./lib/components/SignaturePad";
|
||||||
export * from "./lib/input/phone";
|
export * from "./lib/input/phone";
|
||||||
export * from "./lib/data/AdvancedTable";
|
export * from "./lib/data/AdvancedTable";
|
||||||
export * from "./lib/data/WaitingFor";
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
2531
package-lock.json
generated
2531
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user