feat: add pickup appointment scheduling and management features

- Introduced new pickup appointment functionalities in the licensing API, including scheduling, rescheduling, and managing pickup offices.
- Added UI components for the pickup desk, allowing officers to check in, issue documents, and manage no-show appointments.
- Implemented a new page for managing pickup offices with CRUD operations.
- Enhanced internationalization support for new pickup-related terms and messages.
- Updated licensing types to include new application kinds and issuance periods.
- Created a read-only panel for displaying scheduled pickup appointments in the licensing component.
This commit is contained in:
fitse-yotor
2026-08-28 15:49:05 +03:00
parent 12e7927d01
commit 687e3df3c2
27 changed files with 1233 additions and 30 deletions

View File

@@ -10,7 +10,7 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { IconDownload, IconRefresh } from '@tabler/icons-react';
import { IconAlertTriangle, IconDownload, IconRefresh } from '@tabler/icons-react';
import {
extractErrorMessage,
useLocalized,
@@ -59,6 +59,36 @@ export function useRenewLicense() {
return { renewLicense, isRenewing };
}
/**
* Damaged/Reissue reuses the same wizard, application kind REISSUE — the
* "Damage Information" step and the Reissue document set only appear because
* the created application carries that kind, exactly the way RENEWAL's own
* fields do above.
*/
export function useReissueLicense() {
const navigate = useNavigate();
const { t } = useTranslation();
const [createApplication, { isLoading: isReissuing }] =
useCreateApplicationMutation();
async function reissueLicense(license: IssuedLicense) {
const typeKey = license.licenseType?.key;
if (!typeKey) return;
try {
const application = await createApplication({
licenseType: typeKey,
kind: 'REISSUE',
previousLicenseId: license.id,
}).unwrap();
navigate(`/licensing/${typeKey}/applications/${application.id}`);
} catch (err) {
notify.error(extractErrorMessage(err), t('licensing.card.reissueFailed'));
}
}
return { reissueLicense, isReissuing };
}
function daysUntil(date: string): number {
const ms = new Date(date).getTime() - Date.now();
return Math.ceil(ms / 86_400_000);
@@ -68,20 +98,25 @@ export function LicenseCard({
license,
isDownloading,
isRenewing,
isReissuing,
onDownload,
onRenew,
onReissue,
}: {
license: IssuedLicense;
isDownloading: boolean;
isRenewing: boolean;
isReissuing?: boolean;
onDownload: () => void;
onRenew: () => void;
onReissue?: () => void;
}) {
// The API computes both in the authority's timezone; the local fallbacks are
// only for a cached response from before those fields existed.
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
const expired = license.status === 'EXPIRED' || days < 0;
const renewable = license.renewable ?? false;
const reissuable = license.reissuable ?? false;
const showDate = useDateDisplayer();
const localized = useLocalized();
const { t } = useTranslation();
@@ -157,6 +192,25 @@ export function LicenseCard({
</Button>
</RequirePermission>
)}
{/* Damaged/Reissue has no window — a lost or damaged document can be
replaced at any point in its validity, unlike Renewal above. */}
{reissuable && onReissue && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
<Button
fullWidth
mt="xs"
size="xs"
variant="subtle"
color="gray"
loading={isReissuing}
leftSection={<IconAlertTriangle size={14} />}
onClick={onReissue}
>
{t('licensing.card.reportDamaged')}
</Button>
</RequirePermission>
)}
</Card>
);
}

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,
Alert,
Badge,
Box,
Button,
Card,
Container,
@@ -65,6 +66,7 @@ import {
PORTAL_PERMISSIONS,
RequirePermission,
useCurrentProfile,
usePermissions,
} from "@ema-platform/auth";
import { ApplicationSummary } from "../components/ApplicationSummary";
import {
@@ -72,6 +74,7 @@ import {
fillFromVessel,
} from "../components/ConfigDrivenSection";
import { DocumentSlots } from "../components/DocumentSlots";
import { PickupSchedulingPanel } from "../components/PickupSchedulingPanel";
import { StaffEvidence } from "../components/StaffEvidence";
import { useAppSelector } from "../../../store/hooks";
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
@@ -115,9 +118,14 @@ export function LicenseApplicationPage() {
const { data: config, isLoading: loadingConfig } =
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
const { profile } = useCurrentProfile();
const { can: hasPermission, known: permissionsKnown } = usePermissions();
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
// here rather than deeper down since it's the shared source of draft state.
const { data: vessels } = useGetMyVesselsQuery();
// Skipped for accounts without VIEW_OWN_VESSELS (e.g. freight forwarders):
// the API 403s for them, since vessels belong to VESSEL_OWNER accounts.
const { data: vessels } = useGetMyVesselsQuery(undefined, {
skip: !permissionsKnown || !hasPermission([PORTAL_PERMISSIONS.VIEW_OWN_VESSELS]),
});
const [createApplication] = useCreateApplicationMutation();
const [appId, setAppId] = useState<string | undefined>(applicationId);
@@ -342,14 +350,20 @@ export function LicenseApplicationPage() {
);
// Sections that share a group collapse onto one step, so the stepper stays
// short instead of showing a page per section.
// short instead of showing a page per section. A Damaged/Reissue
// application skips Staff and Documents outright — it asks nothing beyond
// the Damage Information step, regardless of what the licence type
// otherwise requires for a new application or renewal.
const isReissue = application?.kind === 'REISSUE';
const steps = useMemo(
() =>
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
hasStaff: isReissue ? false : (config?.staffRoleRequirements?.length ?? 0) > 0,
hasDocuments: !isReissue,
language: i18n.language,
applicationKind: application?.kind,
}),
[config, draft, i18n.language],
[config, draft, i18n.language, application?.kind, isReissue],
);
const sections = useMemo(
() => steps.flatMap((step) => step.sections),
@@ -642,6 +656,11 @@ export function LicenseApplicationPage() {
>
{STATUS_LABELS[application.status]}
</Badge>
{detail?.issuedLicenseStatus === "SUPERSEDED" && (
<Badge size="sm" variant="light" color="gray">
{t("licensing.certificateSuperseded", "Certificate superseded")}
</Badge>
)}
</Group>
</div>
<Group gap="md" align="center">
@@ -681,6 +700,17 @@ export function LicenseApplicationPage() {
</Alert>
)}
{config.licenseType.requiresIssuanceScheduling &&
(application.status === "PAYMENT_CONFIRMED" ||
application.status === "SCHEDULED") && (
<Box mb="md">
<PickupSchedulingPanel
scheduledDate={application.scheduledIssuanceDate}
scheduledPeriod={application.scheduledIssuancePeriod}
/>
</Box>
)}
{showSummary && editableWhileSubmitted && (
<Alert
color="blue"

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 { AdvancedColumn } from '@ema-platform/ui';
import {
@@ -6,10 +6,23 @@ import {
STATUS_PROGRESS,
applicantOrCompanyName,
localized,
type ApplicationKind,
type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api';
const KIND_LABEL: Record<ApplicationKind, string> = {
NEW: 'applications.table.kindNew',
RENEWAL: 'applications.table.kindRenewal',
REISSUE: 'applications.table.kindReissue',
};
const KIND_COLOR: Record<ApplicationKind, string> = {
NEW: 'blue',
RENEWAL: 'teal',
REISSUE: 'orange',
};
export function applicationColumns(
t: TFunction,
deps: {
@@ -23,9 +36,16 @@ export function applicationColumns(
header: t('applications.table.licence'),
cell: ({ row }) => (
<Box>
<Text size="sm" fw={600}>
{localized(row.original.licenseType?.name, deps.language) || '—'}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{localized(row.original.licenseType?.name, deps.language) || '—'}
</Text>
{row.original.kind !== 'NEW' && (
<Badge size="xs" variant="light" color={KIND_COLOR[row.original.kind]}>
{t(KIND_LABEL[row.original.kind])}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{row.original.applicationNumber}
</Text>

View File

@@ -32,7 +32,7 @@ import {
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { LicenseCatalogue } from '../../components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../../components/LicenseCard';
import { LicenseCard, useReissueLicense, useRenewLicense } from '../../components/LicenseCard';
import { useApplicationPayment } from '../../../payments/hooks/useApplicationPayment';
import { notifications } from '@mantine/notifications';
import {
@@ -47,6 +47,7 @@ import {
useGetMyLicensesQuery,
useGetPaymentCapabilitiesQuery,
useRetakeExamMutation,
type ApplicationKind,
type LicenseStatus,
} from '@ema-platform/api';
import {
@@ -102,6 +103,7 @@ export function MyApplicationsPage() {
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
const { reissueLicense, isReissuing } = useReissueLicense();
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
const { can } = usePermissions();
@@ -207,10 +209,13 @@ export function MyApplicationsPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<LicenseStatus | null>(null);
const [kindFilter, setKindFilter] = useState<ApplicationKind | null>(null);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [bucketFilter, setBucketFilter] = useState<Bucket>(null);
const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo || bucketFilter);
const hasFilters = Boolean(
search || statusFilter || kindFilter || dateFrom || dateTo || bucketFilter,
);
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
const counts = useMemo(() => {
@@ -228,6 +233,7 @@ export function MyApplicationsPage() {
if (!haystack.includes(q)) return false;
}
if (statusFilter && app.status !== statusFilter) return false;
if (kindFilter && app.kind !== kindFilter) return false;
// Drafts have no submittedAt, so date filtering falls back to createdAt
// rather than silently excluding every draft from a date-ranged search.
const at = app.submittedAt ?? app.createdAt;
@@ -245,13 +251,14 @@ export function MyApplicationsPage() {
const bAt = b.submittedAt ?? b.createdAt;
return aAt < bAt ? 1 : aAt > bAt ? -1 : 0;
});
}, [allItems, search, statusFilter, dateFrom, dateTo, bucketFilter]);
}, [allItems, search, statusFilter, kindFilter, dateFrom, dateTo, bucketFilter]);
const page = paginate(items);
function clearFilters() {
setSearch('');
setStatusFilter(null);
setKindFilter(null);
setDateFrom('');
setDateTo('');
setBucketFilter(null);
@@ -385,6 +392,22 @@ export function MyApplicationsPage() {
clearable
w={200}
/>
<Select
label={t('applications.filters.kind')}
placeholder={t('applications.filters.any')}
data={[
{ value: 'NEW', label: t('applications.table.kindNew') },
{ value: 'RENEWAL', label: t('applications.table.kindRenewal') },
{ value: 'REISSUE', label: t('applications.table.kindReissue') },
]}
value={kindFilter}
onChange={(v) => {
setKindFilter(v as ApplicationKind | null);
setPageIndex(0);
}}
clearable
w={160}
/>
<AmharicDatePicker
label={t('applications.filters.from')}
value={dateFrom}
@@ -475,8 +498,10 @@ export function MyApplicationsPage() {
license={license}
isDownloading={isDownloadingCert}
isRenewing={isRenewing}
isReissuing={isReissuing}
onDownload={() => downloadCertificate(license.id)}
onRenew={() => renewLicense(license)}
onReissue={() => reissueLicense(license)}
/>
))}
</SimpleGrid>

View File

@@ -22,11 +22,15 @@ import {
IconFileDescription,
IconInfoCircle,
IconPrinter,
IconRefresh,
IconReplace,
IconShield,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage,
@@ -34,6 +38,8 @@ import {
useGetMySeafarerDocumentsQuery,
useGetPaymentCapabilitiesQuery,
useLazyGetMySeafarerDocumentDownloadQuery,
useRenewSeafarerDocumentMutation,
useReplaceSeafarerDocumentMutation,
type SeafarerDocument,
type SeafarerDocumentStatus,
} from "@ema-platform/api";
@@ -73,9 +79,20 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const [bypass, { isLoading: bypassing }] = useBypassDocumentPaymentMutation();
const [getDownload, { isFetching: downloading }] = useLazyGetMySeafarerDocumentDownloadQuery();
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
const activeStep = stageIndexFor(document.status);
async function renewOrReplace(action: () => ReturnType<typeof renew>) {
try {
await action().unwrap();
onChanged();
} catch (err) {
notifications.show({ color: "red", title: "Request failed", message: extractErrorMessage(err) });
}
}
async function download() {
try {
const { url } = await getDownload(document.id).unwrap();
@@ -102,9 +119,16 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
</Text>
</div>
</Group>
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
<Group gap="xs">
{document.requestKind !== "NEW" && (
<Badge color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[document.requestKind]} variant="outline" size="lg">
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]}
</Badge>
)}
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
</Group>
</Group>
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
@@ -165,9 +189,29 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
{document.issueDate && ` on ${formatDate(document.issueDate)}`}
{document.expiryDate && `, valid until ${formatDate(document.expiryDate)}`}.
</span>
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF
</Button>
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF
</Button>
<Button
size="xs"
variant="default"
leftSection={<IconRefresh size={14} />}
loading={renewing}
onClick={() => renewOrReplace(() => renew(document.id))}
>
Renew
</Button>
<Button
size="xs"
variant="default"
leftSection={<IconReplace size={14} />}
loading={replacing}
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
>
Report Lost/Damaged
</Button>
</Group>
</Group>
</Alert>
)}

View File

@@ -192,6 +192,7 @@ export const am: Translations = {
search: 'ፈልግ',
searchPlaceholder: 'ቁጥር ወይም አመልካች',
status: 'ሁኔታ',
kind: 'ዓይነት',
any: 'ማንኛውም',
from: 'ከ',
to: 'እስከ',
@@ -215,6 +216,9 @@ export const am: Translations = {
applicant: 'አመልካች',
progress: 'ደረጃ',
applicationNumber: 'የማመልከቻ ቁጥር',
kindNew: 'አዲስ',
kindRenewal: 'እድሳት',
kindReissue: 'ምትክ',
},
actions: {
continue: 'ቀጥል',
@@ -777,6 +781,7 @@ export const am: Translations = {
},
licensing: {
certificateSuperseded: 'ሰርተፍኬቱ ተተክቷል',
vesselPicker: {
placeholder: 'የተመዘገበ መርከብ ይምረጡ',
},
@@ -801,6 +806,8 @@ export const am: Translations = {
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
reportDamaged: 'ጉዳት ሪፖርት ያድርጉ / ምትክ ይጠይቁ',
reissueFailed: 'የምትክ ጥያቄ መጀመር አልተቻለም',
},
catalogue: {
emptyTitle: 'የሚሰሩበትን የስራ ዘርፍ ይንገሩን',
@@ -825,6 +832,15 @@ export const am: Translations = {
},
},
pickup: {
title: 'የሰነድ መረከቢያ',
scheduledFor: 'ሰነድዎን ለመረከብ በ{{date}} ({{period}}) ወደ ቢሮ ይምጡ።',
setByOffice: 'ይህ ቀጠሮ በፈቃድ ጽ/ቤቱ ተይዟል።',
awaitingSchedule: 'ክፍያዎ ከተረጋገጠ በኋላ ፈቃድ ጽ/ቤቱ የመረከቢያ ቀን ይይዝልዎታል።',
morning: 'ጠዋት',
afternoon: 'ከሰዓት በኋላ',
},
certificates: {
title: "የእኔ የምስክር ወረቀቶች",
loading: "የምስክር ወረቀቶች በመጫን ላይ…",

View File

@@ -192,6 +192,7 @@ export const en = {
search: 'Search',
searchPlaceholder: 'Number or applicant',
status: 'Status',
kind: 'Type',
any: 'Any',
from: 'From',
to: 'To',
@@ -215,6 +216,9 @@ export const en = {
applicant: 'Applicant',
progress: 'Progress',
applicationNumber: 'Application №',
kindNew: 'New',
kindRenewal: 'Renewal',
kindReissue: 'Replacement',
},
actions: {
continue: 'Continue',
@@ -777,6 +781,7 @@ export const en = {
},
licensing: {
certificateSuperseded: 'Certificate superseded',
vesselPicker: {
placeholder: 'Select a registered vessel',
},
@@ -801,6 +806,8 @@ export const en = {
renewDays_one: 'Renew — expires in {{count}} day',
renewDays_other: 'Renew — expires in {{count}} days',
renewFailed: 'Could not start the renewal',
reportDamaged: 'Report damaged / request replacement',
reissueFailed: 'Could not start the replacement request',
},
catalogue: {
emptyTitle: 'Tell us what you operate as',
@@ -825,6 +832,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: {
title: 'My Certificates',
loading: 'Loading Certificates…',