logestics chnage

This commit is contained in:
Fistum
2026-08-22 07:54:35 +00:00
parent 81d3ebdcd9
commit 0ac969cca2
11 changed files with 380 additions and 73 deletions

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'
@@ -75,6 +79,46 @@ export const ACTIONS: ActionDefinition[] = [
permissions: ['can:claim:license-application'], permissions: ['can:claim:license-application'],
emphasis: 'light', 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',
},
{ {
id: 'assign', id: 'assign',
tier: 'workflow', tier: 'workflow',
@@ -162,7 +206,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', 'UNDER_EVALUATION'], from: ['INSPECTION_COMPLETED',
'REVIEW_REPORTED',
'INSPECTION_REPORTED', 'UNDER_EVALUATION'],
permissions: ['can:approve:license-application'], permissions: ['can:approve:license-application'],
emphasis: 'filled', emphasis: 'filled',
color: 'teal', color: 'teal',
@@ -172,7 +218,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'], from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED',
'REVIEW_REPORTED',
'INSPECTION_REPORTED'],
permissions: ['can:request-adjustment:license-application'], permissions: ['can:request-adjustment:license-application'],
emphasis: 'light', emphasis: 'light',
color: 'orange', color: 'orange',
@@ -187,6 +235,8 @@ export const ACTIONS: ActionDefinition[] = [
'UNDER_EVALUATION', 'UNDER_EVALUATION',
'INSPECTION_PENDING', 'INSPECTION_PENDING',
'INSPECTION_COMPLETED', 'INSPECTION_COMPLETED',
'REVIEW_REPORTED',
'INSPECTION_REPORTED',
], ],
permissions: ['can:reject:license-application'], permissions: ['can:reject:license-application'],
emphasis: 'light', emphasis: 'light',

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,
@@ -70,6 +69,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';
@@ -267,7 +267,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();
@@ -354,58 +355,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,
@@ -414,10 +397,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: () => {
// Only unclaimed rows on a logistics queue can be claimed; pressing c // "c" now opens the assign dialog on an undispatched row. Kept on the
// elsewhere is a no-op rather than an error the officer has to read. // 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) if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
handleClaim(cursorRow.id); setAssignTarget(cursorRow);
}, },
onEscape: () => setSelected([]), onEscape: () => setSelected([]),
onHelp: () => setHelpOpen(true), onHelp: () => setHelpOpen(true),
@@ -478,12 +463,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 claimed off a shared queue (see // Non-logistics applications aren't dispatched off a shared queue (see
// `savedViewsForFamily`) — every row opens straight to Review. // `savedViewsForFamily`) — every row opens straight to Review.
claimable: isLogistics !== false, assignable: isLogistics !== false,
}), }),
], ],
[ [
@@ -494,7 +479,7 @@ export function LicenseQueuePage() {
selected, selected,
allSelected, allSelected,
items, items,
claiming, assigning,
isLogistics, isLogistics,
], ],
); );
@@ -763,23 +748,18 @@ export function LicenseQueuePage() {
> >
{t("queue.export", "Export CSV")} {t("queue.export", "Export CSV")}
</Button> </Button>
{isLogistics !== false && (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly
>
<Button loading={claiming} onClick={handleBulkClaim}>
{t("queue.bulkClaim", {
count: selected.length,
defaultValue: "Claim {{count}}",
})}
</Button>
</RequirePermission>
)}
</Group> </Group>
</Group> </Group>
</Paper> </Paper>
)} )}
<AssignDialog
opened={assignTarget !== null}
onClose={() => setAssignTarget(null)}
kind="review"
applicationNumber={assignTarget?.applicationNumber}
loading={assigning}
onConfirm={handleAssign}
/>
</Container> </Container>
); );
} }

View File

@@ -39,6 +39,10 @@ import {
useLocalized, useLocalized,
useApproveDocumentsMutation, useApproveDocumentsMutation,
useAssignApplicationMutation, useAssignApplicationMutation,
useAssignReviewerMutation,
useAssignInspectorMutation,
useReportReviewMutation,
useReportInspectionMutation,
useCompleteReviewMutation, useCompleteReviewMutation,
useConfirmPaymentMutation, useConfirmPaymentMutation,
useScheduleIssuanceMutation, useScheduleIssuanceMutation,
@@ -215,6 +219,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();
@@ -534,6 +545,46 @@ export function LicenseReviewPage() {
// Claim is fired from the queue in practice; kept here for the case // Claim is fired from the queue in practice; kept here for the case
// where an officer opens an unclaimed application directly. // where an officer opens an unclaimed application directly.
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(
() => () =>

View File

@@ -867,6 +867,11 @@ export const am: Translations = {
submitted: "የቀረበበት", submitted: "የቀረበበት",
sla: "ዕድሜ / የጊዜ ገደብ", sla: "ዕድሜ / የጊዜ ገደብ",
claim: "ውሰድ", claim: "ውሰድ",
assign: "መድብ",
assigned: "ተመድቧል",
assignedBody: "ባለሙያው ተነግሮታል፤ ግምገማው ተጀምሯል።",
assignFailed: "መመደብ አልተቻለም",
assignError: "ማመልከቻውን መመደብ አልተቻለም።",
review: "ገምግም", review: "ገምግም",
claimed: "ተወስዷል", claimed: "ተወስዷል",
claimedBody: "ማመልከቻው አሁን ለእርስዎ ተመድቧል።", claimedBody: "ማመልከቻው አሁን ለእርስዎ ተመድቧል።",
@@ -966,6 +971,10 @@ export const am: Translations = {
actions: { actions: {
claim: "ውሰድ", claim: "ውሰድ",
assign: "መድብ", assign: "መድብ",
assignReviewer: "ግምገማ መድብ",
reportReview: "ለቡድን መሪ አሳውቅ",
assignInspector: "ምርመራ መድብ",
reportInspection: "የምርመራ ውጤት አሳውቅ",
escalate: "ወደ ላይ አሳድግ", escalate: "ወደ ላይ አሳድግ",
hold: "አግድ", hold: "አግድ",
resume: "ቀጥል", resume: "ቀጥል",

View File

@@ -875,6 +875,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.',
@@ -974,6 +979,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',
escalate: 'Escalate', escalate: 'Escalate',
hold: 'Put on hold', hold: 'Put on hold',
resume: 'Resume', resume: 'Resume',

View File

@@ -755,6 +755,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,
@@ -953,6 +1013,10 @@ export const {
useRevokeLicenseMutation, useRevokeLicenseMutation,
useReinstateLicenseMutation, useReinstateLicenseMutation,
useAssignApplicationMutation, useAssignApplicationMutation,
useAssignReviewerMutation,
useAssignInspectorMutation,
useReportReviewMutation,
useReportInspectionMutation,
useHoldApplicationMutation, useHoldApplicationMutation,
useResumeApplicationMutation, useResumeApplicationMutation,
useEscalateApplicationMutation, useEscalateApplicationMutation,

View File

@@ -65,7 +65,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',
APPROVED: 'Approved', APPROVED: 'Approved',
REJECTED: 'Rejected', REJECTED: 'Rejected',
ON_HOLD: 'On Hold', ON_HOLD: 'On Hold',
@@ -92,7 +94,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',
APPROVED: 'teal', APPROVED: 'teal',
REJECTED: 'red', REJECTED: 'red',
ON_HOLD: 'gray', ON_HOLD: 'gray',
@@ -125,7 +131,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,
APPROVED: 75, APPROVED: 75,
// Parked, so it keeps the progress of wherever it was held from. // Parked, so it keeps the progress of wherever it was held from.
ON_HOLD: 45, ON_HOLD: 45,

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"
| "APPROVED" | "APPROVED"
| "REJECTED" | "REJECTED"
| "ON_HOLD" | "ON_HOLD"