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 d04f360dc..57c3912b7 100644
--- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx
+++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx
@@ -9,6 +9,8 @@ interface Props {
logoUrl: string;
logoPlacement: TemplateLogoPlacement;
landscape: boolean;
+ pageWidth?: string;
+ pageHeight?: string;
placements: TemplateFieldPlacement[];
selectedId: string | null;
onSelect: (id: string | null) => void;
@@ -16,9 +18,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%)' }),
@@ -54,6 +64,8 @@ export function TemplateCanvas({
logoUrl,
logoPlacement,
landscape,
+ pageWidth,
+ pageHeight,
placements,
selectedId,
onSelect,
@@ -181,6 +193,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 (
@@ -196,7 +220,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 14abcd909..abfd1ad01 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 ||
@@ -160,6 +169,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 61d3f5ef9..f9401f3e4 100644
--- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
+++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
@@ -221,6 +221,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}
/>
@@ -278,6 +282,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}
@@ -345,6 +351,8 @@ export function CertificateDesignerPage() {
: draft.source,
licenseTypeId: typeId,
landscape: draft.landscape,
+ pageWidth: draft.pageWidth || undefined,
+ pageHeight: draft.pageHeight || undefined,
})
}
onSave={() =>
@@ -357,7 +365,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/components/AssignDialog.tsx b/apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx
new file mode 100644
index 000000000..9afca696c
--- /dev/null
+++ b/apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useState } from 'react';
+import { Modal, Select, Stack, Text, Textarea } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { useGetAssignableOfficersQuery } from '@ema-platform/api';
+import { ModalFooter } from '@ema-platform/ui';
+import { Button } from '@mantine/core';
+
+export type AssignKind = 'review' | 'inspection';
+
+interface AssignDialogProps {
+ opened: boolean;
+ onClose: () => void;
+ /** Which stage is being handed out — changes the wording, not the mechanics. */
+ kind: AssignKind;
+ /** Reference of the application being dispatched, shown for confirmation. */
+ applicationNumber?: string;
+ loading?: boolean;
+ onConfirm: (officerId: string, remark?: string) => void;
+}
+
+/**
+ * The team leader handing work to an employee.
+ *
+ * One dialog for both stages because the decision is identical — pick a person,
+ * optionally say why — and two near-identical modals would drift apart. The
+ * `kind` only selects wording.
+ *
+ * Confirm stays disabled until someone is picked: an assignment with no
+ * assignee is the one mistake this dialog exists to prevent.
+ */
+export function AssignDialog({
+ opened,
+ onClose,
+ kind,
+ applicationNumber,
+ loading,
+ onConfirm,
+}: AssignDialogProps) {
+ const { t } = useTranslation();
+ const { data: officers = [], isLoading } = useGetAssignableOfficersQuery();
+ const [officerId, setOfficerId] = useState(null);
+ const [remark, setRemark] = useState('');
+
+ // Reopening for a different application must not offer the previous
+ // dialog's answers as if they had been chosen for this one.
+ useEffect(() => {
+ if (opened) {
+ setOfficerId(null);
+ setRemark('');
+ }
+ }, [opened]);
+
+ const title =
+ kind === 'review'
+ ? t('queue.assignReview', 'Assign document review')
+ : t('queue.assignInspection', 'Assign inspection');
+
+ return (
+
+
+ {applicationNumber && (
+
+ {applicationNumber}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx b/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx
index 2c34fc9cd..8c863538f 100644
--- a/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx
+++ b/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx
@@ -134,7 +134,13 @@ export function DecisionConfirmModal({
if (!action) return null;
- const needsOfficer = action.id === 'assign' || action.id === 'escalate';
+ // The push-model assignments name a person the same way Assign does, so they
+ // reuse this picker rather than each carrying a dialog of their own.
+ const needsOfficer =
+ action.id === 'assign' ||
+ action.id === 'escalate' ||
+ action.id === 'assign-reviewer' ||
+ action.id === 'assign-inspector';
const reasonMissing = action.requiresReason && !reason.trim() && !reasonCode;
const blocked =
reasonMissing ||
@@ -177,7 +183,11 @@ export function DecisionConfirmModal({
label={
action.id === 'escalate'
? t('review.supervisor', 'Supervisor')
- : t('review.officer', 'Officer')
+ : action.id === 'assign-inspector'
+ ? t('review.inspector', 'Inspector')
+ : action.id === 'assign-reviewer'
+ ? t('review.reviewer', 'Reviewer')
+ : t('review.officer', 'Officer')
}
placeholder={t('review.officerPlaceholder', 'Select who takes this on')}
data={officers.map((officer) => ({
diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts
index d1547d43f..475c7fb9b 100644
--- a/apps/backoffice/src/app/features/license-review/config/actions.ts
+++ b/apps/backoffice/src/app/features/license-review/config/actions.ts
@@ -18,6 +18,10 @@ export type ActionTier =
export type ActionId =
| 'claim'
| 'assign'
+ | 'assign-reviewer'
+ | 'report-review'
+ | 'assign-inspector'
+ | 'report-inspection'
| 'escalate'
| 'hold'
| 'resume'
@@ -65,6 +69,56 @@ export interface ActionDefinition {
*/
export const ACTIONS: ActionDefinition[] = [
// ------------------------------------------------------------- workflow
+ {
+ id: 'claim',
+ tier: 'workflow',
+ labelKey: 'review.actions.claim',
+ // Mirrors the CLAIM transition's `from` list: an examined cert (CoC/CoP)
+ // sits in ELIGIBILITY_PAID once its eligibility fee clears, not SUBMITTED.
+ from: ['SUBMITTED', 'ELIGIBILITY_PAID'],
+ permissions: ['can:claim:license-application'],
+ emphasis: 'light',
+ },
+ /**
+ * The push-model actions.
+ *
+ * `assign-reviewer` and `assign-inspector` start a stage; `report-review`
+ * and `report-inspection` hand it back. An employee sees only the two report
+ * actions — the assign pair is gated on ASSIGN_APPLICATION, which their
+ * position type does not carry.
+ */
+ {
+ id: 'assign-reviewer',
+ tier: 'workflow',
+ labelKey: 'review.actions.assignReviewer',
+ from: ['SUBMITTED', 'ELIGIBILITY_PAID'],
+ permissions: ['can:assign:license-application'],
+ emphasis: 'filled',
+ },
+ {
+ id: 'report-review',
+ tier: 'workflow',
+ labelKey: 'review.actions.reportReview',
+ from: ['UNDER_REVIEW', 'UNDER_EVALUATION'],
+ permissions: ['can:review:license-application'],
+ emphasis: 'filled',
+ },
+ {
+ id: 'assign-inspector',
+ tier: 'workflow',
+ labelKey: 'review.actions.assignInspector',
+ from: ['REVIEW_REPORTED', 'UNDER_EVALUATION'],
+ permissions: ['can:assign:license-application'],
+ emphasis: 'filled',
+ },
+ {
+ id: 'report-inspection',
+ tier: 'workflow',
+ labelKey: 'review.actions.reportInspection',
+ from: ['INSPECTION_COMPLETED'],
+ permissions: ['can:update:inspection'],
+ emphasis: 'filled',
+ },
// Claim is deliberately absent here: an officer claims from the queue
// (LicenseQueuePage), not from this detail page. That implementation is
// separate — see LicenseQueuePage/actions.tsx — and is unaffected by this.
@@ -158,6 +212,9 @@ export const ACTIONS: ActionDefinition[] = [
id: 'final-approve',
tier: 'primary',
labelKey: 'review.actions.finalApprove',
+ from: ['INSPECTION_COMPLETED',
+ 'REVIEW_REPORTED',
+ 'INSPECTION_REPORTED', 'UNDER_EVALUATION'],
// ELIGIBILITY_PAID: an examined certificate (CoC/CoP) is decided straight
// off the eligibility queue — no assignment step.
from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION', 'ELIGIBILITY_PAID'],
@@ -170,6 +227,9 @@ export const ACTIONS: ActionDefinition[] = [
id: 'request-adjustment',
tier: 'primary',
labelKey: 'review.actions.requestAdjustment',
+ from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED',
+ 'REVIEW_REPORTED',
+ 'INSPECTION_REPORTED'],
from: [
'UNDER_REVIEW',
'UNDER_EVALUATION',
@@ -191,6 +251,8 @@ export const ACTIONS: ActionDefinition[] = [
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
+ 'REVIEW_REPORTED',
+ 'INSPECTION_REPORTED',
'INSPECTION_FAILED',
'ELIGIBILITY_PAID',
],
diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx
index bd3eb3753..ac28d13ab 100644
--- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx
+++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx
@@ -7,11 +7,12 @@ import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
export function licenseQueueActionsColumn(
t: TFunction,
handlers: {
- claiming: boolean;
- onClaim: (id: string) => void;
+ assigning: boolean;
+ /** Opens the assign dialog. Team leaders only — see below. */
+ onAssign: (application: LicenseApplication) => void;
onOpen: (id: string) => void;
- /** False for a non-logistics queue — there's no unclaimed pool to claim from. */
- claimable?: boolean;
+ /** False for a non-logistics queue — nothing there is dispatched. */
+ assignable?: boolean;
},
): AdvancedColumn {
return {
@@ -19,24 +20,32 @@ export function licenseQueueActionsColumn(
label: t("queue.actionsColumn", "Actions"),
align: "right",
size: 140,
+ /**
+ * "Assign", not "Claim".
+ *
+ * Work is pushed, not picked up: an unassigned application is waiting for
+ * the team leader to hand it to someone, and no seeded position type holds
+ * `CLAIM_APPLICATION` any more. Guarded on `ASSIGN_APPLICATION`, so an
+ * employee sees only "Review" on the files that are already theirs.
+ */
cell: ({ row }) =>
- handlers.claimable !== false &&
+ handlers.assignable !== false &&
row.original.assignedOfficerId === null &&
- // Mirrors the CLAIM transition's `from` list: an examined cert
+ // Mirrors the ASSIGN_REVIEWER transition's `from` list: an examined cert
// (CoC/CoP) sits in ELIGIBILITY_PAID once its eligibility fee clears,
// not SUBMITTED.
(row.original.status === "SUBMITTED" ||
row.original.status === "ELIGIBILITY_PAID") ? (
) : (
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 f90008108..d07d859a7 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
@@ -35,7 +35,7 @@ import {
familyLabels,
localized,
resolveFamilyKind,
- useClaimApplicationMutation,
+ useAssignReviewerMutation,
useGetAllApplicationsQuery,
useGetAssignedToMeQuery,
useGetLicenseTypesQuery,
@@ -54,7 +54,6 @@ import {
AmharicDatePicker,
type AdvancedColumn,
} from "@ema-platform/ui";
-import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import {
DEFAULT_VIEW,
SAVED_VIEWS,
@@ -71,6 +70,7 @@ import { setDensity } from "../../../../store/preferences.slice";
import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
import { licenseQueueColumns } from "./columns";
+import { AssignDialog } from "../../components/AssignDialog";
import { licenseQueueActionsColumn } from "./actions";
import { PageHeader } from '@ema-platform/ui';
@@ -268,7 +268,8 @@ export function LicenseQueuePage() {
? mineQuery
: allQuery;
- const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
+ const [assignReviewer, { isLoading: assigning }] = useAssignReviewerMutation();
+ const [assignTarget, setAssignTarget] = useState(null);
const [runExport, { isFetching: exporting }] =
useLazyExportApplicationsQuery();
@@ -355,58 +356,40 @@ export function LicenseQueuePage() {
setFacet({ sortBy: field, sortDir: dir });
};
- async function handleClaim(id: string) {
+ /**
+ * The team leader dispatching one application.
+ *
+ * Replaces the old self-service claim: nothing is picked up any more, so the
+ * queue's primary action is handing a file to an employee. The dialog holds
+ * the choice; this only sends it.
+ */
+ async function handleAssign(officerId: string, remark?: string) {
+ if (!assignTarget) return;
try {
- await claim(id).unwrap();
+ await assignReviewer({ id: assignTarget.id, officerId, remark }).unwrap();
notifications.show({
color: "teal",
- title: t("queue.claimed", "Claimed"),
+ title: t("queue.assigned", "Assigned"),
message: t(
- "queue.claimedBody",
- "The application is now assigned to you.",
+ "queue.assignedBody",
+ "The employee has been notified and the review has started.",
),
});
- changeView("mine");
+ setAssignTarget(null);
+ active.refetch();
} catch (err) {
- // A 409 means another officer got there first — refresh so the queue
- // stops showing work that is no longer available.
notifications.show({
color: "red",
- title: t("queue.claimFailed", "Could not claim"),
+ title: t("queue.assignFailed", "Could not assign"),
message: extractErrorMessage(
err,
- t("queue.claimRace", "Another officer already claimed it."),
+ t("queue.assignError", "The application could not be assigned."),
),
});
active.refetch();
}
}
- async function handleBulkClaim() {
- const results = await Promise.allSettled(
- selected.map((id) => claim(id).unwrap()),
- );
- const claimed = results.filter((r) => r.status === "fulfilled").length;
- const lost = results.length - claimed;
- notifications.show({
- color: lost ? "yellow" : "teal",
- title: t("queue.bulkClaimed", {
- count: claimed,
- defaultValue: "{{count}} claimed",
- }),
- // Partial success is the normal case in a shared queue, so it is
- // reported rather than swallowed or treated as total failure.
- message: lost
- ? t("queue.bulkClaimPartial", {
- count: lost,
- defaultValue: "{{count}} were already taken by another officer.",
- })
- : "",
- });
- setSelected([]);
- active.refetch();
- }
-
const cursorRow = items[cursor];
useQueueKeyboard({
enabled: !helpOpen,
@@ -415,6 +398,12 @@ export function LicenseQueuePage() {
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
onClaim: () => {
+ // "c" now opens the assign dialog on an undispatched row. Kept on the
+ // same key: it is still "do the queue's primary action to this row",
+ // and rebinding a shortcut officers have in their fingers costs more
+ // than the name mismatch.
+ if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
+ setAssignTarget(cursorRow);
// Only unclaimed rows on a claimable queue can be claimed; pressing c
// elsewhere is a no-op rather than an error the officer has to read.
if (claimable && cursorRow && cursorRow.assignedOfficerId === null)
@@ -479,9 +468,12 @@ export function LicenseQueuePage() {
isLogistics,
}),
licenseQueueActionsColumn(t, {
- claiming,
- onClaim: handleClaim,
+ assigning,
+ onAssign: setAssignTarget,
onOpen: (id) => navigate(`/licence-review/${id}`),
+ // Non-logistics applications aren't dispatched off a shared queue (see
+ // `savedViewsForFamily`) — every row opens straight to Review.
+ assignable: isLogistics !== false,
// Applications with no unclaimed pool (see `hasUnclaimedPool`) are
// never claimed — every row opens straight to Review.
claimable,
@@ -495,7 +487,7 @@ export function LicenseQueuePage() {
selected,
allSelected,
items,
- claiming,
+ assigning,
isLogistics,
claimable,
],
@@ -782,6 +774,14 @@ export function LicenseQueuePage() {
)}
+ setAssignTarget(null)}
+ kind="review"
+ applicationNumber={assignTarget?.applicationNumber}
+ loading={assigning}
+ onConfirm={handleAssign}
+ />
);
}
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 71a13403a..1977689ae 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";
@@ -39,6 +45,10 @@ import {
useLocalized,
useApproveDocumentsMutation,
useAssignApplicationMutation,
+ useAssignReviewerMutation,
+ useAssignInspectorMutation,
+ useReportReviewMutation,
+ useReportInspectionMutation,
useClaimApplicationMutation,
useCompleteReviewMutation,
useConfirmPaymentMutation,
@@ -60,6 +70,8 @@ import {
useRequestAdjustmentMutation,
useResumeApplicationMutation,
useScheduleInspectionMutation,
+ useGetCertificateUrlForOfficerMutation,
+ uploadDocument,
type RemarkTargetType,
type StaffEvidenceRequirement,
} from "@ema-platform/api";
@@ -210,6 +222,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();
@@ -217,6 +234,13 @@ export function LicenseReviewPage() {
const [resumeApplication] = useResumeApplicationMutation();
const [escalateApplication] = useEscalateApplicationMutation();
const [assignApplication] = useAssignApplicationMutation();
+ const [assignReviewer, { isLoading: assigningReviewer }] =
+ useAssignReviewerMutation();
+ const [assignInspector, { isLoading: assigningInspector }] =
+ useAssignInspectorMutation();
+ const [reportReview] = useReportReviewMutation();
+ const [reportInspection] = useReportInspectionMutation();
+
// Real officer list, so Assign and Escalate name a person instead of
// silently reassigning to whoever already held the application.
const { data: officers = [] } = useGetAssignableOfficersQuery();
@@ -234,6 +258,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);
@@ -241,6 +268,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
>({});
@@ -286,6 +317,12 @@ export function LicenseReviewPage() {
new Date(pendingInspection.scheduledDate) > new Date(),
);
+ 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
@@ -493,6 +530,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) {
@@ -554,6 +619,46 @@ export function LicenseReviewPage() {
t("review.done.claim", "Claimed — the application is now yours"),
);
break;
+ // Handing work out. Reuses the decision modal's officer picker rather
+ // than a dialog of its own — it already resolves the real officer list.
+ case "assign-reviewer":
+ if (!submission.officerId) return;
+ await run(
+ () =>
+ assignReviewer({
+ id,
+ officerId: submission.officerId as string,
+ remark: submission.reason,
+ }).unwrap(),
+ t("review.done.assignReviewer", "Review assigned"),
+ );
+ break;
+ case "assign-inspector":
+ if (!submission.officerId) return;
+ await run(
+ () =>
+ assignInspector({
+ id,
+ inspectorId: submission.officerId as string,
+ remark: submission.reason,
+ }).unwrap(),
+ t("review.done.assignInspector", "Inspection assigned"),
+ );
+ break;
+ // Handing work back. Parks the file with the team leader — an employee
+ // finishing their task decides nothing.
+ case "report-review":
+ await run(
+ () => reportReview({ id, remark: submission.reason }).unwrap(),
+ t("review.done.reportReview", "Sent to your team leader"),
+ );
+ break;
+ case "report-inspection":
+ await run(
+ () => reportInspection({ id, remark: submission.reason }).unwrap(),
+ t("review.done.reportInspection", "Inspection result sent"),
+ );
+ break;
case "complete-review":
await run(
() =>
@@ -844,6 +949,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")}
+
+
+ }
+ loading={certificateBusy === "view"}
+ disabled={Boolean(certificateBusy)}
+ onClick={() => openCertificate("view")}
+ >
+ {t("review.view", "View")}
+
+ }
+ loading={certificateBusy === "download"}
+ disabled={Boolean(certificateBusy)}
+ onClick={() => openCertificate("download")}
+ >
+ {t("review.download", "Download")}
+
+
+
+ )}
+
{/* Eligibility, checked and shown — not applied invisibly. */}
{eligibility.length > 0 && (
@@ -1104,7 +1241,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 && (
@@ -1289,14 +1434,21 @@ export function LicenseReviewPage() {
>
+ setInspectionTimeSlot(value as "MORNING" | "AFTERNOON")}
+ data={[
+ { value: "MORNING", label: t("review.morning", "Morning") },
+ { value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
+ ]}
/>
@@ -1314,6 +1466,7 @@ export function LicenseReviewPage() {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
+ timeSlot: inspectionTimeSlot,
}).unwrap();
setInspectionOpen(false);
},
@@ -1412,6 +1565,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 ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
{inspectionNotYetDue && (
}>
{t("review.disabled.inspectionNotYetDue", {
@@ -1481,6 +1688,20 @@ export function LicenseReviewPage() {
+
+
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 1c3b813b4..94622fa47 100644
--- a/apps/backoffice/src/app/i18n/locales/am.ts
+++ b/apps/backoffice/src/app/i18n/locales/am.ts
@@ -899,6 +899,11 @@ export const am: Translations = {
submitted: "የቀረበበት",
sla: "ዕድሜ / የጊዜ ገደብ",
claim: "ውሰድ",
+ assign: "መድብ",
+ assigned: "ተመድቧል",
+ assignedBody: "ባለሙያው ተነግሮታል፤ ግምገማው ተጀምሯል።",
+ assignFailed: "መመደብ አልተቻለም",
+ assignError: "ማመልከቻውን መመደብ አልተቻለም።",
review: "ገምግም",
claimed: "ተወስዷል",
claimedBody: "ማመልከቻው አሁን ለእርስዎ ተመድቧል።",
@@ -974,9 +979,14 @@ export const am: Translations = {
unscheduled: "አልተያዘም",
inspectionResult: "የምርመራ ውጤት",
findings: "ግኝቶች",
+ findingsEvidence: "አጋዥ ሰነዶች",
+ uploadEvidence: "ሰነድ መጫን",
dateTime: "ቀን እና ሰዓት",
+ date: "ቀን",
+ morning: "ጠዋት",
+ afternoon: "ከሰዓት በኋላ",
schedule: "ያዝ",
- pickDate: "መጀመሪያ ቀን እና ሰዓት ይምረጡ",
+ pickDate: "መጀመሪያ ቀን ይምረጡ",
passed: "አልፏል",
failed: "ወድቋል",
round_one: "ዙር {{count}}",
@@ -985,6 +995,10 @@ export const am: Translations = {
linkCopied: "አገናኝ ተቀድቷል",
actionFailed: "ተግባሩ አልተሳካም",
errorTitle: "ይህን ማመልከቻ መጫን አልተቻለም",
+ certificate: "ምስክር ወረቀት",
+ view: "ይመልከቱ",
+ download: "አውርድ",
+ certificateError: "ምስክር ወረቀቱን መክፈት አልተቻለም",
hideActivity: "እንቅስቃሴ ደብቅ",
showActivity: "እንቅስቃሴ አሳይ",
awaitingPayment: "አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።",
@@ -998,6 +1012,10 @@ export const am: Translations = {
actions: {
claim: "ውሰድ",
assign: "መድብ",
+ assignReviewer: "ግምገማ መድብ",
+ reportReview: "ለቡድን መሪ አሳውቅ",
+ assignInspector: "ምርመራ መድብ",
+ reportInspection: "የምርመራ ውጤት አሳውቅ",
assignReviewer: "ገምጋሚ መድብ",
escalate: "ወደ ላይ አሳድግ",
hold: "አግድ",
diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts
index 209d12138..e98459f58 100644
--- a/apps/backoffice/src/app/i18n/locales/en.ts
+++ b/apps/backoffice/src/app/i18n/locales/en.ts
@@ -908,6 +908,11 @@ export const en = {
submitted: 'Submitted',
sla: 'Age / SLA',
claim: 'Claim',
+ assign: 'Assign',
+ assigned: 'Assigned',
+ assignedBody: 'The employee has been notified and the review has started.',
+ assignFailed: 'Could not assign',
+ assignError: 'The application could not be assigned.',
review: 'Review',
claimed: 'Claimed',
claimedBody: 'The application is now assigned to you.',
@@ -983,9 +988,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}}',
@@ -994,6 +1004,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}}.',
@@ -1007,6 +1021,10 @@ export const en = {
actions: {
claim: 'Claim',
assign: 'Assign',
+ assignReviewer: 'Assign review',
+ reportReview: 'Report to team leader',
+ assignInspector: 'Assign inspection',
+ reportInspection: 'Report inspection result',
assignReviewer: 'Assign reviewer',
escalate: 'Escalate',
hold: 'Put on hold',
diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts
index ef8690640..969e7d0f9 100644
--- a/apps/backoffice/src/app/layouts/nav-config.ts
+++ b/apps/backoffice/src/app/layouts/nav-config.ts
@@ -96,6 +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: [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: '/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 },
@@ -111,8 +118,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 665aaf8ab..2467979fa 100644
--- a/libs/api/src/lib/features/licensing/licensing-api.ts
+++ b/libs/api/src/lib/features/licensing/licensing-api.ts
@@ -511,6 +511,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) => ({
@@ -842,6 +852,66 @@ export const licensingApi = baseApi
query: () => ({ url: '/license-application-review/officers' }),
}),
+ // ------------------------------------------------- team-leader dispatch
+ /**
+ * Handing work out, and handing it back.
+ *
+ * `assignApplication` below only re-points an application already in
+ * flight; these two *start* a stage, because under the push model
+ * assignment is how work begins — nothing is claimed from a queue.
+ */
+ assignReviewer: builder.mutation<
+ LicenseApplication,
+ { id: string; officerId: string; remark?: string }
+ >({
+ query: ({ id, ...body }) => ({
+ url: `/license-application-review/${id}/assign-reviewer`,
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (_r, error, { id }) =>
+ error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
+ }),
+
+ assignInspector: builder.mutation<
+ LicenseApplication,
+ { id: string; inspectorId: string; remark?: string }
+ >({
+ query: ({ id, ...body }) => ({
+ url: `/license-application-review/${id}/assign-inspector`,
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (_r, error, { id }) =>
+ error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
+ }),
+
+ reportReview: builder.mutation<
+ LicenseApplication,
+ { id: string; remark?: string }
+ >({
+ query: ({ id, ...body }) => ({
+ url: `/license-application-review/${id}/report-review`,
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (_r, error, { id }) =>
+ error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
+ }),
+
+ reportInspection: builder.mutation<
+ LicenseApplication,
+ { id: string; remark?: string }
+ >({
+ query: ({ id, ...body }) => ({
+ url: `/license-application-review/${id}/report-inspection`,
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (_r, error, { id }) =>
+ error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
+ }),
+
// ----------------------------------------------------- workflow controls
assignApplication: builder.mutation<
LicenseApplication,
@@ -946,7 +1016,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 }) =>
@@ -1034,6 +1109,7 @@ export const {
useGetMyLicensesQuery,
useGetLicensesQuery,
useGetCertificateUrlMutation,
+ useGetCertificateUrlForOfficerMutation,
useGetApplicationPaymentQuery,
usePatchSectionMutation,
useAddStaffMutation,
@@ -1068,6 +1144,9 @@ export const {
useReinstateLicenseMutation,
useAssignApplicationMutation,
useAssignReviewerMutation,
+ useAssignInspectorMutation,
+ useReportReviewMutation,
+ useReportInspectionMutation,
useHoldApplicationMutation,
useResumeApplicationMutation,
useEscalateApplicationMutation,
diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts
index 83f0e8098..888b57be9 100644
--- a/libs/api/src/lib/features/licensing/licensing.helpers.ts
+++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts
@@ -66,7 +66,9 @@ export const STATUS_LABELS: Record = {
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
+ REVIEW_REPORTED: 'Review Reported',
INSPECTION_COMPLETED: 'Inspection Completed',
+ INSPECTION_REPORTED: 'Inspection Reported',
INSPECTION_FAILED: 'Inspection Failed',
APPROVED: 'Approved',
REJECTED: 'Rejected',
@@ -94,7 +96,11 @@ export const STATUS_COLORS: Record = {
UNDER_EVALUATION: 'indigo',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'cyan',
+ // Both 'reported' states are waiting on the team leader, so they carry the
+ // same tone as anything else awaiting an officer decision.
+ REVIEW_REPORTED: 'orange',
INSPECTION_COMPLETED: 'cyan',
+ INSPECTION_REPORTED: 'orange',
// Orange, not red: recoverable — a re-inspection can still pass.
INSPECTION_FAILED: 'orange',
APPROVED: 'teal',
@@ -129,7 +135,9 @@ export const STATUS_PROGRESS: Record = {
UNDER_EVALUATION: 45,
RESUBMIT_REQUIRED: 30,
INSPECTION_PENDING: 55,
+ REVIEW_REPORTED: 50,
INSPECTION_COMPLETED: 65,
+ INSPECTION_REPORTED: 70,
// A re-inspection returns to the pending step, so no further along than it.
INSPECTION_FAILED: 55,
APPROVED: 75,
diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts
index 22731177b..0fac20c6e 100644
--- a/libs/api/src/lib/features/licensing/licensing.types.ts
+++ b/libs/api/src/lib/features/licensing/licensing.types.ts
@@ -26,9 +26,13 @@ export type LicenseStatus =
| "SUBMITTED"
| "UNDER_REVIEW"
| "UNDER_EVALUATION"
+ // Employee filed their review; parked with the team leader for a decision.
+ | "REVIEW_REPORTED"
| "RESUBMIT_REQUIRED"
| "INSPECTION_PENDING"
| "INSPECTION_COMPLETED"
+ // Inspector filed the result; parked with the team leader for a decision.
+ | "INSPECTION_REPORTED"
// The inspection was conducted and failed. Approval and issuance are
// unreachable until a re-inspection passes; the officer chooses between a
// repeat visit, an adjustment round, and rejection.
@@ -450,6 +454,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";
@@ -547,6 +553,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;
}