From 9f8f29d61478384d56ac530cc1ab6325a4157782 Mon Sep 17 00:00:00 2001 From: mihretue Date: Mon, 24 Aug 2026 11:30:56 +0000 Subject: [PATCH 01/14] fix(exam): stop hiding non-CHOICE questions from OFFLINE assignment picker eligibleQuestions filtered by q.form === exam.form, which matched the backend's own restriction (assertUsable) only for ONLINE exams. Once exam.form gained the "BOTH" value for OFFLINE mixed papers, no question ever has form "BOTH", so the picker silently emptied out or, for a plain CHOICE-form exam, looked CHOICE-only regardless of administration method. Now mirrors the backend: CHOICE-only gate applies only when administrationMethod is ONLINE. --- .../src/app/features/exam/pages/ExamDetailPage.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index 8e9037840..5ce7a4ab5 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -121,18 +121,25 @@ export function ExamDetailPage() { // Only approved bank items may go on a paper (US-EXAM-003), so the picker // must not offer drafts or retired questions either. + // + // Form restriction mirrors the backend's assertUsable: ONLINE exams are + // CHOICE-only (auto-grading needs it), OFFLINE exams have no form + // restriction at all — mixing ESSAY and CHOICE by hand (exam.form + // "BOTH") is the sanctioned, only way to build a mixed paper. Matching + // `q.form === exam.form` here used to hide every question once "BOTH" + // became a real exam.form value, since no question itself is "BOTH". const eligibleQuestions = useMemo(() => { if (!exam) return []; return allQuestions .filter( (q) => q.certificationId === exam.certificationId && - q.form === exam.form && - q.status === 'APPROVED', + q.status === 'APPROVED' && + (exam.administrationMethod !== 'ONLINE' || q.form === 'CHOICE'), ) .map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points })); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allQuestions, exam?.certificationId, exam?.form]); + }, [allQuestions, exam?.certificationId, exam?.administrationMethod]); if (isLoading) return ; From 35961ec8099a9712254545a2cbbc56ea92581023 Mon Sep 17 00:00:00 2001 From: mihretue Date: Mon, 24 Aug 2026 12:57:37 +0000 Subject: [PATCH 02/14] feat(exam): let ONLINE exams take ESSAY/BOTH form, add grading-sheet API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend half of the backend change: ExamPage no longer disables the form Select or force-resets it to CHOICE when administrationMethod is ONLINE (backend's assertOnlineIsChoiceOnly is gone, matching gate removed here). Also wires up GET .../grading-sheet as useGetGradingSheetQuery — RecordResultModal wiring (show candidate answers, prefill auto-computed CHOICE scores) is next. --- .../src/app/features/exam/api/exam-api.ts | 11 +++++++++++ .../app/features/exam/pages/ExamPage/index.tsx | 15 +-------------- .../src/app/features/exam/types/exam.ts | 18 ++++++++++++++++++ apps/backoffice/src/app/i18n/locales/am.ts | 1 - apps/backoffice/src/app/i18n/locales/en.ts | 1 - 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/apps/backoffice/src/app/features/exam/api/exam-api.ts b/apps/backoffice/src/app/features/exam/api/exam-api.ts index d313a38ac..4dbe8f713 100644 --- a/apps/backoffice/src/app/features/exam/api/exam-api.ts +++ b/apps/backoffice/src/app/features/exam/api/exam-api.ts @@ -12,6 +12,7 @@ import type { CreateIncidentPayload, ResolveIncidentPayload, RegradeOutcome, + GradingSheet, } from '../types/exam'; const examApi = baseApi.injectEndpoints({ @@ -101,6 +102,15 @@ const examApi = baseApi.injectEndpoints({ }), invalidatesTags: ['Api'], }), + /** The candidate's answers plus auto-computable CHOICE scores, for RecordResultModal. */ + getGradingSheet: builder.query< + GradingSheet, + { examId: string; profileId: string } + >({ + query: ({ examId, profileId }) => + `/exam-attempts/exam/${examId}/candidate/${profileId}/grading-sheet`, + providesTags: ['Api'], + }), }), overrideExisting: false, }); @@ -119,4 +129,5 @@ export const { useRecordIncidentMutation, useResolveIncidentMutation, useRegradeAttemptMutation, + useGetGradingSheetQuery, } = examApi; diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx index 034d203ae..34039328c 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -255,12 +255,6 @@ function ExamForm({ onChange={setForm} size="sm" required - disabled={adminMethod === "ONLINE"} - description={ - adminMethod === "ONLINE" - ? t("exam.form.onlineChoiceOnlyHint") - : undefined - } /> setFacet({ kind: (v as ApplicationKind) ?? undefined })} + clearable + w={180} + /> ( + "MORNING", + ); const [resultOpen, setResultOpen] = useState(false); const [scheduleExamOpen, setScheduleExamOpen] = useState(false); const [examOutcomeOpen, setExamOutcomeOpen] = useState(false); @@ -837,6 +840,11 @@ export function LicenseReviewPage() { {t(`queue.statusValues.${status}`, STATUS_LABELS[status])} + {data.issuedLicenseStatus === "SUPERSEDED" && ( + + {t("review.certificateSuperseded", "Certificate superseded")} + + )} {app.adjustmentRound > 0 && ( {t("review.round", { @@ -1458,9 +1466,17 @@ export function LicenseReviewPage() { value={issuanceDate} onChange={setIssuanceDate} /> + setIssuancePeriod(value as "MORNING" | "AFTERNOON")} + data={[ + { value: "MORNING", label: t("review.morning", "Morning") }, + { value: "AFTERNOON", label: t("review.afternoon", "Afternoon") }, + ]} + /> @@ -1478,6 +1494,7 @@ export function LicenseReviewPage() { await scheduleIssuance({ id, scheduledDate: issuanceDate, + scheduledPeriod: issuancePeriod, }).unwrap(); setIssuanceOpen(false); }, diff --git a/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/actions.tsx b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/actions.tsx new file mode 100644 index 000000000..f14cb4c1b --- /dev/null +++ b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/actions.tsx @@ -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 { + return { + header: '', + size: 260, + align: 'right', + cell: ({ row }) => { + const appointment = row.original; + const loading = loadingId === appointment.id; + return ( + + {appointment.status === 'SCHEDULED' && ( + + + + )} + {(appointment.status === 'SCHEDULED' || appointment.status === 'CHECKED_IN') && ( + <> + + + + + + + + )} + + ); + }, + }; +} diff --git a/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/columns.tsx b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/columns.tsx new file mode 100644 index 000000000..821081e92 --- /dev/null +++ b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/columns.tsx @@ -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 = { + SCHEDULED: 'cyan', + CHECKED_IN: 'yellow', + ISSUED: 'green', + NO_SHOW: 'red', + RESCHEDULED: 'gray', + CANCELLED: 'gray', +}; + +export function pickupDeskColumns( + t: TFunction, + officesById: Map, +): AdvancedColumn[] { + return [ + { + header: t('pickupDesk.columns.time', 'Time'), + cell: ({ row }) => ( + + {row.original.slotStartTime} + + ), + }, + { + header: t('pickupDesk.columns.appointment', 'Appointment'), + cell: ({ row }) => ( + + {row.original.appointmentNumber} + + ), + }, + { + header: t('pickupDesk.columns.office', 'Office'), + cell: ({ row }) => ( + {officesById.get(row.original.officeId)?.name ?? '—'} + ), + }, + { + header: t('pickupDesk.columns.status', 'Status'), + cell: ({ row }) => ( + + {row.original.status.replace('_', ' ')} + + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/index.tsx b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/index.tsx new file mode 100644 index 000000000..b14235e11 --- /dev/null +++ b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/index.tsx @@ -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(null); + const [loadingId, setLoadingId] = useState(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) { + 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 ( + + + + + } + /> + + + + { + setRequestKind(v as SeafarerDocumentRequestKind | null); + setPage(0); + }} + clearable + w={160} + /> } itemCount={data?.total ?? 0} diff --git a/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx b/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx index 271fae22e..642ff2b30 100644 --- a/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx +++ b/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx @@ -4,6 +4,8 @@ import {Alert, Badge, Button, Center, Container, Group, Loader, Modal, Paper, St import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react'; import { SEAFARER_DOCUMENT_KIND_LABELS, + SEAFARER_DOCUMENT_REQUEST_KIND_COLORS, + SEAFARER_DOCUMENT_REQUEST_KIND_LABELS, SEAFARER_DOCUMENT_STATUS_COLORS, SEAFARER_DOCUMENT_STATUS_LABELS, extractErrorMessage, @@ -118,6 +120,11 @@ export function SeafarerDocumentReviewPage() { {SEAFARER_DOCUMENT_STATUS_LABELS[document.status]} + {document.requestKind !== 'NEW' && ( + + {SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]} + + )} {document.documentNumber && ( }> {document.documentNumber} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index ad4db16a9..1c821d738 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -96,6 +96,8 @@ export const am: Translations = { seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ", applications: "ማመልከቻዎች", paymentConfig: "የክፍያ ውቅረት", + pickupDesk: "የመረከቢያ ዴስክ", + pickupOffices: "የመረከቢያ ቢሮዎች", analytics: "ትንታኔ", seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ", medicalVerification: "የሕክምና ማረጋገጫ", @@ -830,6 +832,12 @@ export const am: Translations = { type: "ዓይነት", anyType: "ማንኛውም", typeCol: "ዓይነት", + kind: "የማመልከቻ ዓይነት", + kindValues: { + NEW: "አዲስ", + RENEWAL: "እድሳት", + REISSUE: "ምትክ", + }, statusCol: "ሁኔታ", statusValues: { DRAFT: "ረቂቅ", @@ -904,6 +912,7 @@ export const am: Translations = { }, review: { + certificateSuperseded: "ሰርተፍኬቱ ተተክቷል", summary: "ማጠቃለያ", officer: "ሹም", supervisor: "የበላይ ኃላፊ", @@ -996,6 +1005,8 @@ export const am: Translations = { reject: "አትቀበል", scheduleExam: "የፈተና ቀጠሮ ስጥ", confirmPayment: "ክፍያ አረጋግጥ", + scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ", + issueCertificate: "ሰርተፍኬት ስጥ", print: "ሰነድ አትም", copyLink: "አገናኝ ቅዳ", downloadDocuments: "ሁሉንም ሰነዶች አውርድ", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 42b10a8d3..1b5c06ec2 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -95,6 +95,8 @@ export const en = { seafarerRegistrationQueue: 'Seafarer Registration Queue', applications: 'Applications', paymentConfig: 'Payment Config', + pickupDesk: 'Pickup Desk', + pickupOffices: 'Pickup Offices', analytics: 'Analytics', seaServiceVerification: 'Sea Service Verification', medicalVerification: 'Medical Verification', @@ -836,6 +838,12 @@ export const en = { type: 'Type', anyType: 'Any', typeCol: 'Type', + kind: 'Application kind', + kindValues: { + NEW: 'New', + RENEWAL: 'Renewal', + REISSUE: 'Replacement', + }, statusCol: 'Status', statusValues: { DRAFT: 'Draft', @@ -912,6 +920,7 @@ export const en = { }, review: { + certificateSuperseded: 'Certificate superseded', summary: 'Summary', officer: 'Officer', supervisor: 'Supervisor', @@ -1004,6 +1013,8 @@ export const en = { reject: 'Reject', scheduleExam: 'Schedule exam', confirmPayment: 'Confirm payment', + scheduleIssuance: 'Schedule pickup', + issueCertificate: 'Issue certificate', print: 'Print dossier', copyLink: 'Copy link', downloadDocuments: 'Download all documents', diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts index 845605988..05443c12f 100644 --- a/apps/backoffice/src/app/layouts/nav-config.ts +++ b/apps/backoffice/src/app/layouts/nav-config.ts @@ -2,6 +2,8 @@ import { IconAnchor, IconArrowsExchange, IconBook2, + IconBuildingWarehouse, + IconCalendarEvent, IconChartBar, IconClipboardList, IconClipboardText, @@ -157,6 +159,18 @@ export const NAV_SECTIONS: NavSection[] = [ icon: IconCreditCard, permissions: [P.VIEW_PAYMENTS], }, + { + to: '/pickup-desk', + label: 'nav.pickupDesk', + icon: IconCalendarEvent, + permissions: [P.MANAGE_PICKUP_DESK], + }, + { + to: '/pickup-offices', + label: 'nav.pickupOffices', + icon: IconBuildingWarehouse, + permissions: [P.CONFIGURE_PICKUP_OFFICES], + }, ], }, { diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index 46135bab4..ba8aafe3f 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -28,6 +28,8 @@ import { SeaServiceVerificationPage, } from '../features/medical-verification/pages/MedicalVerificationPage'; import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage'; +import { PickupDeskPage } from '../features/pickup/pages/PickupDeskPage'; +import { PickupOfficesPage } from '../features/pickup/pages/PickupOfficesPage'; import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage'; import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage'; import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage'; @@ -95,6 +97,8 @@ const router = createBrowserRouter([ { path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) }, { path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) }, { path: 'payment-config', element: guard([P.VIEW_PAYMENTS], ) }, + { path: 'pickup-desk', element: guard([P.MANAGE_PICKUP_DESK], ) }, + { path: 'pickup-offices', element: guard([P.CONFIGURE_PICKUP_OFFICES], ) }, { path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], ) }, // Seafarer registration is not a licence: own queue, own review. { path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, ) }, diff --git a/apps/portal/src/app/features/licensing/components/LicenseCard.tsx b/apps/portal/src/app/features/licensing/components/LicenseCard.tsx index c8942a140..15138767e 100644 --- a/apps/portal/src/app/features/licensing/components/LicenseCard.tsx +++ b/apps/portal/src/app/features/licensing/components/LicenseCard.tsx @@ -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({ )} + + {/* Damaged/Reissue has no window — a lost or damaged document can be + replaced at any point in its validity, unlike Renewal above. */} + {reissuable && onReissue && ( + + + + )} ); } diff --git a/apps/portal/src/app/features/licensing/components/PickupSchedulingPanel.tsx b/apps/portal/src/app/features/licensing/components/PickupSchedulingPanel.tsx new file mode 100644 index 000000000..b4b124252 --- /dev/null +++ b/apps/portal/src/app/features/licensing/components/PickupSchedulingPanel.tsx @@ -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 ( + + + + + {t('pickup.title')} + + + + {scheduledDate ? ( + + + {t('pickup.scheduledFor', { + date: scheduledDate, + period: + scheduledPeriod === 'AFTERNOON' + ? t('pickup.afternoon') + : t('pickup.morning'), + })} + + + {t('pickup.setByOffice')} + + + ) : ( + + {t('pickup.awaitingSchedule')} + + )} + + ); +} + +export default PickupSchedulingPanel; diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 73728c62e..9e67fd5c2 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -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(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]} + {detail?.issuedLicenseStatus === "SUPERSEDED" && ( + + {t("licensing.certificateSuperseded", "Certificate superseded")} + + )} @@ -681,6 +700,17 @@ export function LicenseApplicationPage() { )} + {config.licenseType.requiresIssuanceScheduling && + (application.status === "PAYMENT_CONFIRMED" || + application.status === "SCHEDULED") && ( + + + + )} + {showSummary && editableWhileSubmitted && ( = { + NEW: 'applications.table.kindNew', + RENEWAL: 'applications.table.kindRenewal', + REISSUE: 'applications.table.kindReissue', +}; + +const KIND_COLOR: Record = { + 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 }) => ( - - {localized(row.original.licenseType?.name, deps.language) || '—'} - + + + {localized(row.original.licenseType?.name, deps.language) || '—'} + + {row.original.kind !== 'NEW' && ( + + {t(KIND_LABEL[row.original.kind])} + + )} + {row.original.applicationNumber} diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx index d3e905816..59f73dfaa 100644 --- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx @@ -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(null); + const [kindFilter, setKindFilter] = useState(null); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const [bucketFilter, setBucketFilter] = useState(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} /> +