mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 08:21:00 +00:00
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "አግድ",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user