From 12e7927d018c33d90c4b1eccc217f1becb2e8966 Mon Sep 17 00:00:00 2001 From: fitse-yotor Date: Thu, 27 Aug 2026 10:53:11 +0300 Subject: [PATCH] feat(certificate-designer): add custom page size options and update related components --- .../components/TemplateBackgroundPanel.tsx | 45 ++++- .../components/TemplateCanvas.tsx | 28 ++- .../certificate-designer/config/designer.ts | 12 +- .../hooks/useTemplateDraft.ts | 13 ++ .../hooks/useTemplatePreview.ts | 9 +- .../pages/CertificateDesignerPage.tsx | 15 +- .../pages/LicenseReviewPage/index.tsx | 178 +++++++++++++++++- apps/backoffice/src/app/i18n/locales/am.ts | 11 +- apps/backoffice/src/app/i18n/locales/en.ts | 11 +- apps/backoffice/src/app/layouts/nav-config.ts | 18 +- .../portal/src/app/providers/AppProviders.tsx | 4 +- .../lib/features/licensing/licensing-api.ts | 18 +- .../lib/features/licensing/licensing.types.ts | 9 + 13 files changed, 342 insertions(+), 29 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx index 70e603189..3c5bbea86 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx @@ -23,6 +23,10 @@ interface Props { onLogoPlacementChange: (placement: TemplateLogoPlacement) => void; landscape: boolean; onLandscapeChange: (landscape: boolean) => void; + pageWidth: string; + onPageWidthChange: (width: string) => void; + pageHeight: string; + onPageHeightChange: (height: string) => void; disabled: boolean; } @@ -51,6 +55,10 @@ export function TemplateBackgroundPanel({ onLogoPlacementChange, landscape, onLandscapeChange, + pageWidth, + onPageWidthChange, + pageHeight, + onPageHeightChange, disabled, }: Props) { const { t } = useTranslation(); @@ -91,9 +99,40 @@ export function TemplateBackgroundPanel({ ]} /> - {landscape - ? t('designer.a4Landscape', 'A4 — 297 × 210 mm') - : t('designer.a4Portrait', 'A4 — 210 × 297 mm')} + {pageWidth && pageHeight + ? t('designer.customSize', 'Custom size — see below') + : landscape + ? t('designer.a4Landscape', 'A4 — 297 × 210 mm') + : t('designer.a4Portrait', 'A4 — 210 × 297 mm')} + + + +
+ + {t('designer.customPageSize', 'Custom page size')} + + + onPageWidthChange(e.currentTarget.value)} + disabled={disabled} + w={100} + /> + × + onPageHeightChange(e.currentTarget.value)} + disabled={disabled} + w={100} + /> + + + {t( + 'designer.customSizeHint', + 'e.g. 4.92in × 3.46in. Leave both blank to use A4. Overrides orientation\'s A4 size when set.', + )}
diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx index db0530f2f..a4018d460 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx @@ -8,6 +8,8 @@ interface Props { logoUrl: string; logoPlacement: TemplateLogoPlacement; landscape: boolean; + pageWidth?: string; + pageHeight?: string; placements: TemplateFieldPlacement[]; selectedId: string | null; onSelect: (id: string | null) => void; @@ -15,9 +17,17 @@ interface Props { disabled: boolean; } -/** A4 aspect ratio, the only page size the renderer is configured for. */ +/** A4 aspect ratio, the fallback for a version with no custom page size. */ const A4_RATIO = 297 / 210; +/** Parses a CSS length like "4.92in" or "125mm" into a unitless number, unit-agnostic — only the ratio between width and height matters here. */ +function parseLength(value: string): number | null { + const match = value.trim().match(/^([\d.]+)/); + if (!match) return null; + const n = Number(match[1]); + return Number.isFinite(n) && n > 0 ? n : null; +} + const LOGO_CORNER_STYLE: Record React.CSSProperties> = { TOP_LEFT: (o) => ({ top: `${o}%`, left: `${o}%` }), TOP_CENTER: (o) => ({ top: `${o}%`, left: '50%', transform: 'translateX(-50%)' }), @@ -53,6 +63,8 @@ export function TemplateCanvas({ logoUrl, logoPlacement, landscape, + pageWidth, + pageHeight, placements, selectedId, onSelect, @@ -180,6 +192,18 @@ export function TemplateCanvas({ logoPlacement.offsetPct ?? 5, ); + // A custom size already states its own orientation (4.92in × 3.46in is + // landscape on its own), so it is used as-is rather than flipped again by + // `landscape` — that flag only disambiguates the A4 fallback below. + const customWidth = pageWidth ? parseLength(pageWidth) : null; + const customHeight = pageHeight ? parseLength(pageHeight) : null; + const aspectRatio = + customWidth && customHeight + ? customWidth / customHeight + : landscape + ? A4_RATIO + : 1 / A4_RATIO; + return ( @@ -195,7 +219,7 @@ export function TemplateCanvas({ style={{ position: 'relative', width: '100%', - aspectRatio: landscape ? String(A4_RATIO) : String(1 / A4_RATIO), + aspectRatio: String(aspectRatio), background: '#ffffff', border: '1px solid var(--mantine-color-gray-4)', overflow: 'hidden', diff --git a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts index f6fd5abae..e9f4d2892 100644 --- a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts +++ b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts @@ -11,8 +11,16 @@ export const STATUS_COLOR: Record = { ARCHIVED: 'dark', }; -/** Page options sent with every save and preview — A4, background printed. */ -export function pageOptionsFor(landscape: boolean) { +/** + * Page options sent with every save and preview. + * + * A4 unless the version carries an explicit page size — set for documents + * like the Seaman Book, whose ICAO 9303 passport-booklet dimensions have no + * named `format` preset. `width`/`height` win over `format` in Puppeteer, so + * a custom size is sent alone rather than alongside `format: 'A4'`. + */ +export function pageOptionsFor(landscape: boolean, size?: { width: string; height: string }) { + if (size) return { width: size.width, height: size.height, landscape, printBackground: true }; return { format: 'A4' as const, landscape, printBackground: true }; } diff --git a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts index 19a28fe34..1d8cdc6a3 100644 --- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts +++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts @@ -22,6 +22,11 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { const [source, setSource] = useState(''); const [name, setName] = useState(''); const [landscape, setLandscape] = useState(true); + // Empty string means "use the A4 default" — only a version whose + // pageOptions already carries a custom size (e.g. the Seaman Book) starts + // with these populated. + const [pageWidth, setPageWidth] = useState(''); + const [pageHeight, setPageHeight] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState(''); const [logoUrl, setLogoUrl] = useState(''); const [logoPlacement, setLogoPlacement] = useState({}); @@ -49,6 +54,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { setSource(selected.hbsSource); setName(selected.name); setLandscape(selected.pageOptions?.landscape ?? true); + setPageWidth(selected.pageOptions?.width ?? ''); + setPageHeight(selected.pageOptions?.height ?? ''); setBackgroundUrl(selected.backgroundUrl ?? ''); setLogoUrl(selected.logoUrl ?? ''); setLogoPlacement(selected.logoPlacement ?? {}); @@ -70,6 +77,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { (source !== selected?.hbsSource || name !== selected?.name || landscape !== (selected?.pageOptions?.landscape ?? true) || + pageWidth !== (selected?.pageOptions?.width ?? '') || + pageHeight !== (selected?.pageOptions?.height ?? '') || backgroundUrl !== (selected?.backgroundUrl ?? '') || logoUrl !== (selected?.logoUrl ?? '') || logoPlacementChanged || @@ -137,6 +146,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { setName, landscape, setLandscape, + pageWidth, + setPageWidth, + pageHeight, + setPageHeight, backgroundUrl, setBackgroundUrl, logoUrl, diff --git a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts index c2714f856..975e03459 100644 --- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts +++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts @@ -9,6 +9,8 @@ interface PreviewArgs { hbsSource: string; licenseTypeId: string | null; landscape: boolean; + pageWidth?: string; + pageHeight?: string; } /** @@ -21,7 +23,7 @@ export function useTemplatePreview() { const [previewUrl, setPreviewUrl] = useState(null); const open = useCallback( - async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => { + async ({ hbsSource, licenseTypeId, landscape, pageWidth, pageHeight }: PreviewArgs) => { try { // The preview returns a PDF stream, not JSON, so it bypasses RTK Query // and calls the API directly — which means spelling out the base URL and @@ -36,7 +38,10 @@ export function useTemplatePreview() { body: JSON.stringify({ hbsSource, licenseTypeId, - pageOptions: pageOptionsFor(landscape), + pageOptions: pageOptionsFor( + landscape, + pageWidth && pageHeight ? { width: pageWidth, height: pageHeight } : undefined, + ), }), }); if (!response.ok) throw new Error(await response.text()); diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx index d30aa5d2f..e2903f2d8 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -182,6 +182,10 @@ export function CertificateDesignerPage() { onLogoPlacementChange={draft.setLogoPlacement} landscape={draft.landscape} onLandscapeChange={draft.setLandscape} + pageWidth={draft.pageWidth} + onPageWidthChange={draft.setPageWidth} + pageHeight={draft.pageHeight} + onPageHeightChange={draft.setPageHeight} disabled={editingLocked} /> @@ -239,6 +243,8 @@ export function CertificateDesignerPage() { logoUrl={draft.logoUrl} logoPlacement={draft.logoPlacement} landscape={draft.landscape} + pageWidth={draft.pageWidth} + pageHeight={draft.pageHeight} placements={draft.placements} selectedId={draft.selectedBlockId} onSelect={draft.setSelectedBlockId} @@ -306,6 +312,8 @@ export function CertificateDesignerPage() { : draft.source, licenseTypeId: typeId, landscape: draft.landscape, + pageWidth: draft.pageWidth || undefined, + pageHeight: draft.pageHeight || undefined, }) } onSave={() => @@ -318,7 +326,12 @@ export function CertificateDesignerPage() { // canvas layout is present, so sending the stale source // alongside it would only fight that. hbsSource: draft.usesCanvas ? undefined : draft.source, - pageOptions: pageOptionsFor(draft.landscape), + pageOptions: pageOptionsFor( + draft.landscape, + draft.pageWidth && draft.pageHeight + ? { width: draft.pageWidth, height: draft.pageHeight } + : undefined, + ), backgroundUrl: draft.backgroundUrl || undefined, logoUrl: draft.logoUrl || undefined, logoPlacement: draft.logoPlacement, diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index fdd4ab464..e92dd964e 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -4,9 +4,12 @@ import { ActionIcon, Alert, Badge, + Button, Container, + FileButton, Grid, Group, + Loader, Modal, NumberInput, Paper, @@ -24,8 +27,11 @@ import { import { IconAlertTriangle, IconCheck, + IconEye, + IconFileDownload, IconLayoutSidebarRightCollapse, IconLayoutSidebarRightExpand, + IconPaperclip, IconQuestionMark, IconX, } from "@tabler/icons-react"; @@ -63,6 +69,8 @@ import { useRequestAdjustmentMutation, useResumeApplicationMutation, useScheduleInspectionMutation, + useGetCertificateUrlForOfficerMutation, + uploadDocument, type RemarkTargetType, type StaffEvidenceRequirement, } from "@ema-platform/api"; @@ -212,6 +220,11 @@ export function LicenseReviewPage() { const [confirmPayment] = useConfirmPaymentMutation(); const [scheduleIssuance] = useScheduleIssuanceMutation(); const [issueCertificate] = useIssueCertificateMutation(); + const [getCertificateUrlForOfficer] = useGetCertificateUrlForOfficerMutation(); + const [certificateBusy, setCertificateBusy] = useState<"view" | "download" | null>( + null, + ); + const [certificatePreview, setCertificatePreview] = useState(null); const [scheduleExam, { isLoading: schedulingExam }] = useScheduleExamMutation(); const [recordExamOutcome] = useRecordExamOutcomeMutation(); @@ -243,6 +256,9 @@ export function LicenseReviewPage() { const [railOpen, setRailOpen] = useState(true); const [inspectionOpen, setInspectionOpen] = useState(false); const [inspectionDate, setInspectionDate] = useState(""); + const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">( + "MORNING", + ); const [issuanceOpen, setIssuanceOpen] = useState(false); const [issuanceDate, setIssuanceDate] = useState(""); const [resultOpen, setResultOpen] = useState(false); @@ -250,6 +266,10 @@ export function LicenseReviewPage() { const [examOutcomeOpen, setExamOutcomeOpen] = useState(false); const [examScore, setExamScore] = useState(); const [findings, setFindings] = useState(""); + const [findingsUploadBusy, setFindingsUploadBusy] = useState(false); + const [findingsPreview, setFindingsPreview] = useState< + { url: string; title: string } | null + >(null); const [checklist, setChecklist] = useState< Record >({}); @@ -289,6 +309,12 @@ export function LicenseReviewPage() { const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED'); + const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } = + useGetAttachmentsQuery( + { ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' }, + { skip: !pendingInspection }, + ); + // Approving means every uploaded document was accepted — one unjudged or // rejected file is enough to keep the decision buttons dead. The counts feed // the hover explanation, so the officer sees how much is left rather than @@ -488,6 +514,34 @@ export function LicenseReviewPage() { } } + /** + * Fetches a fresh presigned link and either opens the in-app PDF preview + * or a new tab, depending which button was pressed — the link itself is + * short-lived, so each click gets its own rather than caching one. + */ + async function openCertificate(mode: "view" | "download") { + if (!app.issuedLicenseId) return; + setCertificateBusy(mode); + try { + const { url } = await getCertificateUrlForOfficer( + app.issuedLicenseId, + ).unwrap(); + if (mode === "view") { + setCertificatePreview(url); + } else { + window.open(url, "_blank", "noopener"); + } + } catch (err) { + notifications.show({ + color: "red", + title: t("review.certificateError", "Could not open the certificate"), + message: extractErrorMessage(err), + }); + } finally { + setCertificateBusy(null); + } + } + /** Actions with their own dedicated form open that; the rest confirm. */ function handleAction(action: ResolvedAction) { switch (action.id) { @@ -875,6 +929,38 @@ export function LicenseReviewPage() { + {/* Only once the certificate actually exists — before that there + is nothing to view or download yet. */} + {app.issuedLicenseId && ( + + + {t("review.certificate", "Certificate")} + + + + + + + )} + {/* Eligibility, checked and shown — not applied invisibly. */} {eligibility.length > 0 && ( @@ -1123,7 +1209,15 @@ export function LicenseReviewPage() {
{inspection.scheduledDate - ? showDate(inspection.scheduledDate) + ? `${showDate(inspection.scheduledDate)}${ + inspection.timeSlot + ? ` — ${ + inspection.timeSlot === "MORNING" + ? t("review.morning", "Morning") + : t("review.afternoon", "Afternoon") + }` + : "" + }` : t("review.unscheduled", "Not scheduled")} {inspection.findings && ( @@ -1307,14 +1401,21 @@ export function LicenseReviewPage() { > + setInspectionTimeSlot(value as "MORNING" | "AFTERNOON")} + data={[ + { value: "MORNING", label: t("review.morning", "Morning") }, + { value: "AFTERNOON", label: t("review.afternoon", "Afternoon") }, + ]} /> @@ -1332,6 +1433,7 @@ export function LicenseReviewPage() { await scheduleInspection({ applicationId: id, scheduledDate: inspectionDate, + timeSlot: inspectionTimeSlot, }).unwrap(); setInspectionOpen(false); }, @@ -1430,6 +1532,60 @@ export function LicenseReviewPage() { autosize minRows={3} /> + + + {t("review.findingsEvidence", "Supporting documents")} + + + {findingsEvidence.flatMap((attachment) => + (attachment.files ?? []).map((file) => ( + + file.url && + setFindingsPreview({ url: file.url, title: file.originalName }) + } + > + + + )), + )} + { + if (!file || !pendingInspection) return; + setFindingsUploadBusy(true); + await uploadDocument({ + ownerType: "INSPECTION", + ownerId: pendingInspection.id, + documentKey: `evidence-${Date.now()}`, + file, + }); + setFindingsUploadBusy(false); + refetchFindingsEvidence(); + }} + > + {(props) => ( + + {findingsUploadBusy ? ( + + ) : ( + + )} + + )} + + + + + setFindingsPreview(null)} + url={findingsPreview?.url ?? ""} + title={findingsPreview?.title} + /> + + setCertificatePreview(null)} + url={certificatePreview ?? ""} + title={t("review.certificate", "Certificate")} + /> ); } diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index d2ec65d3c..ad4db16a9 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -947,9 +947,14 @@ export const am: Translations = { unscheduled: "አልተያዘም", inspectionResult: "የምርመራ ውጤት", findings: "ግኝቶች", + findingsEvidence: "አጋዥ ሰነዶች", + uploadEvidence: "ሰነድ መጫን", dateTime: "ቀን እና ሰዓት", + date: "ቀን", + morning: "ጠዋት", + afternoon: "ከሰዓት በኋላ", schedule: "ያዝ", - pickDate: "መጀመሪያ ቀን እና ሰዓት ይምረጡ", + pickDate: "መጀመሪያ ቀን ይምረጡ", passed: "አልፏል", failed: "ወድቋል", round_one: "ዙር {{count}}", @@ -958,6 +963,10 @@ export const am: Translations = { linkCopied: "አገናኝ ተቀድቷል", actionFailed: "ተግባሩ አልተሳካም", errorTitle: "ይህን ማመልከቻ መጫን አልተቻለም", + certificate: "ምስክር ወረቀት", + view: "ይመልከቱ", + download: "አውርድ", + certificateError: "ምስክር ወረቀቱን መክፈት አልተቻለም", hideActivity: "እንቅስቃሴ ደብቅ", showActivity: "እንቅስቃሴ አሳይ", awaitingPayment: "አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 8697095ce..42b10a8d3 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -955,9 +955,14 @@ export const en = { unscheduled: 'Not scheduled', inspectionResult: 'Inspection result', findings: 'Findings', + findingsEvidence: 'Supporting documents', + uploadEvidence: 'Upload document', dateTime: 'Date and time', + date: 'Date', + morning: 'Morning', + afternoon: 'Afternoon', schedule: 'Schedule', - pickDate: 'Pick a date and time first', + pickDate: 'Pick a date first', passed: 'Passed', failed: 'Failed', round_one: 'round {{count}}', @@ -966,6 +971,10 @@ export const en = { linkCopied: 'Link copied', actionFailed: 'Action failed', errorTitle: 'Could not load this application', + certificate: 'Certificate', + view: 'View', + download: 'Download', + certificateError: 'Could not open the certificate', hideActivity: 'Hide activity', showActivity: 'Show activity', awaitingPayment: 'Waiting for the applicant to pay {{amount}} {{currency}}.', diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts index 3878efa3f..845605988 100644 --- a/apps/backoffice/src/app/layouts/nav-config.ts +++ b/apps/backoffice/src/app/layouts/nav-config.ts @@ -96,13 +96,13 @@ export const NAV_SECTIONS: NavSection[] = [ label: 'nav.groupSeafarer', items: [ { to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] }, - { to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE }, - { to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE }, - { to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE }, - { to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE }, - { to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE }, - { to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE }, - { to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE }, + { to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: [P.VIEW_SEAFARER_REGISTRY] }, + { to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] }, + { to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] }, + { to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: [P.VIEW_SEAFARER_REGISTRY] }, + { to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] }, + { to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: [P.VIEW_SEAFARER_REGISTRY] }, + { to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: [P.VIEW_SEAFARER_REGISTRY] }, { to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] }, { to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] }, ], @@ -112,8 +112,8 @@ export const NAV_SECTIONS: NavSection[] = [ items: [ { to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] }, { to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] }, - { to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE }, - { to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] }, + { to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: [P.VIEW_VESSEL_REGISTRY] }, { to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] }, ], }, diff --git a/apps/portal/src/app/providers/AppProviders.tsx b/apps/portal/src/app/providers/AppProviders.tsx index 886fd7138..ac1b13894 100644 --- a/apps/portal/src/app/providers/AppProviders.tsx +++ b/apps/portal/src/app/providers/AppProviders.tsx @@ -21,9 +21,7 @@ export function AppProviders({ children }: { children: ReactNode }) { value={{ appName: 'Portal', storagePrefix: 'ema-portal', - // Applicants land on the licence list rather than the seafarer - // dashboard: signing up here is the first step of applying. - loginRedirectPath: '/licensing/applications', + loginRedirectPath: '/dashboard', enableSignup: true, enableForgotPassword: true, }} diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 7768628f0..dfccf2a60 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -436,6 +436,16 @@ export const licensingApi = baseApi query: (id) => ({ url: `/licenses/${id}/certificate` }), }), + /** + * Same download link, backoffice side. Separate endpoint from + * `getCertificateUrl`: the applicant route only ever hands the + * certificate to its holder, and an officer reviewing what they just + * issued is never the holder. + */ + getCertificateUrlForOfficer: builder.mutation<{ url: string }, string>({ + query: (id) => ({ url: `/licenses/${id}/certificate-backoffice` }), + }), + // ------------------------------------------------------------- review getQueue: builder.query, QueueFilter | void>({ query: (params) => ({ @@ -902,7 +912,12 @@ export const licensingApi = baseApi // --------------------------------------------------------- inspection scheduleInspection: builder.mutation< Inspection, - { applicationId: string; scheduledDate: string; location?: string } + { + applicationId: string; + scheduledDate: string; + timeSlot: 'MORNING' | 'AFTERNOON'; + location?: string; + } >({ query: (body) => ({ url: '/inspections', method: 'POST', body }), invalidatesTags: (_r, error, { applicationId }) => @@ -980,6 +995,7 @@ export const { useGetMyLicensesQuery, useGetLicensesQuery, useGetCertificateUrlMutation, + useGetCertificateUrlForOfficerMutation, useGetApplicationPaymentQuery, usePatchSectionMutation, useAddStaffMutation, diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index a3a47d19b..dc02170b2 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -443,6 +443,8 @@ export interface Inspection { inspectorId: string | null; inspectorName: string | null; scheduledDate: string | null; + /** Half-day slot the site visit is booked into. */ + timeSlot: "MORNING" | "AFTERNOON" | null; conductedDate: string | null; location: string | null; status: "SCHEDULED" | "COMPLETED" | "CANCELLED"; @@ -540,6 +542,13 @@ export type TemplateStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED"; export interface TemplatePageOptions { format?: "A4" | "A5" | "Letter" | "Legal"; + /** + * Explicit page dimensions (e.g. "4.92in"), for a size `format` has no + * named preset for — an ID-3 passport-booklet page, for one. Takes + * precedence over `format` when both are present. + */ + width?: string; + height?: string; landscape?: boolean; printBackground?: boolean; }