Merge pull request #34 from Tria-plc/logestic_chnage

Logestic chnage
This commit is contained in:
Nati Nigussie
2026-08-27 11:24:18 +03:00
committed by GitHub
19 changed files with 733 additions and 75 deletions

View File

@@ -23,6 +23,10 @@ interface Props {
onLogoPlacementChange: (placement: TemplateLogoPlacement) => void; onLogoPlacementChange: (placement: TemplateLogoPlacement) => void;
landscape: boolean; landscape: boolean;
onLandscapeChange: (landscape: boolean) => void; onLandscapeChange: (landscape: boolean) => void;
pageWidth: string;
onPageWidthChange: (width: string) => void;
pageHeight: string;
onPageHeightChange: (height: string) => void;
disabled: boolean; disabled: boolean;
} }
@@ -51,6 +55,10 @@ export function TemplateBackgroundPanel({
onLogoPlacementChange, onLogoPlacementChange,
landscape, landscape,
onLandscapeChange, onLandscapeChange,
pageWidth,
onPageWidthChange,
pageHeight,
onPageHeightChange,
disabled, disabled,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -91,9 +99,40 @@ export function TemplateBackgroundPanel({
]} ]}
/> />
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{landscape {pageWidth && pageHeight
? t('designer.a4Landscape', 'A4 — 297 × 210 mm') ? t('designer.customSize', 'Custom size — see below')
: t('designer.a4Portrait', 'A4 — 210 × 297 mm')} : landscape
? t('designer.a4Landscape', 'A4 — 297 × 210 mm')
: t('designer.a4Portrait', 'A4 — 210 × 297 mm')}
</Text>
</div>
<div>
<Text size="sm" fw={500} mb={4}>
{t('designer.customPageSize', 'Custom page size')}
</Text>
<Group gap="xs">
<TextInput
placeholder={t('designer.pageWidth', 'Width')}
value={pageWidth}
onChange={(e) => onPageWidthChange(e.currentTarget.value)}
disabled={disabled}
w={100}
/>
<Text size="sm" c="dimmed">×</Text>
<TextInput
placeholder={t('designer.pageHeight', 'Height')}
value={pageHeight}
onChange={(e) => onPageHeightChange(e.currentTarget.value)}
disabled={disabled}
w={100}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{t(
'designer.customSizeHint',
'e.g. 4.92in × 3.46in. Leave both blank to use A4. Overrides orientation\'s A4 size when set.',
)}
</Text> </Text>
</div> </div>
</Group> </Group>

View File

@@ -9,6 +9,8 @@ interface Props {
logoUrl: string; logoUrl: string;
logoPlacement: TemplateLogoPlacement; logoPlacement: TemplateLogoPlacement;
landscape: boolean; landscape: boolean;
pageWidth?: string;
pageHeight?: string;
placements: TemplateFieldPlacement[]; placements: TemplateFieldPlacement[];
selectedId: string | null; selectedId: string | null;
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
@@ -16,9 +18,17 @@ interface Props {
disabled: boolean; 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; 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<string, (offset: number) => React.CSSProperties> = { const LOGO_CORNER_STYLE: Record<string, (offset: number) => React.CSSProperties> = {
TOP_LEFT: (o) => ({ top: `${o}%`, left: `${o}%` }), TOP_LEFT: (o) => ({ top: `${o}%`, left: `${o}%` }),
TOP_CENTER: (o) => ({ top: `${o}%`, left: '50%', transform: 'translateX(-50%)' }), TOP_CENTER: (o) => ({ top: `${o}%`, left: '50%', transform: 'translateX(-50%)' }),
@@ -54,6 +64,8 @@ export function TemplateCanvas({
logoUrl, logoUrl,
logoPlacement, logoPlacement,
landscape, landscape,
pageWidth,
pageHeight,
placements, placements,
selectedId, selectedId,
onSelect, onSelect,
@@ -181,6 +193,18 @@ export function TemplateCanvas({
logoPlacement.offsetPct ?? 5, 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 ( return (
<Paper withBorder p="sm" radius="md"> <Paper withBorder p="sm" radius="md">
<Text size="xs" c="dimmed" mb="xs"> <Text size="xs" c="dimmed" mb="xs">
@@ -196,7 +220,7 @@ export function TemplateCanvas({
style={{ style={{
position: 'relative', position: 'relative',
width: '100%', width: '100%',
aspectRatio: landscape ? String(A4_RATIO) : String(1 / A4_RATIO), aspectRatio: String(aspectRatio),
background: '#ffffff', background: '#ffffff',
border: '1px solid var(--mantine-color-gray-4)', border: '1px solid var(--mantine-color-gray-4)',
overflow: 'hidden', overflow: 'hidden',

View File

@@ -11,8 +11,16 @@ export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
ARCHIVED: 'dark', 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 }; return { format: 'A4' as const, landscape, printBackground: true };
} }

View File

@@ -22,6 +22,11 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
const [source, setSource] = useState(''); const [source, setSource] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [landscape, setLandscape] = useState(true); 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 [backgroundUrl, setBackgroundUrl] = useState('');
const [logoUrl, setLogoUrl] = useState(''); const [logoUrl, setLogoUrl] = useState('');
const [logoPlacement, setLogoPlacement] = useState<TemplateLogoPlacement>({}); const [logoPlacement, setLogoPlacement] = useState<TemplateLogoPlacement>({});
@@ -49,6 +54,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
setSource(selected.hbsSource); setSource(selected.hbsSource);
setName(selected.name); setName(selected.name);
setLandscape(selected.pageOptions?.landscape ?? true); setLandscape(selected.pageOptions?.landscape ?? true);
setPageWidth(selected.pageOptions?.width ?? '');
setPageHeight(selected.pageOptions?.height ?? '');
setBackgroundUrl(selected.backgroundUrl ?? ''); setBackgroundUrl(selected.backgroundUrl ?? '');
setLogoUrl(selected.logoUrl ?? ''); setLogoUrl(selected.logoUrl ?? '');
setLogoPlacement(selected.logoPlacement ?? {}); setLogoPlacement(selected.logoPlacement ?? {});
@@ -70,6 +77,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
(source !== selected?.hbsSource || (source !== selected?.hbsSource ||
name !== selected?.name || name !== selected?.name ||
landscape !== (selected?.pageOptions?.landscape ?? true) || landscape !== (selected?.pageOptions?.landscape ?? true) ||
pageWidth !== (selected?.pageOptions?.width ?? '') ||
pageHeight !== (selected?.pageOptions?.height ?? '') ||
backgroundUrl !== (selected?.backgroundUrl ?? '') || backgroundUrl !== (selected?.backgroundUrl ?? '') ||
logoUrl !== (selected?.logoUrl ?? '') || logoUrl !== (selected?.logoUrl ?? '') ||
logoPlacementChanged || logoPlacementChanged ||
@@ -160,6 +169,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
setName, setName,
landscape, landscape,
setLandscape, setLandscape,
pageWidth,
setPageWidth,
pageHeight,
setPageHeight,
backgroundUrl, backgroundUrl,
setBackgroundUrl, setBackgroundUrl,
logoUrl, logoUrl,

View File

@@ -9,6 +9,8 @@ interface PreviewArgs {
hbsSource: string; hbsSource: string;
licenseTypeId: string | null; licenseTypeId: string | null;
landscape: boolean; landscape: boolean;
pageWidth?: string;
pageHeight?: string;
} }
/** /**
@@ -21,7 +23,7 @@ export function useTemplatePreview() {
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const open = useCallback( const open = useCallback(
async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => { async ({ hbsSource, licenseTypeId, landscape, pageWidth, pageHeight }: PreviewArgs) => {
try { try {
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query // 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 // and calls the API directly — which means spelling out the base URL and
@@ -36,7 +38,10 @@ export function useTemplatePreview() {
body: JSON.stringify({ body: JSON.stringify({
hbsSource, hbsSource,
licenseTypeId, licenseTypeId,
pageOptions: pageOptionsFor(landscape), pageOptions: pageOptionsFor(
landscape,
pageWidth && pageHeight ? { width: pageWidth, height: pageHeight } : undefined,
),
}), }),
}); });
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());

View File

@@ -221,6 +221,10 @@ export function CertificateDesignerPage() {
onLogoPlacementChange={draft.setLogoPlacement} onLogoPlacementChange={draft.setLogoPlacement}
landscape={draft.landscape} landscape={draft.landscape}
onLandscapeChange={draft.setLandscape} onLandscapeChange={draft.setLandscape}
pageWidth={draft.pageWidth}
onPageWidthChange={draft.setPageWidth}
pageHeight={draft.pageHeight}
onPageHeightChange={draft.setPageHeight}
disabled={editingLocked} disabled={editingLocked}
/> />
@@ -278,6 +282,8 @@ export function CertificateDesignerPage() {
logoUrl={draft.logoUrl} logoUrl={draft.logoUrl}
logoPlacement={draft.logoPlacement} logoPlacement={draft.logoPlacement}
landscape={draft.landscape} landscape={draft.landscape}
pageWidth={draft.pageWidth}
pageHeight={draft.pageHeight}
placements={draft.placements} placements={draft.placements}
selectedId={draft.selectedBlockId} selectedId={draft.selectedBlockId}
onSelect={draft.setSelectedBlockId} onSelect={draft.setSelectedBlockId}
@@ -345,6 +351,8 @@ export function CertificateDesignerPage() {
: draft.source, : draft.source,
licenseTypeId: typeId, licenseTypeId: typeId,
landscape: draft.landscape, landscape: draft.landscape,
pageWidth: draft.pageWidth || undefined,
pageHeight: draft.pageHeight || undefined,
}) })
} }
onSave={() => onSave={() =>
@@ -357,7 +365,12 @@ export function CertificateDesignerPage() {
// canvas layout is present, so sending the stale source // canvas layout is present, so sending the stale source
// alongside it would only fight that. // alongside it would only fight that.
hbsSource: draft.usesCanvas ? undefined : draft.source, 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, backgroundUrl: draft.backgroundUrl || undefined,
logoUrl: draft.logoUrl || undefined, logoUrl: draft.logoUrl || undefined,
logoPlacement: draft.logoPlacement, logoPlacement: draft.logoPlacement,

View File

@@ -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<string | null>(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 (
<Modal opened={opened} onClose={onClose} title={title} size="md">
<Stack gap="md">
{applicationNumber && (
<Text size="sm" c="dimmed">
{applicationNumber}
</Text>
)}
<Select
label={
kind === 'review'
? t('queue.assignReviewTo', 'Employee to review the documents')
: t('queue.assignInspectionTo', 'Employee to conduct the inspection')
}
placeholder={t('queue.selectEmployee', 'Select an employee')}
data={officers.map((o) => ({
value: o.id,
label: o.name ?? o.id,
}))}
value={officerId}
onChange={setOfficerId}
disabled={isLoading}
searchable
withAsterisk
/>
<Textarea
label={t('queue.assignRemark', 'Instructions')}
description={t(
'queue.assignRemarkHint',
'Optional. Sent with the assignment notification.',
)}
value={remark}
onChange={(e) => setRemark(e.currentTarget.value)}
autosize
minRows={2}
/>
<ModalFooter>
<Button variant="default" onClick={onClose} size="sm">
{t('common.cancel', 'Cancel')}
</Button>
<Button
size="sm"
loading={loading}
disabled={!officerId}
onClick={() => officerId && onConfirm(officerId, remark || undefined)}
>
{t('queue.assignConfirm', 'Assign')}
</Button>
</ModalFooter>
</Stack>
</Modal>
);
}

View File

@@ -134,7 +134,13 @@ export function DecisionConfirmModal({
if (!action) return null; 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 reasonMissing = action.requiresReason && !reason.trim() && !reasonCode;
const blocked = const blocked =
reasonMissing || reasonMissing ||
@@ -177,7 +183,11 @@ export function DecisionConfirmModal({
label={ label={
action.id === 'escalate' action.id === 'escalate'
? t('review.supervisor', 'Supervisor') ? 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')} placeholder={t('review.officerPlaceholder', 'Select who takes this on')}
data={officers.map((officer) => ({ data={officers.map((officer) => ({

View File

@@ -18,6 +18,10 @@ export type ActionTier =
export type ActionId = export type ActionId =
| 'claim' | 'claim'
| 'assign' | 'assign'
| 'assign-reviewer'
| 'report-review'
| 'assign-inspector'
| 'report-inspection'
| 'escalate' | 'escalate'
| 'hold' | 'hold'
| 'resume' | 'resume'
@@ -65,6 +69,56 @@ export interface ActionDefinition {
*/ */
export const ACTIONS: ActionDefinition[] = [ export const ACTIONS: ActionDefinition[] = [
// ------------------------------------------------------------- workflow // ------------------------------------------------------------- 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 // Claim is deliberately absent here: an officer claims from the queue
// (LicenseQueuePage), not from this detail page. That implementation is // (LicenseQueuePage), not from this detail page. That implementation is
// separate — see LicenseQueuePage/actions.tsx — and is unaffected by this. // separate — see LicenseQueuePage/actions.tsx — and is unaffected by this.
@@ -158,6 +212,9 @@ export const ACTIONS: ActionDefinition[] = [
id: 'final-approve', id: 'final-approve',
tier: 'primary', tier: 'primary',
labelKey: 'review.actions.finalApprove', labelKey: 'review.actions.finalApprove',
from: ['INSPECTION_COMPLETED',
'REVIEW_REPORTED',
'INSPECTION_REPORTED', 'UNDER_EVALUATION'],
// ELIGIBILITY_PAID: an examined certificate (CoC/CoP) is decided straight // ELIGIBILITY_PAID: an examined certificate (CoC/CoP) is decided straight
// off the eligibility queue — no assignment step. // off the eligibility queue — no assignment step.
from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION', 'ELIGIBILITY_PAID'], from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION', 'ELIGIBILITY_PAID'],
@@ -170,6 +227,9 @@ export const ACTIONS: ActionDefinition[] = [
id: 'request-adjustment', id: 'request-adjustment',
tier: 'primary', tier: 'primary',
labelKey: 'review.actions.requestAdjustment', labelKey: 'review.actions.requestAdjustment',
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED',
'REVIEW_REPORTED',
'INSPECTION_REPORTED'],
from: [ from: [
'UNDER_REVIEW', 'UNDER_REVIEW',
'UNDER_EVALUATION', 'UNDER_EVALUATION',
@@ -191,6 +251,8 @@ export const ACTIONS: ActionDefinition[] = [
'UNDER_EVALUATION', 'UNDER_EVALUATION',
'INSPECTION_PENDING', 'INSPECTION_PENDING',
'INSPECTION_COMPLETED', 'INSPECTION_COMPLETED',
'REVIEW_REPORTED',
'INSPECTION_REPORTED',
'INSPECTION_FAILED', 'INSPECTION_FAILED',
'ELIGIBILITY_PAID', 'ELIGIBILITY_PAID',
], ],

View File

@@ -7,11 +7,12 @@ import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
export function licenseQueueActionsColumn( export function licenseQueueActionsColumn(
t: TFunction, t: TFunction,
handlers: { handlers: {
claiming: boolean; assigning: boolean;
onClaim: (id: string) => void; /** Opens the assign dialog. Team leaders only — see below. */
onAssign: (application: LicenseApplication) => void;
onOpen: (id: string) => void; onOpen: (id: string) => void;
/** False for a non-logistics queue — there's no unclaimed pool to claim from. */ /** False for a non-logistics queue — nothing there is dispatched. */
claimable?: boolean; assignable?: boolean;
}, },
): AdvancedColumn<LicenseApplication> { ): AdvancedColumn<LicenseApplication> {
return { return {
@@ -19,24 +20,32 @@ export function licenseQueueActionsColumn(
label: t("queue.actionsColumn", "Actions"), label: t("queue.actionsColumn", "Actions"),
align: "right", align: "right",
size: 140, 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 }) => cell: ({ row }) =>
handlers.claimable !== false && handlers.assignable !== false &&
row.original.assignedOfficerId === null && 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, // (CoC/CoP) sits in ELIGIBILITY_PAID once its eligibility fee clears,
// not SUBMITTED. // not SUBMITTED.
(row.original.status === "SUBMITTED" || (row.original.status === "SUBMITTED" ||
row.original.status === "ELIGIBILITY_PAID") ? ( row.original.status === "ELIGIBILITY_PAID") ? (
<RequirePermission <RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]} anyOf={[LICENSE_PERMISSIONS.ASSIGN_APPLICATION]}
hideOnly hideOnly
> >
<Button <Button
size="xs" size="xs"
loading={handlers.claiming} loading={handlers.assigning}
onClick={() => handlers.onClaim(row.original.id)} onClick={() => handlers.onAssign(row.original)}
> >
{t("queue.claim", "Claim")} {t("queue.assign", "Assign")}
</Button> </Button>
</RequirePermission> </RequirePermission>
) : ( ) : (

View File

@@ -35,7 +35,7 @@ import {
familyLabels, familyLabels,
localized, localized,
resolveFamilyKind, resolveFamilyKind,
useClaimApplicationMutation, useAssignReviewerMutation,
useGetAllApplicationsQuery, useGetAllApplicationsQuery,
useGetAssignedToMeQuery, useGetAssignedToMeQuery,
useGetLicenseTypesQuery, useGetLicenseTypesQuery,
@@ -54,7 +54,6 @@ import {
AmharicDatePicker, AmharicDatePicker,
type AdvancedColumn, type AdvancedColumn,
} from "@ema-platform/ui"; } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import { import {
DEFAULT_VIEW, DEFAULT_VIEW,
SAVED_VIEWS, SAVED_VIEWS,
@@ -71,6 +70,7 @@ import { setDensity } from "../../../../store/preferences.slice";
import { useAppDispatch, useAppSelector } from "../../../../store/hooks"; import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard"; import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
import { licenseQueueColumns } from "./columns"; import { licenseQueueColumns } from "./columns";
import { AssignDialog } from "../../components/AssignDialog";
import { licenseQueueActionsColumn } from "./actions"; import { licenseQueueActionsColumn } from "./actions";
import { PageHeader } from '@ema-platform/ui'; import { PageHeader } from '@ema-platform/ui';
@@ -268,7 +268,8 @@ export function LicenseQueuePage() {
? mineQuery ? mineQuery
: allQuery; : allQuery;
const [claim, { isLoading: claiming }] = useClaimApplicationMutation(); const [assignReviewer, { isLoading: assigning }] = useAssignReviewerMutation();
const [assignTarget, setAssignTarget] = useState<LicenseApplication | null>(null);
const [runExport, { isFetching: exporting }] = const [runExport, { isFetching: exporting }] =
useLazyExportApplicationsQuery(); useLazyExportApplicationsQuery();
@@ -355,58 +356,40 @@ export function LicenseQueuePage() {
setFacet({ sortBy: field, sortDir: dir }); 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 { try {
await claim(id).unwrap(); await assignReviewer({ id: assignTarget.id, officerId, remark }).unwrap();
notifications.show({ notifications.show({
color: "teal", color: "teal",
title: t("queue.claimed", "Claimed"), title: t("queue.assigned", "Assigned"),
message: t( message: t(
"queue.claimedBody", "queue.assignedBody",
"The application is now assigned to you.", "The employee has been notified and the review has started.",
), ),
}); });
changeView("mine"); setAssignTarget(null);
active.refetch();
} catch (err) { } catch (err) {
// A 409 means another officer got there first — refresh so the queue
// stops showing work that is no longer available.
notifications.show({ notifications.show({
color: "red", color: "red",
title: t("queue.claimFailed", "Could not claim"), title: t("queue.assignFailed", "Could not assign"),
message: extractErrorMessage( message: extractErrorMessage(
err, err,
t("queue.claimRace", "Another officer already claimed it."), t("queue.assignError", "The application could not be assigned."),
), ),
}); });
active.refetch(); 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]; const cursorRow = items[cursor];
useQueueKeyboard({ useQueueKeyboard({
enabled: !helpOpen, enabled: !helpOpen,
@@ -415,6 +398,12 @@ export function LicenseQueuePage() {
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)), onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`), onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
onClaim: () => { 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 // 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. // elsewhere is a no-op rather than an error the officer has to read.
if (claimable && cursorRow && cursorRow.assignedOfficerId === null) if (claimable && cursorRow && cursorRow.assignedOfficerId === null)
@@ -479,9 +468,12 @@ export function LicenseQueuePage() {
isLogistics, isLogistics,
}), }),
licenseQueueActionsColumn(t, { licenseQueueActionsColumn(t, {
claiming, assigning,
onClaim: handleClaim, onAssign: setAssignTarget,
onOpen: (id) => navigate(`/licence-review/${id}`), 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 // Applications with no unclaimed pool (see `hasUnclaimedPool`) are
// never claimed — every row opens straight to Review. // never claimed — every row opens straight to Review.
claimable, claimable,
@@ -495,7 +487,7 @@ export function LicenseQueuePage() {
selected, selected,
allSelected, allSelected,
items, items,
claiming, assigning,
isLogistics, isLogistics,
claimable, claimable,
], ],
@@ -782,6 +774,14 @@ export function LicenseQueuePage() {
</Group> </Group>
</Paper> </Paper>
)} )}
<AssignDialog
opened={assignTarget !== null}
onClose={() => setAssignTarget(null)}
kind="review"
applicationNumber={assignTarget?.applicationNumber}
loading={assigning}
onConfirm={handleAssign}
/>
</Container> </Container>
); );
} }

View File

@@ -4,9 +4,12 @@ import {
ActionIcon, ActionIcon,
Alert, Alert,
Badge, Badge,
Button,
Container, Container,
FileButton,
Grid, Grid,
Group, Group,
Loader,
Modal, Modal,
NumberInput, NumberInput,
Paper, Paper,
@@ -24,8 +27,11 @@ import {
import { import {
IconAlertTriangle, IconAlertTriangle,
IconCheck, IconCheck,
IconEye,
IconFileDownload,
IconLayoutSidebarRightCollapse, IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand, IconLayoutSidebarRightExpand,
IconPaperclip,
IconQuestionMark, IconQuestionMark,
IconX, IconX,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -39,6 +45,10 @@ import {
useLocalized, useLocalized,
useApproveDocumentsMutation, useApproveDocumentsMutation,
useAssignApplicationMutation, useAssignApplicationMutation,
useAssignReviewerMutation,
useAssignInspectorMutation,
useReportReviewMutation,
useReportInspectionMutation,
useClaimApplicationMutation, useClaimApplicationMutation,
useCompleteReviewMutation, useCompleteReviewMutation,
useConfirmPaymentMutation, useConfirmPaymentMutation,
@@ -60,6 +70,8 @@ import {
useRequestAdjustmentMutation, useRequestAdjustmentMutation,
useResumeApplicationMutation, useResumeApplicationMutation,
useScheduleInspectionMutation, useScheduleInspectionMutation,
useGetCertificateUrlForOfficerMutation,
uploadDocument,
type RemarkTargetType, type RemarkTargetType,
type StaffEvidenceRequirement, type StaffEvidenceRequirement,
} from "@ema-platform/api"; } from "@ema-platform/api";
@@ -210,6 +222,11 @@ export function LicenseReviewPage() {
const [confirmPayment] = useConfirmPaymentMutation(); const [confirmPayment] = useConfirmPaymentMutation();
const [scheduleIssuance] = useScheduleIssuanceMutation(); const [scheduleIssuance] = useScheduleIssuanceMutation();
const [issueCertificate] = useIssueCertificateMutation(); const [issueCertificate] = useIssueCertificateMutation();
const [getCertificateUrlForOfficer] = useGetCertificateUrlForOfficerMutation();
const [certificateBusy, setCertificateBusy] = useState<"view" | "download" | null>(
null,
);
const [certificatePreview, setCertificatePreview] = useState<string | null>(null);
const [scheduleExam, { isLoading: schedulingExam }] = const [scheduleExam, { isLoading: schedulingExam }] =
useScheduleExamMutation(); useScheduleExamMutation();
const [recordExamOutcome] = useRecordExamOutcomeMutation(); const [recordExamOutcome] = useRecordExamOutcomeMutation();
@@ -217,6 +234,13 @@ export function LicenseReviewPage() {
const [resumeApplication] = useResumeApplicationMutation(); const [resumeApplication] = useResumeApplicationMutation();
const [escalateApplication] = useEscalateApplicationMutation(); const [escalateApplication] = useEscalateApplicationMutation();
const [assignApplication] = useAssignApplicationMutation(); 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 // Real officer list, so Assign and Escalate name a person instead of
// silently reassigning to whoever already held the application. // silently reassigning to whoever already held the application.
const { data: officers = [] } = useGetAssignableOfficersQuery(); const { data: officers = [] } = useGetAssignableOfficersQuery();
@@ -234,6 +258,9 @@ export function LicenseReviewPage() {
const [railOpen, setRailOpen] = useState(true); const [railOpen, setRailOpen] = useState(true);
const [inspectionOpen, setInspectionOpen] = useState(false); const [inspectionOpen, setInspectionOpen] = useState(false);
const [inspectionDate, setInspectionDate] = useState(""); const [inspectionDate, setInspectionDate] = useState("");
const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
"MORNING",
);
const [issuanceOpen, setIssuanceOpen] = useState(false); const [issuanceOpen, setIssuanceOpen] = useState(false);
const [issuanceDate, setIssuanceDate] = useState(""); const [issuanceDate, setIssuanceDate] = useState("");
const [resultOpen, setResultOpen] = useState(false); const [resultOpen, setResultOpen] = useState(false);
@@ -241,6 +268,10 @@ export function LicenseReviewPage() {
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false); const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
const [examScore, setExamScore] = useState<number | undefined>(); const [examScore, setExamScore] = useState<number | undefined>();
const [findings, setFindings] = useState(""); const [findings, setFindings] = useState("");
const [findingsUploadBusy, setFindingsUploadBusy] = useState(false);
const [findingsPreview, setFindingsPreview] = useState<
{ url: string; title: string } | null
>(null);
const [checklist, setChecklist] = useState< const [checklist, setChecklist] = useState<
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION"> Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
>({}); >({});
@@ -286,6 +317,12 @@ export function LicenseReviewPage() {
new Date(pendingInspection.scheduledDate) > new Date(), 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 // Approving means every uploaded document was accepted — one unjudged or
// rejected file is enough to keep the decision buttons dead. The counts feed // 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 // 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. */ /** Actions with their own dedicated form open that; the rest confirm. */
function handleAction(action: ResolvedAction) { function handleAction(action: ResolvedAction) {
switch (action.id) { switch (action.id) {
@@ -554,6 +619,46 @@ export function LicenseReviewPage() {
t("review.done.claim", "Claimed — the application is now yours"), t("review.done.claim", "Claimed — the application is now yours"),
); );
break; 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": case "complete-review":
await run( await run(
() => () =>
@@ -844,6 +949,38 @@ export function LicenseReviewPage() {
</Stack> </Stack>
</Paper> </Paper>
{/* Only once the certificate actually exists — before that there
is nothing to view or download yet. */}
{app.issuedLicenseId && (
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
{t("review.certificate", "Certificate")}
</Text>
<Group gap="xs">
<Button
size="xs"
variant="light"
leftSection={<IconEye size={14} />}
loading={certificateBusy === "view"}
disabled={Boolean(certificateBusy)}
onClick={() => openCertificate("view")}
>
{t("review.view", "View")}
</Button>
<Button
size="xs"
variant="light"
leftSection={<IconFileDownload size={14} />}
loading={certificateBusy === "download"}
disabled={Boolean(certificateBusy)}
onClick={() => openCertificate("download")}
>
{t("review.download", "Download")}
</Button>
</Group>
</Paper>
)}
{/* Eligibility, checked and shown — not applied invisibly. */} {/* Eligibility, checked and shown — not applied invisibly. */}
{eligibility.length > 0 && ( {eligibility.length > 0 && (
<Paper withBorder p="md"> <Paper withBorder p="md">
@@ -1104,7 +1241,15 @@ export function LicenseReviewPage() {
<div> <div>
<Text size="sm"> <Text size="sm">
{inspection.scheduledDate {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")} : t("review.unscheduled", "Not scheduled")}
</Text> </Text>
{inspection.findings && ( {inspection.findings && (
@@ -1289,14 +1434,21 @@ export function LicenseReviewPage() {
> >
<Stack> <Stack>
<AmharicDatePicker <AmharicDatePicker
label={t("review.dateTime", "Date and time")} label={t("review.date", "Date")}
value={inspectionDate} value={inspectionDate}
onChange={setInspectionDate} onChange={setInspectionDate}
withTime />
<SegmentedControl
value={inspectionTimeSlot}
onChange={(value) => setInspectionTimeSlot(value as "MORNING" | "AFTERNOON")}
data={[
{ value: "MORNING", label: t("review.morning", "Morning") },
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
]}
/> />
<ModalFooter> <ModalFooter>
<Tooltip <Tooltip
label={t("review.pickDate", "Pick a date and time first")} label={t("review.pickDate", "Pick a date first")}
disabled={Boolean(inspectionDate)} disabled={Boolean(inspectionDate)}
> >
<span> <span>
@@ -1314,6 +1466,7 @@ export function LicenseReviewPage() {
await scheduleInspection({ await scheduleInspection({
applicationId: id, applicationId: id,
scheduledDate: inspectionDate, scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
}).unwrap(); }).unwrap();
setInspectionOpen(false); setInspectionOpen(false);
}, },
@@ -1412,6 +1565,60 @@ export function LicenseReviewPage() {
autosize autosize
minRows={3} minRows={3}
/> />
<Stack gap={4}>
<Text size="sm" fw={500}>
{t("review.findingsEvidence", "Supporting documents")}
</Text>
<Group gap="xs">
{findingsEvidence.flatMap((attachment) =>
(attachment.files ?? []).map((file) => (
<ActionIcon
key={file.id}
variant="light"
size="lg"
aria-label={file.originalName}
onClick={() =>
file.url &&
setFindingsPreview({ url: file.url, title: file.originalName })
}
>
<IconEye size={16} />
</ActionIcon>
)),
)}
<FileButton
accept="application/pdf,image/jpeg,image/png"
onChange={async (file) => {
if (!file || !pendingInspection) return;
setFindingsUploadBusy(true);
await uploadDocument({
ownerType: "INSPECTION",
ownerId: pendingInspection.id,
documentKey: `evidence-${Date.now()}`,
file,
});
setFindingsUploadBusy(false);
refetchFindingsEvidence();
}}
>
{(props) => (
<ActionIcon
{...props}
variant="outline"
size="lg"
disabled={!pendingInspection || findingsUploadBusy}
aria-label={t("review.uploadEvidence", "Upload document")}
>
{findingsUploadBusy ? (
<Loader size={14} type="oval" />
) : (
<IconPaperclip size={16} />
)}
</ActionIcon>
)}
</FileButton>
</Group>
</Stack>
{inspectionNotYetDue && ( {inspectionNotYetDue && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}> <Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("review.disabled.inspectionNotYetDue", { {t("review.disabled.inspectionNotYetDue", {
@@ -1481,6 +1688,20 @@ export function LicenseReviewPage() {
</ModalFooter> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
<PdfPreviewModal
opened={Boolean(findingsPreview)}
onClose={() => setFindingsPreview(null)}
url={findingsPreview?.url ?? ""}
title={findingsPreview?.title}
/>
<PdfPreviewModal
opened={Boolean(certificatePreview)}
onClose={() => setCertificatePreview(null)}
url={certificatePreview ?? ""}
title={t("review.certificate", "Certificate")}
/>
</Container> </Container>
); );
} }

View File

@@ -899,6 +899,11 @@ export const am: Translations = {
submitted: "የቀረበበት", submitted: "የቀረበበት",
sla: "ዕድሜ / የጊዜ ገደብ", sla: "ዕድሜ / የጊዜ ገደብ",
claim: "ውሰድ", claim: "ውሰድ",
assign: "መድብ",
assigned: "ተመድቧል",
assignedBody: "ባለሙያው ተነግሮታል፤ ግምገማው ተጀምሯል።",
assignFailed: "መመደብ አልተቻለም",
assignError: "ማመልከቻውን መመደብ አልተቻለም።",
review: "ገምግም", review: "ገምግም",
claimed: "ተወስዷል", claimed: "ተወስዷል",
claimedBody: "ማመልከቻው አሁን ለእርስዎ ተመድቧል።", claimedBody: "ማመልከቻው አሁን ለእርስዎ ተመድቧል።",
@@ -974,9 +979,14 @@ export const am: Translations = {
unscheduled: "አልተያዘም", unscheduled: "አልተያዘም",
inspectionResult: "የምርመራ ውጤት", inspectionResult: "የምርመራ ውጤት",
findings: "ግኝቶች", findings: "ግኝቶች",
findingsEvidence: "አጋዥ ሰነዶች",
uploadEvidence: "ሰነድ መጫን",
dateTime: "ቀን እና ሰዓት", dateTime: "ቀን እና ሰዓት",
date: "ቀን",
morning: "ጠዋት",
afternoon: "ከሰዓት በኋላ",
schedule: "ያዝ", schedule: "ያዝ",
pickDate: "መጀመሪያ ቀን እና ሰዓት ይምረጡ", pickDate: "መጀመሪያ ቀን ይምረጡ",
passed: "አልፏል", passed: "አልፏል",
failed: "ወድቋል", failed: "ወድቋል",
round_one: "ዙር {{count}}", round_one: "ዙር {{count}}",
@@ -985,6 +995,10 @@ export const am: Translations = {
linkCopied: "አገናኝ ተቀድቷል", linkCopied: "አገናኝ ተቀድቷል",
actionFailed: "ተግባሩ አልተሳካም", actionFailed: "ተግባሩ አልተሳካም",
errorTitle: "ይህን ማመልከቻ መጫን አልተቻለም", errorTitle: "ይህን ማመልከቻ መጫን አልተቻለም",
certificate: "ምስክር ወረቀት",
view: "ይመልከቱ",
download: "አውርድ",
certificateError: "ምስክር ወረቀቱን መክፈት አልተቻለም",
hideActivity: "እንቅስቃሴ ደብቅ", hideActivity: "እንቅስቃሴ ደብቅ",
showActivity: "እንቅስቃሴ አሳይ", showActivity: "እንቅስቃሴ አሳይ",
awaitingPayment: "አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።", awaitingPayment: "አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።",
@@ -998,6 +1012,10 @@ export const am: Translations = {
actions: { actions: {
claim: "ውሰድ", claim: "ውሰድ",
assign: "መድብ", assign: "መድብ",
assignReviewer: "ግምገማ መድብ",
reportReview: "ለቡድን መሪ አሳውቅ",
assignInspector: "ምርመራ መድብ",
reportInspection: "የምርመራ ውጤት አሳውቅ",
assignReviewer: "ገምጋሚ መድብ", assignReviewer: "ገምጋሚ መድብ",
escalate: "ወደ ላይ አሳድግ", escalate: "ወደ ላይ አሳድግ",
hold: "አግድ", hold: "አግድ",

View File

@@ -908,6 +908,11 @@ export const en = {
submitted: 'Submitted', submitted: 'Submitted',
sla: 'Age / SLA', sla: 'Age / SLA',
claim: 'Claim', 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', review: 'Review',
claimed: 'Claimed', claimed: 'Claimed',
claimedBody: 'The application is now assigned to you.', claimedBody: 'The application is now assigned to you.',
@@ -983,9 +988,14 @@ export const en = {
unscheduled: 'Not scheduled', unscheduled: 'Not scheduled',
inspectionResult: 'Inspection result', inspectionResult: 'Inspection result',
findings: 'Findings', findings: 'Findings',
findingsEvidence: 'Supporting documents',
uploadEvidence: 'Upload document',
dateTime: 'Date and time', dateTime: 'Date and time',
date: 'Date',
morning: 'Morning',
afternoon: 'Afternoon',
schedule: 'Schedule', schedule: 'Schedule',
pickDate: 'Pick a date and time first', pickDate: 'Pick a date first',
passed: 'Passed', passed: 'Passed',
failed: 'Failed', failed: 'Failed',
round_one: 'round {{count}}', round_one: 'round {{count}}',
@@ -994,6 +1004,10 @@ export const en = {
linkCopied: 'Link copied', linkCopied: 'Link copied',
actionFailed: 'Action failed', actionFailed: 'Action failed',
errorTitle: 'Could not load this application', errorTitle: 'Could not load this application',
certificate: 'Certificate',
view: 'View',
download: 'Download',
certificateError: 'Could not open the certificate',
hideActivity: 'Hide activity', hideActivity: 'Hide activity',
showActivity: 'Show activity', showActivity: 'Show activity',
awaitingPayment: 'Waiting for the applicant to pay {{amount}} {{currency}}.', awaitingPayment: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
@@ -1007,6 +1021,10 @@ export const en = {
actions: { actions: {
claim: 'Claim', claim: 'Claim',
assign: 'Assign', assign: 'Assign',
assignReviewer: 'Assign review',
reportReview: 'Report to team leader',
assignInspector: 'Assign inspection',
reportInspection: 'Report inspection result',
assignReviewer: 'Assign reviewer', assignReviewer: 'Assign reviewer',
escalate: 'Escalate', escalate: 'Escalate',
hold: 'Put on hold', hold: 'Put on hold',

View File

@@ -96,6 +96,13 @@ export const NAV_SECTIONS: NavSection[] = [
label: 'nav.groupSeafarer', label: 'nav.groupSeafarer',
items: [ items: [
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] }, { 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: '/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_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: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
@@ -111,8 +118,8 @@ export const NAV_SECTIONS: NavSection[] = [
items: [ items: [
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] }, { 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: '/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_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE }, { 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] }, { to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
], ],
}, },

View File

@@ -21,9 +21,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
value={{ value={{
appName: 'Portal', appName: 'Portal',
storagePrefix: 'ema-portal', storagePrefix: 'ema-portal',
// Applicants land on the licence list rather than the seafarer loginRedirectPath: '/dashboard',
// dashboard: signing up here is the first step of applying.
loginRedirectPath: '/licensing/applications',
enableSignup: true, enableSignup: true,
enableForgotPassword: true, enableForgotPassword: true,
}} }}

View File

@@ -511,6 +511,16 @@ export const licensingApi = baseApi
query: (id) => ({ url: `/licenses/${id}/certificate` }), 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 // ------------------------------------------------------------- review
getQueue: builder.query<Paginated<LicenseApplication>, QueueFilter | void>({ getQueue: builder.query<Paginated<LicenseApplication>, QueueFilter | void>({
query: (params) => ({ query: (params) => ({
@@ -842,6 +852,66 @@ export const licensingApi = baseApi
query: () => ({ url: '/license-application-review/officers' }), 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 // ----------------------------------------------------- workflow controls
assignApplication: builder.mutation< assignApplication: builder.mutation<
LicenseApplication, LicenseApplication,
@@ -946,7 +1016,12 @@ export const licensingApi = baseApi
// --------------------------------------------------------- inspection // --------------------------------------------------------- inspection
scheduleInspection: builder.mutation< scheduleInspection: builder.mutation<
Inspection, Inspection,
{ applicationId: string; scheduledDate: string; location?: string } {
applicationId: string;
scheduledDate: string;
timeSlot: 'MORNING' | 'AFTERNOON';
location?: string;
}
>({ >({
query: (body) => ({ url: '/inspections', method: 'POST', body }), query: (body) => ({ url: '/inspections', method: 'POST', body }),
invalidatesTags: (_r, error, { applicationId }) => invalidatesTags: (_r, error, { applicationId }) =>
@@ -1034,6 +1109,7 @@ export const {
useGetMyLicensesQuery, useGetMyLicensesQuery,
useGetLicensesQuery, useGetLicensesQuery,
useGetCertificateUrlMutation, useGetCertificateUrlMutation,
useGetCertificateUrlForOfficerMutation,
useGetApplicationPaymentQuery, useGetApplicationPaymentQuery,
usePatchSectionMutation, usePatchSectionMutation,
useAddStaffMutation, useAddStaffMutation,
@@ -1068,6 +1144,9 @@ export const {
useReinstateLicenseMutation, useReinstateLicenseMutation,
useAssignApplicationMutation, useAssignApplicationMutation,
useAssignReviewerMutation, useAssignReviewerMutation,
useAssignInspectorMutation,
useReportReviewMutation,
useReportInspectionMutation,
useHoldApplicationMutation, useHoldApplicationMutation,
useResumeApplicationMutation, useResumeApplicationMutation,
useEscalateApplicationMutation, useEscalateApplicationMutation,

View File

@@ -66,7 +66,9 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
UNDER_EVALUATION: 'Under Evaluation', UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required', RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending', INSPECTION_PENDING: 'Inspection Pending',
REVIEW_REPORTED: 'Review Reported',
INSPECTION_COMPLETED: 'Inspection Completed', INSPECTION_COMPLETED: 'Inspection Completed',
INSPECTION_REPORTED: 'Inspection Reported',
INSPECTION_FAILED: 'Inspection Failed', INSPECTION_FAILED: 'Inspection Failed',
APPROVED: 'Approved', APPROVED: 'Approved',
REJECTED: 'Rejected', REJECTED: 'Rejected',
@@ -94,7 +96,11 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
UNDER_EVALUATION: 'indigo', UNDER_EVALUATION: 'indigo',
RESUBMIT_REQUIRED: 'orange', RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'cyan', 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_COMPLETED: 'cyan',
INSPECTION_REPORTED: 'orange',
// Orange, not red: recoverable — a re-inspection can still pass. // Orange, not red: recoverable — a re-inspection can still pass.
INSPECTION_FAILED: 'orange', INSPECTION_FAILED: 'orange',
APPROVED: 'teal', APPROVED: 'teal',
@@ -129,7 +135,9 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
UNDER_EVALUATION: 45, UNDER_EVALUATION: 45,
RESUBMIT_REQUIRED: 30, RESUBMIT_REQUIRED: 30,
INSPECTION_PENDING: 55, INSPECTION_PENDING: 55,
REVIEW_REPORTED: 50,
INSPECTION_COMPLETED: 65, INSPECTION_COMPLETED: 65,
INSPECTION_REPORTED: 70,
// A re-inspection returns to the pending step, so no further along than it. // A re-inspection returns to the pending step, so no further along than it.
INSPECTION_FAILED: 55, INSPECTION_FAILED: 55,
APPROVED: 75, APPROVED: 75,

View File

@@ -26,9 +26,13 @@ export type LicenseStatus =
| "SUBMITTED" | "SUBMITTED"
| "UNDER_REVIEW" | "UNDER_REVIEW"
| "UNDER_EVALUATION" | "UNDER_EVALUATION"
// Employee filed their review; parked with the team leader for a decision.
| "REVIEW_REPORTED"
| "RESUBMIT_REQUIRED" | "RESUBMIT_REQUIRED"
| "INSPECTION_PENDING" | "INSPECTION_PENDING"
| "INSPECTION_COMPLETED" | "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 // The inspection was conducted and failed. Approval and issuance are
// unreachable until a re-inspection passes; the officer chooses between a // unreachable until a re-inspection passes; the officer chooses between a
// repeat visit, an adjustment round, and rejection. // repeat visit, an adjustment round, and rejection.
@@ -450,6 +454,8 @@ export interface Inspection {
inspectorId: string | null; inspectorId: string | null;
inspectorName: string | null; inspectorName: string | null;
scheduledDate: string | null; scheduledDate: string | null;
/** Half-day slot the site visit is booked into. */
timeSlot: "MORNING" | "AFTERNOON" | null;
conductedDate: string | null; conductedDate: string | null;
location: string | null; location: string | null;
status: "SCHEDULED" | "COMPLETED" | "CANCELLED"; status: "SCHEDULED" | "COMPLETED" | "CANCELLED";
@@ -547,6 +553,13 @@ export type TemplateStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export interface TemplatePageOptions { export interface TemplatePageOptions {
format?: "A4" | "A5" | "Letter" | "Legal"; 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; landscape?: boolean;
printBackground?: boolean; printBackground?: boolean;
} }