mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
1576 lines
54 KiB
TypeScript
1576 lines
54 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Container,
|
|
Grid,
|
|
Group,
|
|
Modal,
|
|
NumberInput,
|
|
Paper,
|
|
SegmentedControl,
|
|
Skeleton,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
Textarea,
|
|
ThemeIcon,
|
|
Timeline,
|
|
Title,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import {
|
|
IconAlertTriangle,
|
|
IconCheck,
|
|
IconLayoutSidebarRightCollapse,
|
|
IconLayoutSidebarRightExpand,
|
|
IconQuestionMark,
|
|
IconX,
|
|
} from "@tabler/icons-react";
|
|
import { notifications } from "@mantine/notifications";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
STATUS_COLORS,
|
|
STATUS_LABELS,
|
|
applicantOrCompanyName,
|
|
extractErrorMessage,
|
|
useLocalized,
|
|
useApproveDocumentsMutation,
|
|
useAssignApplicationMutation,
|
|
useClaimApplicationMutation,
|
|
useCompleteReviewMutation,
|
|
useConfirmPaymentMutation,
|
|
useScheduleIssuanceMutation,
|
|
useIssueCertificateMutation,
|
|
useScheduleExamMutation,
|
|
useRecordExamOutcomeMutation,
|
|
useEscalateApplicationMutation,
|
|
useFinalApproveMutation,
|
|
useGetApplicationForReviewQuery,
|
|
useGetAttachmentsQuery,
|
|
useGetDocumentReviewsQuery,
|
|
useGetInspectionsQuery,
|
|
useGetAssignableOfficersQuery,
|
|
useGetLicenseTypeRequirementsQuery,
|
|
useHoldApplicationMutation,
|
|
useRecordInspectionResultMutation,
|
|
useRejectApplicationMutation,
|
|
useRequestAdjustmentMutation,
|
|
useResumeApplicationMutation,
|
|
useScheduleInspectionMutation,
|
|
type RemarkTargetType,
|
|
type StaffEvidenceRequirement,
|
|
} from "@ema-platform/api";
|
|
import {
|
|
AdvancedTable,
|
|
AmharicDatePicker,
|
|
ErrorState,
|
|
ModalFooter,
|
|
PdfPreviewModal,
|
|
useServerTable,
|
|
} from "@ema-platform/ui";
|
|
import { useDateDisplayer } from "@ema-platform/shared";
|
|
import { usePermissions } from "@ema-platform/auth";
|
|
import { useAppSelector } from "../../../../store/hooks";
|
|
import { DecisionBar } from "../../components/DecisionBar";
|
|
import {
|
|
DecisionConfirmModal,
|
|
type DecisionSubmission,
|
|
} from "../../components/DecisionConfirmModal";
|
|
import { ActivityRail } from "../../components/ActivityRail";
|
|
import { DocumentsTab } from "../../components/DocumentsTab";
|
|
import { FormDetailsTab } from "../../components/FormDetailsTab";
|
|
import { ApplicantCard } from "../../components/ApplicantCard";
|
|
import { useGetLocationsQuery } from "../../../location/api/location-api";
|
|
import { ScheduleExamModal } from "../../components/ScheduleExamModal";
|
|
import { computeSla } from "../../sla";
|
|
import { reviewStaffColumns } from "./columns";
|
|
import {
|
|
evaluateEligibility,
|
|
presentationFor,
|
|
} from "../../config/license-types";
|
|
import {
|
|
resolveActions,
|
|
type ActionId,
|
|
type ResolvedAction,
|
|
} from "../../config/actions";
|
|
|
|
type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
|
|
|
|
/**
|
|
* Standard site-visit checklist (US-LOG-016 / US-VES-007). The inspection
|
|
* entity has carried a checklist column since day one; this is the first UI
|
|
* that fills it in.
|
|
*/
|
|
const INSPECTION_CHECKLIST_ITEMS = [
|
|
{
|
|
key: "office_premises",
|
|
labelKey: "review.checklist.officePremises",
|
|
fallback: "Office premises",
|
|
},
|
|
{
|
|
key: "storage_facilities",
|
|
labelKey: "review.checklist.storageFacilities",
|
|
fallback: "Warehouse / storage facilities",
|
|
},
|
|
{
|
|
key: "vehicles_equipment",
|
|
labelKey: "review.checklist.vehiclesEquipment",
|
|
fallback: "Vehicles / equipment",
|
|
},
|
|
{
|
|
key: "safety_compliance",
|
|
labelKey: "review.checklist.safetyCompliance",
|
|
fallback: "Safety & regulatory compliance",
|
|
},
|
|
] as const;
|
|
|
|
function buildChecklist(
|
|
outcomes: Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">,
|
|
) {
|
|
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
|
|
key: item.key,
|
|
// Persisted as-is (stable English), not the officer's display language —
|
|
// this is an audit record, not UI text.
|
|
label: item.fallback,
|
|
// Untouched rows default to PASS — the segmented control shows exactly
|
|
// that, so what the officer saw is what gets recorded.
|
|
outcome: outcomes[item.key] ?? "PASS",
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* The officer's review workspace.
|
|
*
|
|
* Three zones: a sticky left rail carrying the summary and eligibility, a
|
|
* centre column of tabs driven by the licence type's sections, and a
|
|
* collapsible activity trail on the right. Every decision goes through the
|
|
* Decision Bar pinned to the bottom, so an officer can act from any scroll
|
|
* position instead of scrolling back to a column of buttons.
|
|
*/
|
|
export function LicenseReviewPage() {
|
|
const navigate = useNavigate();
|
|
const { t, i18n } = useTranslation();
|
|
const showDate = useDateDisplayer();
|
|
const localized = useLocalized();
|
|
const { id = "" } = useParams();
|
|
const { can } = usePermissions();
|
|
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? "";
|
|
|
|
const { data, isLoading, isError, error, refetch } =
|
|
useGetApplicationForReviewQuery(id, {
|
|
skip: !id,
|
|
});
|
|
const { data: inspections = [], refetch: refetchInspections } =
|
|
useGetInspectionsQuery(id, {
|
|
skip: !id,
|
|
});
|
|
const { data: requirements } = useGetLicenseTypeRequirementsQuery(
|
|
{
|
|
idOrKey: data?.application.licenseTypeId ?? "",
|
|
kind: data?.application.kind ?? "NEW",
|
|
},
|
|
{ skip: !data?.application.licenseTypeId },
|
|
);
|
|
// Staff role names are already bilingual on the config — the wire data only
|
|
// carries the role key (e.g. 'CAPTAIN'), so this is what turns it back into
|
|
// the label an officer reads.
|
|
const roleNameByKey = useMemo(
|
|
() =>
|
|
new Map(
|
|
requirements?.staffRoleRequirements.map((r) => [r.roleKey, r.name]) ??
|
|
[],
|
|
),
|
|
[requirements],
|
|
);
|
|
// What each role is *required* to produce (CV, work agreement, ERB
|
|
// certificate). Without this the tab can only list what was uploaded, so a
|
|
// missing CV looks identical to a role that never needed one.
|
|
const evidenceByRole = useMemo(
|
|
() =>
|
|
new Map(
|
|
requirements?.staffRoleRequirements.map((r) => [
|
|
r.roleKey,
|
|
r.requiredEvidence ?? [],
|
|
]) ?? [],
|
|
),
|
|
[requirements],
|
|
);
|
|
|
|
const [claimApplication] = useClaimApplicationMutation();
|
|
const [completeReview] = useCompleteReviewMutation();
|
|
const [requestAdjustment] = useRequestAdjustmentMutation();
|
|
const [approveDocuments] = useApproveDocumentsMutation();
|
|
const [finalApprove] = useFinalApproveMutation();
|
|
const [rejectApplication] = useRejectApplicationMutation();
|
|
const [scheduleInspection] = useScheduleInspectionMutation();
|
|
const [recordResult] = useRecordInspectionResultMutation();
|
|
const [confirmPayment] = useConfirmPaymentMutation();
|
|
const [scheduleIssuance] = useScheduleIssuanceMutation();
|
|
const [issueCertificate] = useIssueCertificateMutation();
|
|
const [scheduleExam, { isLoading: schedulingExam }] =
|
|
useScheduleExamMutation();
|
|
const [recordExamOutcome] = useRecordExamOutcomeMutation();
|
|
const [holdApplication] = useHoldApplicationMutation();
|
|
const [resumeApplication] = useResumeApplicationMutation();
|
|
const [escalateApplication] = useEscalateApplicationMutation();
|
|
const [assignApplication] = useAssignApplicationMutation();
|
|
// Real officer list, so Assign and Escalate name a person instead of
|
|
// silently reassigning to whoever already held the application.
|
|
const { data: officers = [] } = useGetAssignableOfficersQuery();
|
|
// Same cached query the Documents tab reads, so the decision bar reacts the
|
|
// moment a verdict is saved.
|
|
const { data: documentReviews = [] } = useGetDocumentReviewsQuery(id, { skip: !id });
|
|
|
|
const staffTable = useServerTable();
|
|
const [flags, setFlags] = useState<FlagMap>({});
|
|
const [capital, setCapital] = useState<number | undefined>();
|
|
const [pendingAction, setPendingAction] = useState<ResolvedAction | null>(
|
|
null,
|
|
);
|
|
const [busyAction, setBusyAction] = useState<ActionId | null>(null);
|
|
const [railOpen, setRailOpen] = useState(true);
|
|
const [inspectionOpen, setInspectionOpen] = useState(false);
|
|
const [inspectionDate, setInspectionDate] = useState("");
|
|
const [issuanceOpen, setIssuanceOpen] = useState(false);
|
|
const [issuanceDate, setIssuanceDate] = useState("");
|
|
const [resultOpen, setResultOpen] = useState(false);
|
|
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
|
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
|
const [examScore, setExamScore] = useState<number | undefined>();
|
|
const [findings, setFindings] = useState("");
|
|
const [checklist, setChecklist] = useState<
|
|
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
|
>({});
|
|
|
|
// Prefill from whatever is recorded, else the declared figure, so the officer
|
|
// confirms a number rather than retyping it. Runs before the early return
|
|
// below because hooks must be called unconditionally.
|
|
const seeded = useRef(false);
|
|
const loadedApp = data?.application;
|
|
useEffect(() => {
|
|
if (seeded.current || !loadedApp) return;
|
|
const existing =
|
|
loadedApp.capitalAmountVerified ?? loadedApp.capitalAmountDeclared;
|
|
if (existing != null) setCapital(Number(existing));
|
|
seeded.current = true;
|
|
}, [loadedApp]);
|
|
|
|
const toggleFlag = useCallback(
|
|
(targetType: RemarkTargetType, key: string) => {
|
|
setFlags((prev) => {
|
|
const next = { ...prev };
|
|
if (next[key]) delete next[key];
|
|
else next[key] = { targetType, remark: "" };
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const documentFlags = useMemo(() => {
|
|
const map: Record<string, string> = {};
|
|
for (const [key, flag] of Object.entries(flags)) {
|
|
if (flag.targetType === "DOCUMENT") map[key] = flag.remark;
|
|
}
|
|
return map;
|
|
}, [flags]);
|
|
|
|
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
|
|
|
|
// Approving means every uploaded document was accepted — one unjudged or
|
|
// rejected file is enough to keep the decision buttons dead. The counts feed
|
|
// the hover explanation, so the officer sees how much is left rather than
|
|
// just that the button is dead.
|
|
const documentProgress = useMemo(() => {
|
|
const attachments = data?.attachments ?? [];
|
|
const accepted = new Set(
|
|
documentReviews
|
|
.filter((review) => review.decision === 'ACCEPTED')
|
|
.map((review) => review.documentKey),
|
|
);
|
|
const acceptedCount = attachments.filter((a) => accepted.has(a.documentKey)).length;
|
|
return { acceptedCount, total: attachments.length };
|
|
}, [data?.attachments, documentReviews]);
|
|
const allDocumentsAccepted =
|
|
documentProgress.total > 0 && documentProgress.acceptedCount === documentProgress.total;
|
|
const flagged = Object.entries(flags);
|
|
|
|
/**
|
|
* The flagged items as the officer should see them in the confirmation.
|
|
*
|
|
* Only a document key reads acceptably on its own. A form section is
|
|
* camelCase, and a staff flag is keyed by the ApplicationStaff id — showing
|
|
* a uuid in the checklist would make the list impossible to tick with
|
|
* confidence.
|
|
*/
|
|
const flaggedItems = useMemo(
|
|
() =>
|
|
flagged.map(([key, flag]) => {
|
|
if (flag.targetType === "STAFF") {
|
|
const member = data?.staff.find((s) => s.id === key);
|
|
return {
|
|
key,
|
|
label: member
|
|
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey} — ${member.fullName}`
|
|
: t("review.staffMember", "Staff member"),
|
|
};
|
|
}
|
|
if (flag.targetType === "FORM_SECTION") {
|
|
return { key, label: key.replace(/([A-Z])/g, " $1").trim() };
|
|
}
|
|
return { key, label: key };
|
|
}),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[flags, data?.staff, t, localized, roleNameByKey],
|
|
);
|
|
|
|
const actions = useMemo(() => {
|
|
if (!data) return [];
|
|
return resolveActions({
|
|
detail: data,
|
|
currentUserId,
|
|
can,
|
|
flaggedCount: flagged.length,
|
|
hasPendingInspection: Boolean(pendingInspection),
|
|
allDocumentsAccepted,
|
|
reasons: {
|
|
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
|
|
notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'),
|
|
noPermission: t('review.disabled.noPermission', 'You do not have permission'),
|
|
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
|
|
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
|
|
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
|
|
needsDocumentReviews:
|
|
documentProgress.total === 0
|
|
? t(
|
|
'review.disabled.needsDocumentsUploaded',
|
|
'No documents uploaded to review yet',
|
|
)
|
|
: t('review.disabled.needsDocumentReviews', {
|
|
accepted: documentProgress.acceptedCount,
|
|
total: documentProgress.total,
|
|
defaultValue:
|
|
'Accept all documents first — {{accepted}} of {{total}} accepted. Open the Documents tab and accept the rest.',
|
|
}),
|
|
},
|
|
});
|
|
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
|
|
|
|
// Location answers are tree ids. The picker the applicant used resolves them
|
|
// client-side from the same list, so the reviewer reads the place rather than
|
|
// the uuid. Fetched only when the form actually has a location field.
|
|
//
|
|
// Above the early returns because it is a hook: React requires the same hook
|
|
// order on every render, and the loading and error branches return before the
|
|
// application is known.
|
|
const needsLocations = (
|
|
requirements?.licenseType.formSchema.sections ?? []
|
|
).some((section) =>
|
|
(section.fields ?? []).some(
|
|
(f) =>
|
|
f.key === "locationId" ||
|
|
(f.label?.en ?? "").trim().toLowerCase() === "location",
|
|
),
|
|
);
|
|
const { data: locationsRes } = useGetLocationsQuery(
|
|
{ take: 10000 },
|
|
{ skip: !needsLocations },
|
|
);
|
|
const resolveLocation = useMemo(() => {
|
|
const all = locationsRes?.items ?? [];
|
|
if (all.length === 0) return undefined;
|
|
const byId = new Map(all.map((loc) => [loc.id, loc]));
|
|
return (locationId: string) => {
|
|
// Walks to the root so the answer reads as a place, not a leaf name:
|
|
// "Woreda 03" alone does not say which sub-city it belongs to. Bounded by
|
|
// the map size so a cyclic tree cannot spin the render.
|
|
const path: string[] = [];
|
|
let current = byId.get(locationId);
|
|
let hops = 0;
|
|
while (current && hops++ <= byId.size) {
|
|
path.unshift(localized(current.names) || current.code);
|
|
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
}
|
|
return path.length ? path.join(" → ") : undefined;
|
|
};
|
|
}, [locationsRes, localized]);
|
|
|
|
if (isLoading) {
|
|
// Skeleton mirrors the real three-zone layout so nothing jumps on load.
|
|
return (
|
|
<Container size="xl" py="md">
|
|
<Skeleton height={36} width={320} mb="lg" />
|
|
<Grid>
|
|
<Grid.Col span={{ base: 12, md: 3 }}>
|
|
<Skeleton height={280} radius="md" />
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
|
<Skeleton height={420} radius="md" />
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 12, md: 3 }}>
|
|
<Skeleton height={280} radius="md" />
|
|
</Grid.Col>
|
|
</Grid>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
if (isError || !data) {
|
|
return (
|
|
<Container size="xl" py="md">
|
|
<ErrorState
|
|
title={t("review.errorTitle", "Could not load this application")}
|
|
description={extractErrorMessage(error)}
|
|
onRetry={() => refetch()}
|
|
/>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
const app = data.application;
|
|
const status = app.status;
|
|
const staffPaged = staffTable.paginate(data.staff);
|
|
const presentation = presentationFor(app.licenseType?.key);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
|
|
const sla = computeSla(
|
|
app,
|
|
undefined,
|
|
i18n.language,
|
|
(key, options) => t(key, options as any) as string,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
|
|
const eligibility = evaluateEligibility(
|
|
app,
|
|
app.licenseType,
|
|
i18n.language,
|
|
(key, options) => t(key, options as any) as string,
|
|
);
|
|
|
|
const rawThreshold = app.licenseType?.capitalThreshold;
|
|
const threshold =
|
|
rawThreshold === null || rawThreshold === undefined
|
|
? undefined
|
|
: Number(rawThreshold);
|
|
/** Stages where the officer can still record the verified capital. */
|
|
const needsCapital =
|
|
Boolean(threshold) &&
|
|
[
|
|
"UNDER_REVIEW",
|
|
"UNDER_EVALUATION",
|
|
"INSPECTION_PENDING",
|
|
"INSPECTION_COMPLETED",
|
|
].includes(status);
|
|
|
|
async function run(action: () => Promise<unknown>, success: string) {
|
|
try {
|
|
await action();
|
|
notifications.show({ color: "teal", title: success, message: "" });
|
|
refetch();
|
|
refetchInspections();
|
|
} catch (err) {
|
|
notifications.show({
|
|
color: "red",
|
|
title: t("review.actionFailed", "Action failed"),
|
|
message: extractErrorMessage(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Actions with their own dedicated form open that; the rest confirm. */
|
|
function handleAction(action: ResolvedAction) {
|
|
switch (action.id) {
|
|
case "schedule-inspection":
|
|
setInspectionOpen(true);
|
|
return;
|
|
case "schedule-issuance":
|
|
setIssuanceOpen(true);
|
|
return;
|
|
case "record-inspection":
|
|
setResultOpen(true);
|
|
return;
|
|
// Needs a session picked before anything is sent, so it opens its own
|
|
// modal rather than going through the generic confirm step.
|
|
case "schedule-exam":
|
|
setScheduleExamOpen(true);
|
|
return;
|
|
// Pass/fail plus an optional score, same reasoning as schedule-exam:
|
|
// needs its own inputs before anything is sent.
|
|
case "record-exam-outcome":
|
|
setExamOutcomeOpen(true);
|
|
return;
|
|
case "copy-link":
|
|
navigator.clipboard.writeText(window.location.href);
|
|
notifications.show({
|
|
color: "teal",
|
|
title: t("review.linkCopied", "Link copied"),
|
|
message: "",
|
|
});
|
|
return;
|
|
case "print":
|
|
window.print();
|
|
return;
|
|
case "audit-trail":
|
|
setRailOpen(true);
|
|
return;
|
|
case "download-documents":
|
|
for (const attachment of data?.attachments ?? []) {
|
|
const url = attachment.files?.[0]?.url;
|
|
if (url) window.open(url, "_blank", "noopener");
|
|
}
|
|
return;
|
|
default:
|
|
setPendingAction(action);
|
|
}
|
|
}
|
|
|
|
async function submitDecision(submission: DecisionSubmission) {
|
|
const action = pendingAction;
|
|
if (!action) return;
|
|
setBusyAction(action.id);
|
|
try {
|
|
switch (action.id) {
|
|
case "claim":
|
|
// Usually fired from the queue, but an officer who opened an
|
|
// unclaimed application directly claims it from here.
|
|
await run(
|
|
() => claimApplication(id).unwrap(),
|
|
t("review.done.claim", "Claimed — the application is now yours"),
|
|
);
|
|
break;
|
|
case "complete-review":
|
|
await run(
|
|
() =>
|
|
completeReview({ id, capitalAmountVerified: capital }).unwrap(),
|
|
t("review.done.completeReview", "Review completed"),
|
|
);
|
|
break;
|
|
case "approve-documents":
|
|
await run(
|
|
() => approveDocuments({ id }).unwrap(),
|
|
t("review.done.approveDocuments", "Documents approved"),
|
|
);
|
|
break;
|
|
case "final-approve":
|
|
await run(
|
|
() => finalApprove({ id, capitalAmountVerified: capital }).unwrap(),
|
|
t("review.done.finalApprove", "Approved"),
|
|
);
|
|
break;
|
|
case "request-adjustment": {
|
|
const items = flagged
|
|
// Only the ticked deficiencies are sent, so the applicant can
|
|
// edit exactly the list they were shown.
|
|
.filter(([key]) =>
|
|
submission.deficiencies.length
|
|
? submission.deficiencies.includes(key)
|
|
: true,
|
|
)
|
|
.map(([key, flag]) => ({
|
|
targetType: flag.targetType,
|
|
targetKey: key,
|
|
remark: flag.remark.trim(),
|
|
}));
|
|
|
|
// Each item is an instruction the applicant has to act on, so the
|
|
// API requires wording for every one. Catching it here names the
|
|
// items that need it; letting it through produced a bare 400 saying
|
|
// "items.0.remark should not be empty", which tells an officer
|
|
// nothing about which box to go and fill in.
|
|
const unexplained = items.filter((item) => !item.remark);
|
|
if (items.length === 0 || unexplained.length > 0) {
|
|
notifications.show({
|
|
color: "orange",
|
|
title:
|
|
items.length === 0
|
|
? t(
|
|
"review.adjustment.nothingFlagged",
|
|
"Nothing has been flagged",
|
|
)
|
|
: t(
|
|
"review.adjustment.reasonsMissing",
|
|
"Every flagged item needs a reason",
|
|
),
|
|
message:
|
|
items.length === 0
|
|
? t(
|
|
"review.adjustment.nothingFlaggedHint",
|
|
"Tick what the applicant must correct before requesting an adjustment.",
|
|
)
|
|
: `${t(
|
|
"review.adjustment.reasonsMissingHint",
|
|
"Say what must be corrected for:",
|
|
)} ${unexplained.map((item) => item.targetKey).join(", ")}`,
|
|
});
|
|
// `finally` closes the modal and clears the busy flag.
|
|
return;
|
|
}
|
|
|
|
await run(
|
|
async () => {
|
|
await requestAdjustment({
|
|
id,
|
|
generalRemark: submission.reason,
|
|
notificationBody: submission.notificationBody,
|
|
items,
|
|
}).unwrap();
|
|
setFlags({});
|
|
},
|
|
t("review.done.requestAdjustment", "Adjustment requested"),
|
|
);
|
|
break;
|
|
}
|
|
case "reject":
|
|
await run(
|
|
() =>
|
|
rejectApplication({
|
|
id,
|
|
reason: submission.reason,
|
|
// The preview the officer edited is what actually gets sent.
|
|
notificationBody: submission.notificationBody,
|
|
}).unwrap(),
|
|
t("review.done.reject", "Application rejected"),
|
|
);
|
|
break;
|
|
case "confirm-payment":
|
|
await run(
|
|
() => confirmPayment(id).unwrap(),
|
|
t("review.done.confirmPayment", "Payment confirmed"),
|
|
);
|
|
break;
|
|
case "issue-certificate":
|
|
await run(
|
|
() => issueCertificate(id).unwrap(),
|
|
t("review.done.issueCertificate", "Certificate issued"),
|
|
);
|
|
break;
|
|
case "hold":
|
|
await run(
|
|
() => holdApplication({ id, reason: submission.reason }).unwrap(),
|
|
t("review.done.hold", "Application placed on hold"),
|
|
);
|
|
break;
|
|
case "resume":
|
|
await run(
|
|
() => resumeApplication({ id, remark: submission.reason }).unwrap(),
|
|
t("review.done.resume", "Application resumed"),
|
|
);
|
|
break;
|
|
case "escalate":
|
|
if (!submission.officerId) return;
|
|
await run(
|
|
() =>
|
|
escalateApplication({
|
|
id,
|
|
supervisorId: submission.officerId as string,
|
|
reason: submission.reason,
|
|
}).unwrap(),
|
|
t("review.done.escalate", "Escalated"),
|
|
);
|
|
break;
|
|
case "assign":
|
|
if (!submission.officerId) return;
|
|
await run(
|
|
() =>
|
|
assignApplication({
|
|
id,
|
|
officerId: submission.officerId as string,
|
|
remark: submission.reason,
|
|
}).unwrap(),
|
|
t("review.done.assign", "Reassigned"),
|
|
);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
} finally {
|
|
setBusyAction(null);
|
|
setPendingAction(null);
|
|
}
|
|
}
|
|
|
|
const sections = presentation.detailSections;
|
|
const hasFormAnswers = Object.keys(app.formData ?? {}).length > 0;
|
|
// The bilingual section/field labels the applicant's wizard renders — this
|
|
// page already fetches them (`requirements` above) but used to fall back to
|
|
// the raw formData keys, so an officer saw `vesselId` instead of a label in
|
|
// either language.
|
|
const configSections = requirements?.licenseType.formSchema.sections ?? [];
|
|
|
|
// A person-centric service has no company, so the company-shaped facts are
|
|
// not merely empty — they are the wrong question. TIN is hidden rather than
|
|
// shown blank, and the applicant's own identity card takes its place.
|
|
const isPersonal = !app.companyName;
|
|
const applicant = data.applicant;
|
|
const applicantFullName = [
|
|
applicant?.firstName,
|
|
applicant?.middleName,
|
|
applicant?.lastName,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
// The profile is the reliable name for a personal service — `formData.account`
|
|
// holds one only for applications filed after that field was added, and the
|
|
// application number identifies the paperwork rather than the person.
|
|
const headerName =
|
|
app.companyName ||
|
|
applicantFullName ||
|
|
applicantOrCompanyName(app) ||
|
|
app.applicationNumber;
|
|
const linkedBookServices = (data.relatedApplications ?? []).filter(
|
|
(related) =>
|
|
["SEAMAN_BOOK", "BTC_BASIC_TRAINING"].includes(
|
|
related.licenseType?.key ?? "",
|
|
),
|
|
);
|
|
|
|
return (
|
|
<Container size="xl" py="md">
|
|
<Group justify="space-between" mb="md">
|
|
<div>
|
|
<Title order={2}>{headerName}</Title>
|
|
<Group gap="xs">
|
|
<Text size="sm" c="dimmed">
|
|
{app.applicationNumber}
|
|
</Text>
|
|
<Badge color={STATUS_COLORS[status]} variant="light">
|
|
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
|
</Badge>
|
|
{app.adjustmentRound > 0 && (
|
|
<Badge color="orange" variant="light" size="sm">
|
|
{t("review.round", {
|
|
count: app.adjustmentRound,
|
|
defaultValue: "round {{count}}",
|
|
})}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
{linkedBookServices.length > 1 && (
|
|
<Group gap="xs" mt="xs">
|
|
{linkedBookServices.map((related) => (
|
|
<Badge
|
|
key={related.id}
|
|
variant={related.id === app.id ? "filled" : "outline"}
|
|
color={STATUS_COLORS[related.status]}
|
|
style={{ cursor: "pointer" }}
|
|
onClick={() => navigate(`/licence-review/${related.id}`)}
|
|
>
|
|
{related.licenseType?.key === "SEAMAN_BOOK"
|
|
? "Seaman Book"
|
|
: "BTC"}{" "}
|
|
· {related.applicationNumber} ·{" "}
|
|
{STATUS_LABELS[related.status]}
|
|
</Badge>
|
|
))}
|
|
</Group>
|
|
)}
|
|
</div>
|
|
<Group gap="xs">
|
|
<Tooltip
|
|
label={
|
|
railOpen
|
|
? t("review.hideActivity", "Hide activity")
|
|
: t("review.showActivity", "Show activity")
|
|
}
|
|
>
|
|
<ActionIcon
|
|
variant="default"
|
|
size="lg"
|
|
onClick={() => setRailOpen((o) => !o)}
|
|
>
|
|
{railOpen ? (
|
|
<IconLayoutSidebarRightCollapse size={18} />
|
|
) : (
|
|
<IconLayoutSidebarRightExpand size={18} />
|
|
)}
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
</Group>
|
|
|
|
<Grid>
|
|
{/* Zone 1 — sticky summary rail. */}
|
|
<Grid.Col span={{ base: 12, md: 3 }}>
|
|
<Stack style={{ position: "sticky", top: 16 }}>
|
|
{/* Who, before what: a person-centric review is about the applicant,
|
|
and the licence facts below are the context. */}
|
|
{isPersonal && applicant && <ApplicantCard applicant={applicant} />}
|
|
|
|
<Paper withBorder p="md">
|
|
<Text fw={600} size="sm" mb="sm">
|
|
{t("review.summary", "Summary")}
|
|
</Text>
|
|
<Stack gap={6}>
|
|
<SummaryRow
|
|
label={t("review.type", "Type")}
|
|
value={localized(app.licenseType?.name)}
|
|
/>
|
|
{/* A person has no TIN; showing the row blank invited the
|
|
reviewer to wonder what was missing. */}
|
|
{!isPersonal && (
|
|
<SummaryRow
|
|
label={t("review.tin", "TIN")}
|
|
value={app.tinNumber}
|
|
/>
|
|
)}
|
|
<SummaryRow
|
|
label={t("review.kind", "Kind")}
|
|
value={t(`review.kindValues.${app.kind}`, app.kind)}
|
|
/>
|
|
<SummaryRow
|
|
label={t("review.submitted", "Submitted")}
|
|
value={showDate(app.submittedAt)}
|
|
/>
|
|
<SummaryRow
|
|
label={t("review.slaLabel", "SLA")}
|
|
value={sla.label}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{/* Eligibility, checked and shown — not applied invisibly. */}
|
|
{eligibility.length > 0 && (
|
|
<Paper withBorder p="md">
|
|
<Text fw={600} size="sm" mb="sm">
|
|
{t("review.eligibility", "Eligibility")}
|
|
</Text>
|
|
<Stack gap="xs">
|
|
{eligibility.map((rule) => (
|
|
<Group
|
|
key={rule.id}
|
|
gap="xs"
|
|
wrap="nowrap"
|
|
align="flex-start"
|
|
>
|
|
<ThemeIcon
|
|
size={18}
|
|
radius="xl"
|
|
variant="light"
|
|
color={
|
|
rule.status === "pass"
|
|
? "teal"
|
|
: rule.status === "fail"
|
|
? "red"
|
|
: "gray"
|
|
}
|
|
>
|
|
{rule.status === "pass" ? (
|
|
<IconCheck size={11} />
|
|
) : rule.status === "fail" ? (
|
|
<IconX size={11} />
|
|
) : (
|
|
<IconQuestionMark size={11} />
|
|
)}
|
|
</ThemeIcon>
|
|
<div style={{ minWidth: 0 }}>
|
|
<Text size="xs" fw={500}>
|
|
{rule.label}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{rule.actual}
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
</Paper>
|
|
)}
|
|
|
|
<Paper withBorder p="md">
|
|
<Text fw={600} size="sm" mb="sm">
|
|
{t("review.statusTimeline", "Progress")}
|
|
</Text>
|
|
<Timeline
|
|
bulletSize={12}
|
|
lineWidth={2}
|
|
active={data.history.length}
|
|
>
|
|
{data.history.slice(-5).map((entry) => (
|
|
<Timeline.Item
|
|
key={entry.id}
|
|
title={
|
|
<Text size="xs" fw={600}>
|
|
{t(
|
|
`queue.statusValues.${entry.toStatus}`,
|
|
STATUS_LABELS[entry.toStatus] ?? entry.toStatus,
|
|
)}
|
|
</Text>
|
|
}
|
|
>
|
|
<Text size="xs" c="dimmed">
|
|
{showDate(entry.createdAt)}
|
|
</Text>
|
|
</Timeline.Item>
|
|
))}
|
|
</Timeline>
|
|
</Paper>
|
|
</Stack>
|
|
</Grid.Col>
|
|
|
|
{/* Zone 2 — the application itself. */}
|
|
<Grid.Col span={{ base: 12, md: railOpen ? 6 : 9 }}>
|
|
<Tabs defaultValue={sections[0]}>
|
|
<Tabs.List mb="md">
|
|
{/* Tabs with nothing behind them are not rendered at all. */}
|
|
{sections.includes("overview") && hasFormAnswers && (
|
|
<Tabs.Tab value="overview">
|
|
{t("review.tabs.overview", "Overview")}
|
|
</Tabs.Tab>
|
|
)}
|
|
{sections.includes("financials") && (
|
|
<Tabs.Tab value="financials">
|
|
{t("review.tabs.financials", "Financials")}
|
|
</Tabs.Tab>
|
|
)}
|
|
{sections.includes("documents") && (
|
|
<Tabs.Tab value="documents">
|
|
{t("review.tabs.documents", "Documents")} (
|
|
{data.attachments.length})
|
|
</Tabs.Tab>
|
|
)}
|
|
{sections.includes("staff") && data.staff.length > 0 && (
|
|
<Tabs.Tab value="staff">
|
|
{t("review.tabs.staff", "Staff")} ({data.staff.length})
|
|
</Tabs.Tab>
|
|
)}
|
|
{sections.includes("inspection") &&
|
|
app.licenseType?.inspectionRequired && (
|
|
<Tabs.Tab value="inspection">
|
|
{t("review.tabs.inspection", "Inspection")}
|
|
</Tabs.Tab>
|
|
)}
|
|
</Tabs.List>
|
|
|
|
<Tabs.Panel value="overview">
|
|
<FormDetailsTab
|
|
formData={app.formData ?? {}}
|
|
configSections={configSections}
|
|
currency={app.feeCurrency ?? undefined}
|
|
flags={flags}
|
|
onToggleFlag={(sectionKey) =>
|
|
toggleFlag("FORM_SECTION", sectionKey)
|
|
}
|
|
onFlagRemark={(sectionKey, remark) =>
|
|
setFlags((p) => ({
|
|
...p,
|
|
[sectionKey]: { ...p[sectionKey], remark },
|
|
}))
|
|
}
|
|
resolveLocation={resolveLocation}
|
|
/>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="financials">
|
|
<Paper withBorder p="md">
|
|
{needsCapital ? (
|
|
<>
|
|
<NumberInput
|
|
label={t(
|
|
"review.verifiedCapital",
|
|
"Verified capital (ETB)",
|
|
)}
|
|
description={
|
|
threshold
|
|
? t("review.capitalHint", {
|
|
min: threshold.toLocaleString(i18n.language),
|
|
defaultValue:
|
|
"Minimum {{min}} — check against the bank letter",
|
|
})
|
|
: t(
|
|
"review.capitalHintNoMin",
|
|
"Checked against the bank letter",
|
|
)
|
|
}
|
|
value={capital ?? ""}
|
|
onChange={(v) => setCapital(Number(v) || undefined)}
|
|
thousandSeparator=","
|
|
error={
|
|
capital !== undefined &&
|
|
threshold &&
|
|
capital < threshold
|
|
? t("review.belowMinimum", {
|
|
min: threshold.toLocaleString(i18n.language),
|
|
defaultValue: "Below the {{min}} minimum",
|
|
})
|
|
: undefined
|
|
}
|
|
/>
|
|
<Text size="xs" c="dimmed" mt="xs">
|
|
{t("review.declared", "Applicant declared")}{" "}
|
|
{app.capitalAmountDeclared
|
|
? Number(app.capitalAmountDeclared).toLocaleString(
|
|
i18n.language,
|
|
)
|
|
: "—"}
|
|
</Text>
|
|
</>
|
|
) : (
|
|
<Text size="sm" c="dimmed">
|
|
{t(
|
|
"review.capitalLocked",
|
|
"Capital can no longer be edited at this stage.",
|
|
)}
|
|
</Text>
|
|
)}
|
|
</Paper>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="documents">
|
|
<DocumentsTab
|
|
applicationId={id}
|
|
attachments={data.attachments}
|
|
requirements={requirements?.documentRequirements ?? []}
|
|
formData={app.formData ?? {}}
|
|
flags={documentFlags}
|
|
onToggleFlag={(key) => toggleFlag("DOCUMENT", key)}
|
|
onFlagRemark={(key, remark) =>
|
|
setFlags((p) => ({ ...p, [key]: { ...p[key], remark } }))
|
|
}
|
|
/>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="staff">
|
|
<AdvancedTable
|
|
tableName={t("review.tabs.staff", "Staff")}
|
|
columns={reviewStaffColumns(t, {
|
|
flags,
|
|
roleName: (member) =>
|
|
localized(roleNameByKey.get(member.roleKey)) ||
|
|
member.roleKey,
|
|
renderEvidence: (member) => (
|
|
<StaffEvidenceCell
|
|
staffId={member.id}
|
|
required={evidenceByRole.get(member.roleKey) ?? []}
|
|
fallback={member.documents}
|
|
/>
|
|
),
|
|
onToggleFlag: (member) => toggleFlag("STAFF", member.id),
|
|
onRemarkChange: (member, remark) =>
|
|
setFlags((p) => ({
|
|
...p,
|
|
[member.id]: { ...p[member.id], remark },
|
|
})),
|
|
})}
|
|
data={staffPaged.rows}
|
|
itemCount={staffPaged.itemCount}
|
|
pageIndex={staffPaged.pageIndex}
|
|
onPageChange={staffTable.setPageIndex}
|
|
pageSize={staffTable.pageSize}
|
|
refresh={refetch}
|
|
/>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="inspection">
|
|
<Paper withBorder p="md">
|
|
{inspections.length === 0 ? (
|
|
<Text size="sm" c="dimmed">
|
|
{t(
|
|
"review.noInspections",
|
|
"No inspection has been scheduled yet.",
|
|
)}
|
|
</Text>
|
|
) : (
|
|
<Stack gap="xs">
|
|
{inspections.map((inspection) => (
|
|
<Group key={inspection.id} justify="space-between">
|
|
<div>
|
|
<Text size="sm">
|
|
{inspection.scheduledDate
|
|
? showDate(inspection.scheduledDate)
|
|
: t("review.unscheduled", "Not scheduled")}
|
|
</Text>
|
|
{inspection.findings && (
|
|
<Text size="xs" c="dimmed">
|
|
{inspection.findings}
|
|
</Text>
|
|
)}
|
|
</div>
|
|
<Badge
|
|
variant="light"
|
|
color={
|
|
inspection.result === "FAILED" ? "red" : "teal"
|
|
}
|
|
>
|
|
{inspection.result === "PASSED"
|
|
? t("review.passed", "Passed")
|
|
: inspection.result === "FAILED"
|
|
? t("review.failed", "Failed")
|
|
: t(
|
|
`review.inspectionStatus.${inspection.status}`,
|
|
inspection.status,
|
|
)}
|
|
</Badge>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Paper>
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
|
|
{status === "PAYMENT_PENDING" && (
|
|
<Alert
|
|
mt="md"
|
|
color="yellow"
|
|
icon={<IconAlertTriangle size={16} />}
|
|
>
|
|
{t("review.awaitingPayment", {
|
|
amount: app.feeAmount,
|
|
currency: app.feeCurrency,
|
|
defaultValue:
|
|
"Waiting for the applicant to pay {{amount}} {{currency}}.",
|
|
})}
|
|
</Alert>
|
|
)}
|
|
</Grid.Col>
|
|
|
|
{/* Zone 3 — activity and audit trail. */}
|
|
{railOpen && (
|
|
<Grid.Col span={{ base: 12, md: 3 }}>
|
|
<ActivityRail detail={data} />
|
|
</Grid.Col>
|
|
)}
|
|
</Grid>
|
|
|
|
<DecisionBar
|
|
status={status}
|
|
assigneeName={
|
|
app.assignedOfficerId ? t("review.assigned", "Assigned") : null
|
|
}
|
|
sla={sla}
|
|
actions={actions}
|
|
busyAction={busyAction}
|
|
onAction={handleAction}
|
|
/>
|
|
|
|
<DecisionConfirmModal
|
|
action={pendingAction}
|
|
applicantName={
|
|
app.companyName ||
|
|
applicantFullName ||
|
|
t("review.theApplicant", "the applicant")
|
|
}
|
|
applicationNumber={app.applicationNumber}
|
|
flaggedItems={flaggedItems}
|
|
officers={officers}
|
|
submitting={Boolean(busyAction)}
|
|
onClose={() => setPendingAction(null)}
|
|
onConfirm={submitDecision}
|
|
/>
|
|
|
|
<ScheduleExamModal
|
|
opened={scheduleExamOpen}
|
|
applicationId={id}
|
|
applicantName={
|
|
app.companyName ||
|
|
applicantFullName ||
|
|
t("review.theApplicant", "the applicant")
|
|
}
|
|
loading={schedulingExam}
|
|
onClose={() => setScheduleExamOpen(false)}
|
|
onConfirm={async (payload) => {
|
|
try {
|
|
await scheduleExam({ id, ...payload }).unwrap();
|
|
notifications.show({
|
|
color: "teal",
|
|
title: t("review.done.scheduleExam", "Exam scheduled"),
|
|
message: "",
|
|
});
|
|
setScheduleExamOpen(false);
|
|
} catch (err) {
|
|
notifications.show({
|
|
color: "red",
|
|
title: t("review.actionFailed", "Action failed"),
|
|
message: extractErrorMessage(err),
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<Modal
|
|
opened={examOutcomeOpen}
|
|
onClose={() => setExamOutcomeOpen(false)}
|
|
title={t("review.actions.recordExamOutcome", "Record exam outcome")}
|
|
>
|
|
<Stack>
|
|
<Text size="sm" c="dimmed">
|
|
{t(
|
|
"review.examOutcome.intro",
|
|
"Record the published result. A pass makes the certificate fee due; a fail leaves the application open for a retake.",
|
|
)}
|
|
</Text>
|
|
<NumberInput
|
|
label={t("review.examOutcome.score", "Score (optional)")}
|
|
value={examScore}
|
|
onChange={(v) => setExamScore(typeof v === "number" ? v : undefined)}
|
|
min={0}
|
|
/>
|
|
<ModalFooter grow>
|
|
<ActionIcon
|
|
variant="light"
|
|
color="teal"
|
|
size="lg"
|
|
aria-label={t("review.passed", "Passed")}
|
|
onClick={() =>
|
|
run(
|
|
async () => {
|
|
await recordExamOutcome({
|
|
id,
|
|
passed: true,
|
|
score: examScore,
|
|
}).unwrap();
|
|
setExamOutcomeOpen(false);
|
|
setExamScore(undefined);
|
|
},
|
|
t("review.done.examPassed", "Exam result recorded — passed"),
|
|
)
|
|
}
|
|
>
|
|
<IconCheck size={18} />
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
variant="light"
|
|
color="red"
|
|
size="lg"
|
|
aria-label={t("review.failed", "Failed")}
|
|
onClick={() =>
|
|
run(
|
|
async () => {
|
|
await recordExamOutcome({
|
|
id,
|
|
passed: false,
|
|
score: examScore,
|
|
}).unwrap();
|
|
setExamOutcomeOpen(false);
|
|
setExamScore(undefined);
|
|
},
|
|
t("review.done.examFailed", "Exam result recorded — not passed"),
|
|
)
|
|
}
|
|
>
|
|
<IconX size={18} />
|
|
</ActionIcon>
|
|
</ModalFooter>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={inspectionOpen}
|
|
onClose={() => setInspectionOpen(false)}
|
|
title={t("review.actions.scheduleInspection", "Schedule inspection")}
|
|
>
|
|
<Stack>
|
|
<AmharicDatePicker
|
|
label={t("review.dateTime", "Date and time")}
|
|
value={inspectionDate}
|
|
onChange={setInspectionDate}
|
|
withTime
|
|
/>
|
|
<ModalFooter>
|
|
<Tooltip
|
|
label={t("review.pickDate", "Pick a date and time first")}
|
|
disabled={Boolean(inspectionDate)}
|
|
>
|
|
<span>
|
|
<button type="button" hidden aria-hidden />
|
|
</span>
|
|
</Tooltip>
|
|
<ActionIcon
|
|
variant="filled"
|
|
size="lg"
|
|
disabled={!inspectionDate}
|
|
aria-label={t("review.schedule", "Schedule")}
|
|
onClick={() =>
|
|
run(
|
|
async () => {
|
|
await scheduleInspection({
|
|
applicationId: id,
|
|
scheduledDate: inspectionDate,
|
|
}).unwrap();
|
|
setInspectionOpen(false);
|
|
},
|
|
t("review.done.scheduled", "Inspection scheduled"),
|
|
)
|
|
}
|
|
>
|
|
<IconCheck size={18} />
|
|
</ActionIcon>
|
|
</ModalFooter>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={issuanceOpen}
|
|
onClose={() => setIssuanceOpen(false)}
|
|
title={t("review.actions.scheduleIssuance", "Schedule pickup")}
|
|
>
|
|
<Stack>
|
|
<AmharicDatePicker
|
|
label={t("review.pickupDate", "Pickup date")}
|
|
value={issuanceDate}
|
|
onChange={setIssuanceDate}
|
|
/>
|
|
<ModalFooter>
|
|
<Tooltip
|
|
label={t("review.pickDate", "Pick a date and time first")}
|
|
disabled={Boolean(issuanceDate)}
|
|
>
|
|
<span>
|
|
<button type="button" hidden aria-hidden />
|
|
</span>
|
|
</Tooltip>
|
|
<ActionIcon
|
|
variant="filled"
|
|
size="lg"
|
|
disabled={!issuanceDate}
|
|
aria-label={t("review.schedule", "Schedule")}
|
|
onClick={() =>
|
|
run(
|
|
async () => {
|
|
await scheduleIssuance({
|
|
id,
|
|
scheduledDate: issuanceDate,
|
|
}).unwrap();
|
|
setIssuanceOpen(false);
|
|
},
|
|
t("review.done.scheduleIssuance", "Pickup scheduled"),
|
|
)
|
|
}
|
|
>
|
|
<IconCheck size={18} />
|
|
</ActionIcon>
|
|
</ModalFooter>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={resultOpen}
|
|
onClose={() => setResultOpen(false)}
|
|
title={t("review.inspectionResult", "Inspection result")}
|
|
>
|
|
<Stack>
|
|
{/* Structured per-area outcomes; persisted to the inspection's
|
|
checklist column, which was previously never populated. */}
|
|
<Stack gap={6}>
|
|
{INSPECTION_CHECKLIST_ITEMS.map((item) => (
|
|
<Group key={item.key} justify="space-between" wrap="nowrap">
|
|
<Text size="sm">{t(item.labelKey, item.fallback)}</Text>
|
|
<SegmentedControl
|
|
size="xs"
|
|
value={checklist[item.key] ?? "PASS"}
|
|
onChange={(value) =>
|
|
setChecklist((prev) => ({
|
|
...prev,
|
|
[item.key]: value as "PASS" | "FAIL" | "NEEDS_CORRECTION",
|
|
}))
|
|
}
|
|
data={[
|
|
{ value: "PASS", label: t("review.checkPass", "Pass") },
|
|
{
|
|
value: "NEEDS_CORRECTION",
|
|
label: t("review.checkFix", "Fix"),
|
|
},
|
|
{ value: "FAIL", label: t("review.checkFail", "Fail") },
|
|
]}
|
|
/>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
<Textarea
|
|
label={t("review.findings", "Findings")}
|
|
withAsterisk
|
|
value={findings}
|
|
onChange={(e) => setFindings(e.currentTarget.value)}
|
|
autosize
|
|
minRows={3}
|
|
/>
|
|
<ModalFooter grow>
|
|
<ActionIcon
|
|
variant="light"
|
|
color="teal"
|
|
size="lg"
|
|
disabled={!findings.trim() || !pendingInspection}
|
|
aria-label={t("review.passed", "Passed")}
|
|
onClick={() =>
|
|
run(
|
|
async () => {
|
|
// Guarded by the disabled state above; narrowing it here
|
|
// keeps that guarantee in the type system too.
|
|
if (!pendingInspection) return;
|
|
await recordResult({
|
|
inspectionId: pendingInspection.id,
|
|
applicationId: id,
|
|
result: "PASSED",
|
|
findings,
|
|
checklist: buildChecklist(checklist),
|
|
}).unwrap();
|
|
setResultOpen(false);
|
|
},
|
|
t("review.done.inspectionPassed", "Inspection passed"),
|
|
)
|
|
}
|
|
>
|
|
<IconCheck size={18} />
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
variant="light"
|
|
color="red"
|
|
size="lg"
|
|
disabled={!findings.trim() || !pendingInspection}
|
|
aria-label={t("review.failed", "Failed")}
|
|
onClick={() =>
|
|
run(
|
|
async () => {
|
|
// Guarded by the disabled state above; narrowing it here
|
|
// keeps that guarantee in the type system too.
|
|
if (!pendingInspection) return;
|
|
await recordResult({
|
|
inspectionId: pendingInspection.id,
|
|
applicationId: id,
|
|
result: "FAILED",
|
|
findings,
|
|
checklist: buildChecklist(checklist),
|
|
}).unwrap();
|
|
setResultOpen(false);
|
|
},
|
|
t("review.done.inspectionFailed", "Inspection failed"),
|
|
)
|
|
}
|
|
>
|
|
<IconX size={18} />
|
|
</ActionIcon>
|
|
</ModalFooter>
|
|
</Stack>
|
|
</Modal>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Evidence badges for one staff member.
|
|
*
|
|
* `application-for-review` nests a `documents` array per staff member, but it
|
|
* doesn't always carry the uploaded file (the portal's own upload widget
|
|
* hits the attachments endpoint directly for the same reason). Query
|
|
* attachments by owner here too, so the officer gets a working link instead
|
|
* of a badge with nowhere to go.
|
|
*/
|
|
function StaffEvidenceCell({
|
|
staffId,
|
|
required,
|
|
fallback,
|
|
}: {
|
|
staffId: string;
|
|
/** What this person's role must produce, from the licence-type config. */
|
|
required: StaffEvidenceRequirement[];
|
|
fallback?: { id: string; documentKey: string; files: { url?: string }[] }[];
|
|
}) {
|
|
const localized = useLocalized();
|
|
const { t } = useTranslation();
|
|
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
|
|
null,
|
|
);
|
|
const { data: attachments } = useGetAttachmentsQuery({
|
|
ownerType: "APPLICATION_STAFF",
|
|
ownerId: staffId,
|
|
});
|
|
const docs = attachments?.length ? attachments : (fallback ?? []);
|
|
const uploadedBy = new Map(docs.map((d) => [d.documentKey, d]));
|
|
|
|
// Drive the list off the requirements, not off what happens to have been
|
|
// uploaded: a mandatory CV that is absent has to be visible as absent, which
|
|
// is the whole point of the officer looking at this column. Anything
|
|
// uploaded outside the list still gets shown rather than silently dropped.
|
|
const extras = docs.filter(
|
|
(d) => !required.some((r) => r.docKey === d.documentKey),
|
|
);
|
|
|
|
if (!required.length && !extras.length) {
|
|
return (
|
|
<Text size="xs" c="dimmed">
|
|
—
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
const badge = (
|
|
key: string,
|
|
label: string,
|
|
url: string | undefined,
|
|
mandatory: boolean,
|
|
) => {
|
|
const missing = !url;
|
|
return (
|
|
<Tooltip
|
|
key={key}
|
|
label={
|
|
missing
|
|
? mandatory
|
|
? t("review.evidenceMissingRequired", "Required — not uploaded")
|
|
: t("review.evidenceMissing", "Not uploaded")
|
|
: t("licensing.documents.view", "View")
|
|
}
|
|
withArrow
|
|
>
|
|
<Badge
|
|
size="xs"
|
|
variant={missing ? "outline" : "light"}
|
|
color={missing ? (mandatory ? "red" : "gray") : "teal"}
|
|
style={missing ? undefined : { cursor: "pointer" }}
|
|
onClick={
|
|
missing ? undefined : () => setPreview({ url: url, title: label })
|
|
}
|
|
>
|
|
{label}
|
|
{missing && mandatory ? " *" : ""}
|
|
</Badge>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Group gap={4}>
|
|
{required.map((item) =>
|
|
badge(
|
|
item.docKey,
|
|
localized(item.label) || item.docKey,
|
|
uploadedBy.get(item.docKey)?.files?.[0]?.url,
|
|
item.mandatory,
|
|
),
|
|
)}
|
|
{extras.map((doc) =>
|
|
badge(doc.id, doc.documentKey, doc.files?.[0]?.url, false),
|
|
)}
|
|
<PdfPreviewModal
|
|
opened={Boolean(preview)}
|
|
onClose={() => setPreview(null)}
|
|
url={preview?.url ?? ""}
|
|
title={preview?.title}
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
function SummaryRow({
|
|
label,
|
|
value,
|
|
}: {
|
|
label: string;
|
|
value?: string | null;
|
|
}) {
|
|
return (
|
|
<Group justify="space-between" gap="xs" wrap="nowrap">
|
|
<Text size="xs" c="dimmed">
|
|
{label}
|
|
</Text>
|
|
<Text size="xs" fw={500} ta="right" truncate>
|
|
{value || "—"}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
export default LicenseReviewPage;
|