Merge pull request #44 from Tria-plc/logestic_chnage

Logestic chnage
This commit is contained in:
fitsum tesfaye
2026-08-28 16:35:25 +03:00
committed by GitHub
27 changed files with 1228 additions and 30 deletions

View File

@@ -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>
), ),
}, },
{ {

View File

@@ -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 ?? ""}

View File

@@ -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);
}, },

View File

@@ -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>
);
},
};
}

View File

@@ -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>
),
},
];
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}

View File

@@ -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}

View File

@@ -99,6 +99,8 @@ export const am: Translations = {
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ", seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
applications: "ማመልከቻዎች", applications: "ማመልከቻዎች",
paymentConfig: "የክፍያ ውቅረት", paymentConfig: "የክፍያ ውቅረት",
pickupDesk: "የመረከቢያ ዴስክ",
pickupOffices: "የመረከቢያ ቢሮዎች",
analytics: "ትንታኔ", analytics: "ትንታኔ",
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ", seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
medicalVerification: "የሕክምና ማረጋገጫ", medicalVerification: "የሕክምና ማረጋገጫ",
@@ -872,6 +874,12 @@ export const am: Translations = {
type: "ዓይነት", type: "ዓይነት",
anyType: "ማንኛውም", anyType: "ማንኛውም",
typeCol: "ዓይነት", typeCol: "ዓይነት",
kind: "የማመልከቻ ዓይነት",
kindValues: {
NEW: "አዲስ",
RENEWAL: "እድሳት",
REISSUE: "ምትክ",
},
statusCol: "ሁኔታ", statusCol: "ሁኔታ",
statusValues: { statusValues: {
DRAFT: "ረቂቅ", DRAFT: "ረቂቅ",
@@ -947,6 +955,7 @@ export const am: Translations = {
}, },
review: { review: {
certificateSuperseded: "ሰርተፍኬቱ ተተክቷል",
summary: "ማጠቃለያ", summary: "ማጠቃለያ",
officer: "ሹም", officer: "ሹም",
supervisor: "የበላይ ኃላፊ", supervisor: "የበላይ ኃላፊ",

View File

@@ -98,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',
@@ -879,6 +881,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',
@@ -956,6 +964,7 @@ export const en = {
}, },
review: { review: {
certificateSuperseded: 'Certificate superseded',
summary: 'Summary', summary: 'Summary',
officer: 'Officer', officer: 'Officer',
supervisor: 'Supervisor', supervisor: 'Supervisor',

View File

@@ -2,6 +2,8 @@ import {
IconAnchor, IconAnchor,
IconArrowsExchange, IconArrowsExchange,
IconBook2, IconBook2,
IconBuildingWarehouse,
IconCalendarEvent,
IconChartBar, IconChartBar,
IconClipboardList, IconClipboardList,
IconClipboardText, IconClipboardText,
@@ -315,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],
},
], ],
}, },
{ {

View File

@@ -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';
@@ -106,6 +108,8 @@ 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(BIOMETRIC_ENROLLMENT, <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.

View File

@@ -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>
); );
} }

View File

@@ -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;

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)} )}

View File

@@ -193,6 +193,7 @@ export const am: Translations = {
search: 'ፈልግ', search: 'ፈልግ',
searchPlaceholder: 'ቁጥር ወይም አመልካች', searchPlaceholder: 'ቁጥር ወይም አመልካች',
status: 'ሁኔታ', status: 'ሁኔታ',
kind: 'ዓይነት',
any: 'ማንኛውም', any: 'ማንኛውም',
from: 'ከ', from: 'ከ',
to: 'እስከ', to: 'እስከ',
@@ -216,6 +217,9 @@ export const am: Translations = {
applicant: 'አመልካች', applicant: 'አመልካች',
progress: 'ደረጃ', progress: 'ደረጃ',
applicationNumber: 'የማመልከቻ ቁጥር', applicationNumber: 'የማመልከቻ ቁጥር',
kindNew: 'አዲስ',
kindRenewal: 'እድሳት',
kindReissue: 'ምትክ',
}, },
actions: { actions: {
continue: 'ቀጥል', continue: 'ቀጥል',
@@ -801,6 +805,7 @@ export const am: Translations = {
}, },
licensing: { licensing: {
certificateSuperseded: 'ሰርተፍኬቱ ተተክቷል',
vesselPicker: { vesselPicker: {
placeholder: 'የተመዘገበ መርከብ ይምረጡ', placeholder: 'የተመዘገበ መርከብ ይምረጡ',
}, },
@@ -825,6 +830,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: 'ጊዜው ያለፈበት',
@@ -859,6 +866,15 @@ export const am: Translations = {
}, },
}, },
pickup: {
title: 'የሰነድ መረከቢያ',
scheduledFor: 'ሰነድዎን ለመረከብ በ{{date}} ({{period}}) ወደ ቢሮ ይምጡ።',
setByOffice: 'ይህ ቀጠሮ በፈቃድ ጽ/ቤቱ ተይዟል።',
awaitingSchedule: 'ክፍያዎ ከተረጋገጠ በኋላ ፈቃድ ጽ/ቤቱ የመረከቢያ ቀን ይይዝልዎታል።',
morning: 'ጠዋት',
afternoon: 'ከሰዓት በኋላ',
},
certificates: { certificates: {
title: "የእኔ የምስክር ወረቀቶች", title: "የእኔ የምስክር ወረቀቶች",
loading: "የምስክር ወረቀቶች በመጫን ላይ…", loading: "የምስክር ወረቀቶች በመጫን ላይ…",

View File

@@ -193,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',
@@ -216,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',
@@ -801,6 +805,7 @@ export const en = {
}, },
licensing: { licensing: {
certificateSuperseded: 'Certificate superseded',
vesselPicker: { vesselPicker: {
placeholder: 'Select a registered vessel', placeholder: 'Select a registered vessel',
}, },
@@ -825,6 +830,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',
@@ -859,6 +866,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…',

View File

@@ -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,

View File

@@ -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 },
]; ];
} }

View File

@@ -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;
} }

View File

@@ -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,

View File

@@ -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',

View File

@@ -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;