diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx index 45414b19b..6137c9460 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx @@ -1,14 +1,21 @@ import type { Dispatch, ReactNode, SetStateAction } from "react"; -import { Badge, Checkbox, Text, Tooltip } from "@mantine/core"; +import { Badge, Checkbox, Group, Text, Tooltip } from "@mantine/core"; import type { TFunction } from "i18next"; import { STATUS_COLORS, STATUS_LABELS, applicantOrCompanyName, localized, + type ApplicationKind, type LicenseApplication, type QueueFilter, } from "@ema-platform/api"; + +const KIND_COLOR: Record = { + NEW: "blue", + RENEWAL: "teal", + REISSUE: "orange", +}; import type { AdvancedColumn } from "@ema-platform/ui"; import { dateDisplayer } from "@ema-platform/shared"; import { computeSla } from "../../sla"; @@ -112,9 +119,19 @@ export function licenseQueueColumns( { header: t("queue.typeCol", "Type"), cell: ({ row }) => ( - - {localized(row.original.licenseType?.name, locale) || "—"} - + + + {localized(row.original.licenseType?.name, locale) || "—"} + + {row.original.kind !== "NEW" && ( + + {t( + `queue.kindValues.${row.original.kind}`, + row.original.kind === "RENEWAL" ? "Renewal" : "Replacement", + )} + + )} + ), }, { diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx index 395f98087..d46037b7f 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx @@ -42,6 +42,7 @@ import { useGetQueueCountsQuery, useGetQueueQuery, useLazyExportApplicationsQuery, + type ApplicationKind, type LicenseApplication, type LicenseStatus, type LicenseType, @@ -432,6 +433,7 @@ export function LicenseQueuePage() { const hasFacets = Boolean( urlFilter.status?.length || urlFilter.licenseTypeId || + urlFilter.kind || urlFilter.assignee || urlFilter.submittedFrom || debouncedSearch, @@ -580,6 +582,19 @@ export function LicenseQueuePage() { w={220} /> )} + + + + + + ); +} + +export default PickupDeskPage; diff --git a/apps/backoffice/src/app/features/pickup/pages/PickupOfficesPage/index.tsx b/apps/backoffice/src/app/features/pickup/pages/PickupOfficesPage/index.tsx new file mode 100644 index 000000000..70548498f --- /dev/null +++ b/apps/backoffice/src/app/features/pickup/pages/PickupOfficesPage/index.tsx @@ -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(null); + const [creatingNew, setCreatingNew] = useState(false); + const [draft, setDraft] = useState(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[] = [ + { + header: t('pickupOffices.columns.name', 'Office'), + cell: ({ row }) => ( + <> + + {row.original.name} + + + {row.original.address ?? '—'} + + + ), + }, + { + header: t('pickupOffices.columns.hours', 'Working hours'), + cell: ({ row }) => ( + + {row.original.startTime}–{row.original.endTime} + + ), + }, + { + header: t('pickupOffices.columns.capacity', 'Capacity / slot'), + cell: ({ row }) => ( + + {row.original.maxApplicantsPerSlot} · {row.original.slotDurationMinutes}min + + ), + }, + { + header: t('pickupOffices.columns.status', 'Status'), + cell: ({ row }) => ( + + {row.original.isActive + ? t('pickupOffices.active', 'Active') + : t('pickupOffices.inactive', 'Inactive')} + + ), + }, + { + header: '', + align: 'right', + cell: ({ row }) => ( + + + + ), + }, + ]; + + return ( + + + + + + + + + + } + /> + + + + + + setDraft((d) => ({ ...d, name: e.currentTarget.value }))} + withAsterisk + /> + setDraft((d) => ({ ...d, address: e.currentTarget.value }))} + /> + setDraft((d) => ({ ...d, workingDays: v }))} + /> + + setDraft((d) => ({ ...d, startTime: e.currentTarget.value }))} + /> + setDraft((d) => ({ ...d, endTime: e.currentTarget.value }))} + /> + + + + setDraft((d) => ({ ...d, slotDurationMinutes: Number(v) || d.slotDurationMinutes })) + } + /> + + setDraft((d) => ({ ...d, maxApplicantsPerSlot: Number(v) || d.maxApplicantsPerSlot })) + } + /> + + + setDraft((d) => ({ + ...d, + rescheduleMinNoticeHours: Number(v) || d.rescheduleMinNoticeHours, + })) + } + /> + setDraft((d) => ({ ...d, isActive: e.currentTarget.checked }))} + /> + + + + + + + + + ); +} + +export default PickupOfficesPage; diff --git a/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentQueuePage.tsx b/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentQueuePage.tsx index 51460e1f8..dc7deefc7 100644 --- a/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentQueuePage.tsx +++ b/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentQueuePage.tsx @@ -5,10 +5,13 @@ import { IconSearch } from '@tabler/icons-react'; import { useDebouncedValue } from '@mantine/hooks'; import { SEAFARER_DOCUMENT_KIND_LABELS, + SEAFARER_DOCUMENT_REQUEST_KIND_COLORS, + SEAFARER_DOCUMENT_REQUEST_KIND_LABELS, SEAFARER_DOCUMENT_STATUS_COLORS, SEAFARER_DOCUMENT_STATUS_LABELS, useListSeafarerDocumentsQuery, type SeafarerDocumentKind, + type SeafarerDocumentRequestKind, type SeafarerDocumentRow, type SeafarerDocumentStatus, } from '@ema-platform/api'; @@ -22,6 +25,10 @@ const STATUS_FILTERS = ( ['PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'SCHEDULED', 'ISSUED', 'REJECTED'] as SeafarerDocumentStatus[] ).map((value) => ({ value, label: SEAFARER_DOCUMENT_STATUS_LABELS[value] })); +const REQUEST_KIND_FILTERS = ( + ['NEW', 'RENEWAL', 'REPLACEMENT'] as SeafarerDocumentRequestKind[] +).map((value) => ({ value, label: SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[value] })); + /** * The Seaman Book queue and the BTC queue — one page, keyed by kind. Requests * appear here once the seafarer registration that opened them is approved. @@ -30,6 +37,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind const navigate = useNavigate(); const showDate = useDateDisplayer(); const [status, setStatus] = useState(null); + const [requestKind, setRequestKind] = useState(null); const [search, setSearch] = useState(''); const [debouncedSearch] = useDebouncedValue(search, 300); const [page, setPage] = useState(0); @@ -38,6 +46,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind const { data, isLoading, isFetching, refetch } = useListSeafarerDocumentsQuery({ kind, status: status ?? undefined, + requestKind: requestKind ?? undefined, search: debouncedSearch || undefined, take: pageSize, skip: page * pageSize, @@ -75,6 +84,15 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind ), }, + { + header: 'Type', + accessorKey: 'requestKind', + cell: ({ row }) => ( + + {SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[row.original.requestKind]} + + ), + }, { header: 'Fee', accessorKey: 'feeAmount', @@ -148,6 +166,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind clearable w={200} /> + { + setKindFilter(v as ApplicationKind | null); + setPageIndex(0); + }} + clearable + w={160} + /> downloadCertificate(license.id)} onRenew={() => renewLicense(license)} + onReissue={() => reissueLicense(license)} /> ))} diff --git a/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx b/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx index e62b6ef00..98b18d7b2 100644 --- a/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx +++ b/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx @@ -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) { + 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 - - {SEAFARER_DOCUMENT_STATUS_LABELS[document.status]} - + + {document.requestKind !== "NEW" && ( + + {SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]} + + )} + + {SEAFARER_DOCUMENT_STATUS_LABELS[document.status]} + + {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)}`}. - + + + + + )} diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 07b37d41e..c5e8a5930 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -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: "የምስክር ወረቀቶች በመጫን ላይ…", diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index edb6fc354..01e7856c4 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -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…', diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index dfccf2a60..033f93494 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -12,6 +12,7 @@ import type { InitiatePaymentResult, IssuedLicense, Inspection, + IssuancePeriod, LicenseApplication, LicenseCategoryDefinition, LicenseStatus, @@ -24,6 +25,9 @@ import type { ExportResult, LicenseTemplate, Paginated, + PickupAppointment, + PickupOffice, + PickupSlot, QueueCounts, QueueFilter, RemarkTargetType, @@ -71,6 +75,8 @@ const TAGS = [ 'SavedView', 'LicenseTemplate', 'DocumentRequirement', + 'PickupOffice', + 'PickupAppointment', ] as const; const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const; @@ -889,12 +895,12 @@ export const licensingApi = baseApi scheduleIssuance: builder.mutation< LicenseApplication, - { id: string; scheduledDate: string } + { id: string; scheduledDate: string; scheduledPeriod: IssuancePeriod } >({ - query: ({ id, scheduledDate }) => ({ + query: ({ id, scheduledDate, scheduledPeriod }) => ({ url: `/license-application-review/${id}/schedule-issuance`, method: 'POST', - body: { scheduledDate }, + body: { scheduledDate, scheduledPeriod }, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], @@ -909,6 +915,104 @@ export const licensingApi = baseApi error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), + // ------------------------------------------------------------- pickup + getPickupOffices: builder.query({ + 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({ + 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({ + query: (id) => ({ url: `/pickup/appointments/${id}/check-in`, method: 'PATCH' }), + invalidatesTags: (_r, error, id) => + error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')], + }), + + markPickupIssued: builder.mutation({ + query: (id) => ({ url: `/pickup/appointments/${id}/issued`, method: 'PATCH' }), + invalidatesTags: (_r, error, id) => + error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')], + }), + + markPickupNoShow: builder.mutation({ + query: (id) => ({ url: `/pickup/appointments/${id}/no-show`, method: 'PATCH' }), + invalidatesTags: (_r, error, id) => + error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')], + }), + + createPickupOffice: builder.mutation>({ + query: (body) => ({ url: '/pickup/offices', method: 'POST', body }), + invalidatesTags: [listTag('PickupOffice')], + }), + + updatePickupOffice: builder.mutation< + PickupOffice, + { id: string } & Partial + >({ + query: ({ id, ...body }) => ({ + url: `/pickup/offices/${id}`, + method: 'PATCH', + body, + }), + invalidatesTags: (_r, error, { id }) => + error ? [] : [itemTag('PickupOffice', id), listTag('PickupOffice')], + }), + // --------------------------------------------------------- inspection scheduleInspection: builder.mutation< Inspection, @@ -1049,6 +1153,17 @@ export const { useConfirmPaymentMutation, useScheduleIssuanceMutation, useIssueCertificateMutation, + useGetPickupOfficesQuery, + useGetPickupSlotsQuery, + useSchedulePickupMutation, + useReschedulePickupMutation, + useGetPickupAppointmentsForApplicationQuery, + useGetPickupWorklistQuery, + useCheckInPickupMutation, + useMarkPickupIssuedMutation, + useMarkPickupNoShowMutation, + useCreatePickupOfficeMutation, + useUpdatePickupOfficeMutation, useScheduleInspectionMutation, useGetInspectionsQuery, useRecordInspectionResultMutation, diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index d3a4c4613..4bfa2bef1 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -1,6 +1,7 @@ import { isValidPhoneNumber } from 'libphonenumber-js'; import { resolveTokenFromStorage } from '../../session'; import type { + ApplicationKind, Bilingual, FamilyKind, FormFieldConfig, @@ -10,6 +11,11 @@ import type { ValidationIssue, } from './licensing.types'; +/** 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); +} + const BASE_API_URL = (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? 'http://localhost:3000/api'; @@ -437,12 +443,28 @@ export function buildWizardSteps( * instead of showing an empty page. */ hasStaff?: boolean; + /** + * Whether this application has any document requirements to upload. + * False for a Damaged/Reissue application, which asks nothing beyond the + * Damage Information step — showing an empty Documents page would be a + * page to click past for nothing. + */ + hasDocuments?: boolean; /** Active UI language. Components get this from `useLocalized`; this is a * pure function, so the caller passes `i18n.language` through. */ language?: string; + /** + * The application's kind — NEW unless the caller is renewing or + * reissuing. A section scoped to a different kind via + * `applicationKinds` is left out entirely, the same as a `showWhen` + * that never holds. + */ + applicationKind?: ApplicationKind; }, ): WizardStep[] { + const kind = options?.applicationKind ?? 'NEW'; const visible = [...sections] + .filter((section) => sectionAppliesToKind(section, kind)) .filter((section) => conditionHolds(section.showWhen, formData)) .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); @@ -497,7 +519,9 @@ export function buildWizardSteps( ...(options?.hasStaff === false ? [] : [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]), - { key: 'documents', label: 'Documents', kind: 'documents', sections: [] }, + ...(options?.hasDocuments === false + ? [] + : [{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] } as WizardStep]), { key: 'review', label: 'Review', kind: 'review', sections: reviewSections }, ]; } diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index dc02170b2..8137ca763 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -56,7 +56,10 @@ export type LicenseStatus = | "EXAM_PASSED" | "EXAM_FAILED"; -export type ApplicationKind = "NEW" | "RENEWAL"; +export type ApplicationKind = "NEW" | "RENEWAL" | "REISSUE"; + +/** Half-day window a team leader books an applicant's document pickup into. */ +export type IssuancePeriod = "MORNING" | "AFTERNOON"; export type FormFieldType = | "TEXT" @@ -106,6 +109,12 @@ export interface FormSectionConfig { group?: string; /** Position of the group in the stepper; lowest value in a group wins. */ groupOrder?: number; + /** + * Restricts this section to specific application kinds — e.g. the + * Damaged/Reissue "Damage Information" step. Undefined or empty means + * every kind. + */ + applicationKinds?: ApplicationKind[]; } /** Grouping the portal organises the licence catalogue by. */ @@ -335,6 +344,7 @@ export interface LicenseApplication { issuedLicenseId: string | null; /** Set once an officer schedules pickup for a document requiring in-person handover. */ scheduledIssuanceDate: string | null; + scheduledIssuancePeriod: IssuancePeriod | null; scheduledBy: string | null; createdAt: string; } @@ -426,6 +436,13 @@ export interface ApplicationApplicant { export interface ApplicationDetail { application: LicenseApplication; + /** + * Current status of the license this application issued, independent of + * the application's own (permanently historical) status — a later + * reissue/renewal can supersede the license without changing what this + * application itself accomplished. Null when nothing has been issued yet. + */ + issuedLicenseStatus: "ACTIVE" | "EXPIRED" | "SUSPENDED" | "CANCELLED" | "SUPERSEDED" | null; relatedApplications?: LicenseApplication[]; /** Null when the applicant has no profile row (never expected in practice). */ applicant: ApplicationApplicant | null; @@ -452,6 +469,50 @@ export interface Inspection { findings: string | null; } +export type PickupAppointmentStatus = + | "SCHEDULED" + | "CHECKED_IN" + | "ISSUED" + | "NO_SHOW" + | "RESCHEDULED" + | "CANCELLED"; + +export interface PickupOffice { + id: string; + name: string; + address: string | null; + /** 0=Sunday .. 6=Saturday. */ + workingDays: number[]; + startTime: string; + endTime: string; + slotDurationMinutes: number; + maxApplicantsPerSlot: number; + rescheduleMinNoticeHours: number; + isActive: boolean; +} + +export interface PickupSlot { + date: string; + slotStartTime: string; + capacity: number; + booked: number; + available: number; +} + +export interface PickupAppointment { + id: string; + applicationId: string; + appointmentNumber: string; + officeId: string; + date: string; + slotStartTime: string; + status: PickupAppointmentStatus; + rescheduledFromId: string | null; + rescheduleCount: number; + checkedInAt: string | null; + checkedInById: string | null; +} + export interface AppNotification { id: string; subject: Bilingual; @@ -468,6 +529,7 @@ export interface QueueFilter { licenseTypeId?: string; search?: string; status?: LicenseStatus[]; + kind?: ApplicationKind; /** Officer uuid, or the literal 'unassigned'. */ assignee?: string; submittedFrom?: string; @@ -700,6 +762,8 @@ export interface IssuedLicense { * configuration. */ renewable?: boolean; + /** Whether a Damaged/Reissue replacement may be requested for this licence. */ + reissuable?: boolean; verificationCode: string; certificateFileKey: string | null; } diff --git a/libs/api/src/lib/features/seafarer-document/seafarer-document-api.ts b/libs/api/src/lib/features/seafarer-document/seafarer-document-api.ts index 050fd17be..3dcf422a1 100644 --- a/libs/api/src/lib/features/seafarer-document/seafarer-document-api.ts +++ b/libs/api/src/lib/features/seafarer-document/seafarer-document-api.ts @@ -4,6 +4,7 @@ import type { SeafarerDocument, SeafarerDocumentDetail, SeafarerDocumentKind, + SeafarerDocumentRequestKind, SeafarerDocumentRow, SeafarerDocumentStatus, } from './seafarer-document.types'; @@ -14,6 +15,7 @@ const item = (id: string) => ({ type: TAG, id }) as const; export interface SeafarerDocumentListFilter { kind?: SeafarerDocumentKind; + requestKind?: SeafarerDocumentRequestKind; status?: SeafarerDocumentStatus; search?: string; take?: number; @@ -63,6 +65,16 @@ export const seafarerDocumentApi = baseApi invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]), }), + renewSeafarerDocument: builder.mutation({ + query: (id) => ({ url: `/seafarer-documents/${id}/renew`, method: 'POST' }), + invalidatesTags: (_r, error) => (error ? [] : [LIST]), + }), + + replaceSeafarerDocument: builder.mutation({ + query: (id) => ({ url: `/seafarer-documents/${id}/replace`, method: 'POST' }), + invalidatesTags: (_r, error) => (error ? [] : [LIST]), + }), + // --------------------------------------------------------------- review listSeafarerDocuments: builder.query< { total: number; items: SeafarerDocumentRow[] }, @@ -121,6 +133,8 @@ export const { useInitiateDocumentPaymentMutation, useGetDocumentPaymentQuery, useBypassDocumentPaymentMutation, + useRenewSeafarerDocumentMutation, + useReplaceSeafarerDocumentMutation, useListSeafarerDocumentsQuery, useGetSeafarerDocumentReviewQuery, useLazyGetSeafarerDocumentReviewDownloadQuery, diff --git a/libs/api/src/lib/features/seafarer-document/seafarer-document.constants.ts b/libs/api/src/lib/features/seafarer-document/seafarer-document.constants.ts index 4ce55058a..b1b21d4ed 100644 --- a/libs/api/src/lib/features/seafarer-document/seafarer-document.constants.ts +++ b/libs/api/src/lib/features/seafarer-document/seafarer-document.constants.ts @@ -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 = { SEAMAN_BOOK: 'Seaman Book', BTC_BASIC_TRAINING: 'Basic Training Certificate', }; +export const SEAFARER_DOCUMENT_REQUEST_KIND_LABELS: Record = { + NEW: 'New', + RENEWAL: 'Renewal', + REPLACEMENT: 'Replacement', +}; + +export const SEAFARER_DOCUMENT_REQUEST_KIND_COLORS: Record = { + NEW: 'gray', + RENEWAL: 'blue', + REPLACEMENT: 'orange', +}; + export const SEAFARER_DOCUMENT_STATUS_LABELS: Record = { AWAITING_REGISTRATION: 'Awaiting Registration', PAYMENT_PENDING: 'Payment Pending', diff --git a/libs/api/src/lib/features/seafarer-document/seafarer-document.types.ts b/libs/api/src/lib/features/seafarer-document/seafarer-document.types.ts index 0fe44de62..8d8180475 100644 --- a/libs/api/src/lib/features/seafarer-document/seafarer-document.types.ts +++ b/libs/api/src/lib/features/seafarer-document/seafarer-document.types.ts @@ -12,14 +12,19 @@ export type SeafarerDocumentStatus = | 'REJECTED' | 'CANCELLED'; -/** A Seaman Book or BTC request — opened by a seafarer registration. */ +/** NEW comes from a seafarer registration; RENEWAL/REPLACEMENT are applicant-initiated. */ +export type SeafarerDocumentRequestKind = 'NEW' | 'RENEWAL' | 'REPLACEMENT'; + +/** A Seaman Book or BTC request — opened by a seafarer registration, or by the applicant as a renewal/replacement. */ export interface SeafarerDocument { id: string; kind: SeafarerDocumentKind; + requestKind: SeafarerDocumentRequestKind; requestNumber: string; applicantUserId: string; profileId: string | null; seafarerRegistrationId: string | null; + previousDocumentId: string | null; status: SeafarerDocumentStatus; feeAmount: number | null; feeCurrency: string;