mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +00:00
Merge branch 'estif-branch-1' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
IconFileUpload,
|
||||
IconMessage,
|
||||
IconArrowRight,
|
||||
IconPencilCheck,
|
||||
IconUserCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -19,10 +20,16 @@ import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
type ApplicationDetail,
|
||||
type ApplicationRemark,
|
||||
} from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
|
||||
type EntryKind =
|
||||
| 'status'
|
||||
| 'remark'
|
||||
| 'correction'
|
||||
| 'upload'
|
||||
| 'assignment';
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string;
|
||||
@@ -37,6 +44,7 @@ interface ActivityEntry {
|
||||
const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
||||
status: IconArrowRight,
|
||||
remark: IconMessage,
|
||||
correction: IconPencilCheck,
|
||||
upload: IconFileUpload,
|
||||
assignment: IconUserCheck,
|
||||
};
|
||||
@@ -50,7 +58,18 @@ const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
||||
* the trade-off is that it can only show what the detail payload carries, and
|
||||
* notifications sent to the applicant are not among them.
|
||||
*/
|
||||
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
export function ActivityRail({
|
||||
detail,
|
||||
remarkLabel,
|
||||
}: {
|
||||
detail: ApplicationDetail;
|
||||
/**
|
||||
* Names a remark's target for the officer. The page owns this because it
|
||||
* holds the licence type config the labels come from; without it the trail
|
||||
* falls back to the raw key, which for a staff remark is a uuid.
|
||||
*/
|
||||
remarkLabel?: (remark: ApplicationRemark) => string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
@@ -82,18 +101,37 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
}
|
||||
|
||||
for (const remark of detail.remarks ?? []) {
|
||||
const target = remarkLabel?.(remark) ?? remark.targetKey;
|
||||
merged.push({
|
||||
id: `remark-${remark.id}`,
|
||||
kind: 'remark',
|
||||
at: remark.createdAt,
|
||||
actor: t('review.activity.officer', 'Officer'),
|
||||
title: t('review.activity.remarkOn', {
|
||||
target: remark.targetKey,
|
||||
target,
|
||||
defaultValue: 'Correction requested on {{target}}',
|
||||
}),
|
||||
detail: remark.remark,
|
||||
color: remark.resolvedAt ? 'teal' : 'orange',
|
||||
});
|
||||
|
||||
// The correction itself, at the moment the data moved. Distinct from the
|
||||
// remark above (which is the *request*) and from `resolvedAt`, which only
|
||||
// records the applicant ticking the item off — the portal does that in
|
||||
// bulk on resubmit, so it says nothing about what was actually edited.
|
||||
if (remark.valueChangedAt) {
|
||||
merged.push({
|
||||
id: `correction-${remark.id}`,
|
||||
kind: 'correction',
|
||||
at: remark.valueChangedAt,
|
||||
actor: t('review.activity.applicant', 'Applicant'),
|
||||
title: t('review.activity.correctedTarget', {
|
||||
target,
|
||||
defaultValue: 'Corrected {{target}}',
|
||||
}),
|
||||
color: 'blue',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of detail.attachments ?? []) {
|
||||
@@ -118,7 +156,7 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
return merged.sort(
|
||||
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
|
||||
);
|
||||
}, [detail, t]);
|
||||
}, [detail, remarkLabel, t]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Alert, Badge, Group, List, Text } from "@mantine/core";
|
||||
import { IconPencilCheck } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDateDisplayer } from "@ema-platform/shared";
|
||||
|
||||
export interface CorrectedItem {
|
||||
/** Section key, document key, or ApplicationStaff id. */
|
||||
key: string;
|
||||
/** What the officer reads — a section title, a document name, a person. */
|
||||
label: string;
|
||||
/** What the officer asked for, so the correction can be judged against it. */
|
||||
remark: string;
|
||||
/** When the applicant actually changed the value. */
|
||||
changedAt: string;
|
||||
}
|
||||
|
||||
interface CorrectedItemsPanelProps {
|
||||
round: number;
|
||||
items: CorrectedItem[];
|
||||
/** Flagged items the applicant ticked off without changing anything. */
|
||||
untouched: CorrectedItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What came back in the resubmission, item by item.
|
||||
*
|
||||
* A returned application announced itself with a "round 2" badge and nothing
|
||||
* else, so re-reviewing meant re-reading the whole file to find the two fields
|
||||
* that moved. This names them, alongside the remark that asked for each, and
|
||||
* separates out the items the applicant marked done but never actually edited —
|
||||
* the ones most likely to come back a third time.
|
||||
*
|
||||
* `valueChangedAt` is what makes the distinction possible: the portal resolves
|
||||
* every remark of the round in bulk on resubmit, so `isResolved` says only that
|
||||
* the applicant pressed the button.
|
||||
*/
|
||||
export function CorrectedItemsPanel({
|
||||
round,
|
||||
items,
|
||||
untouched,
|
||||
}: CorrectedItemsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
if (items.length === 0 && untouched.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
mb="md"
|
||||
color={items.length > 0 ? "blue" : "orange"}
|
||||
variant="light"
|
||||
icon={<IconPencilCheck size={16} />}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{t("review.corrected.title", {
|
||||
count: items.length,
|
||||
defaultValue_one: "1 item corrected in round {{round}}",
|
||||
defaultValue_other:
|
||||
"{{count}} items corrected in round {{round}}",
|
||||
round,
|
||||
})}
|
||||
</Text>
|
||||
{untouched.length > 0 && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
{t("review.corrected.untouchedBadge", {
|
||||
count: untouched.length,
|
||||
defaultValue: "{{count}} unchanged",
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{items.length > 0 && (
|
||||
<List size="sm" spacing={4}>
|
||||
{items.map((item) => (
|
||||
<List.Item key={item.key}>
|
||||
<Text size="sm" fw={500} component="span">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("review.corrected.against", {
|
||||
remark: item.remark,
|
||||
defaultValue: "Asked: {{remark}}",
|
||||
})}{" "}
|
||||
· {showDate(item.changedAt.slice(0, 10))}
|
||||
</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{untouched.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" fw={600} mt={items.length > 0 ? "sm" : 0}>
|
||||
{t(
|
||||
"review.corrected.untouched",
|
||||
"Marked done but left unchanged — re-read these first",
|
||||
)}
|
||||
</Text>
|
||||
<List size="sm" spacing={4}>
|
||||
{untouched.map((item) => (
|
||||
<List.Item key={item.key}>
|
||||
<Text size="sm" component="span">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("review.corrected.against", {
|
||||
remark: item.remark,
|
||||
defaultValue: "Asked: {{remark}}",
|
||||
})}
|
||||
</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconFileText,
|
||||
IconPencilCheck,
|
||||
IconRotate,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -43,6 +44,11 @@ interface DocumentsTabProps {
|
||||
requirements: DocumentRequirement[];
|
||||
/** Applicant answers used to evaluate conditional document requirements. */
|
||||
formData: Record<string, Record<string, unknown>>;
|
||||
/**
|
||||
* Document keys the applicant actually re-uploaded during the open
|
||||
* correction round — the files worth re-opening first on a resubmission.
|
||||
*/
|
||||
correctedKeys?: Set<string>;
|
||||
/** documentKey -> remark. Owned by the review page. */
|
||||
flags: Record<string, string>;
|
||||
onToggleFlag: (documentKey: string) => void;
|
||||
@@ -63,6 +69,7 @@ export function DocumentsTab({
|
||||
attachments,
|
||||
requirements,
|
||||
formData,
|
||||
correctedKeys,
|
||||
flags,
|
||||
onToggleFlag,
|
||||
onFlagRemark,
|
||||
@@ -243,6 +250,16 @@ export function DocumentsTab({
|
||||
{t("review.documents.flagged", "Correction requested")}
|
||||
</Badge>
|
||||
)}
|
||||
{correctedKeys?.has(attachment.documentKey) && (
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconPencilCheck size={11} />}
|
||||
>
|
||||
{t("review.corrected.badge", "Corrected")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file?.originalName ??
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconMapPin,
|
||||
IconPencilCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
conditionHolds,
|
||||
@@ -34,6 +38,12 @@ interface FormDetailsTabProps {
|
||||
/** The licence type's form schema — the order and labels to render by. */
|
||||
configSections: FormSectionConfig[];
|
||||
currency?: string;
|
||||
/**
|
||||
* Section keys the applicant actually edited during the open correction
|
||||
* round, so a returning application points at itself instead of making the
|
||||
* officer re-read every section to find the two that moved.
|
||||
*/
|
||||
correctedKeys?: Set<string>;
|
||||
/** sectionKey -> remark. Owned by the review page. */
|
||||
flags: Record<string, { remark: string }>;
|
||||
onToggleFlag: (sectionKey: string) => void;
|
||||
@@ -63,6 +73,7 @@ export function FormDetailsTab({
|
||||
formData,
|
||||
configSections,
|
||||
currency,
|
||||
correctedKeys,
|
||||
flags,
|
||||
onToggleFlag,
|
||||
onFlagRemark,
|
||||
@@ -145,6 +156,16 @@ export function FormDetailsTab({
|
||||
<Text fw={600} size="sm">
|
||||
{section.title}
|
||||
</Text>
|
||||
{correctedKeys?.has(section.key) && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<IconPencilCheck size={10} />}
|
||||
>
|
||||
{t('review.corrected.badge', 'Corrected')}
|
||||
</Badge>
|
||||
)}
|
||||
{missing > 0 && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
|
||||
@@ -9,6 +9,7 @@ const REASONS = {
|
||||
needsFlags: "needs flags",
|
||||
needsCapital: "needs capital",
|
||||
needsInspection: "needs inspection",
|
||||
inspectionFailed: "inspection failed",
|
||||
needsDocumentReviews: "needs document reviews",
|
||||
inspectionNotYetDue: "not yet due",
|
||||
};
|
||||
@@ -35,10 +36,40 @@ function resolve(licenseTypeKey: string) {
|
||||
flaggedCount: 1,
|
||||
hasPendingInspection: false,
|
||||
inspectionNotYetDue: false,
|
||||
latestInspectionPassed: null,
|
||||
allDocumentsAccepted: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An inspected licence sitting on INSPECTION_COMPLETED, which is the only
|
||||
* status Final Approve fires from — so the inspection *result* is all that
|
||||
* separates an approvable file from an unapprovable one.
|
||||
*/
|
||||
function inspected(latestInspectionPassed: boolean | null) {
|
||||
const detail = {
|
||||
application: {
|
||||
id: "app-1",
|
||||
status: "INSPECTION_COMPLETED",
|
||||
assignedOfficerId: "me",
|
||||
licenseType: { key: "VESSEL_REGISTRATION", inspectionRequired: true },
|
||||
},
|
||||
availableEvents: ["final-approve", "request-adjustment", "reject"],
|
||||
} as unknown as ApplicationDetail;
|
||||
|
||||
return resolveActions({
|
||||
detail,
|
||||
currentUserId: "me",
|
||||
can: () => true,
|
||||
reasons: REASONS,
|
||||
flaggedCount: 1,
|
||||
hasPendingInspection: false,
|
||||
inspectionNotYetDue: false,
|
||||
latestInspectionPassed,
|
||||
allDocumentsAccepted: true,
|
||||
}).find((a) => a.id === "final-approve");
|
||||
}
|
||||
|
||||
describe("resolveActions — seafarer certificates skip the queue", () => {
|
||||
it("drops claim and assign for a CoC", () => {
|
||||
const ids = resolve("CERTIFICATE_OF_COMPETENCY").map((a) => a.id);
|
||||
@@ -62,3 +93,29 @@ describe("resolveActions — seafarer certificates skip the queue", () => {
|
||||
expect(adjust?.disabledReason).toBe(REASONS.notAssigned);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A completed inspection is not a passed inspection.
|
||||
*
|
||||
* The status alone used to unlock approval, so a vessel that failed its visit
|
||||
* before INSPECTION_FAILED existed — the row stayed on INSPECTION_COMPLETED —
|
||||
* offered a live Approve button the server then refused with
|
||||
* `inspection_not_passed`.
|
||||
*/
|
||||
describe("resolveActions — approval waits on a passed inspection", () => {
|
||||
it("disables final approve when the latest inspection failed", () => {
|
||||
const approve = inspected(false);
|
||||
expect(approve?.enabled).toBe(false);
|
||||
expect(approve?.disabledReason).toBe(REASONS.inspectionFailed);
|
||||
});
|
||||
|
||||
it("disables final approve when no inspection was conducted", () => {
|
||||
const approve = inspected(null);
|
||||
expect(approve?.enabled).toBe(false);
|
||||
expect(approve?.disabledReason).toBe(REASONS.needsInspection);
|
||||
});
|
||||
|
||||
it("enables final approve once an inspection passed", () => {
|
||||
expect(inspected(true)?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,6 +347,7 @@ export interface ResolveContext {
|
||||
needsFlags: string;
|
||||
needsCapital: string;
|
||||
needsInspection: string;
|
||||
inspectionFailed: string;
|
||||
needsDocumentReviews: string;
|
||||
inspectionNotYetDue: string;
|
||||
};
|
||||
@@ -360,6 +361,18 @@ export interface ResolveContext {
|
||||
* button waits (the server refuses early results the same way).
|
||||
*/
|
||||
inspectionNotYetDue: boolean;
|
||||
/**
|
||||
* Outcome of the most recent conducted visit: `true` passed, `false` failed,
|
||||
* `null` none conducted yet.
|
||||
*
|
||||
* A completed inspection is not a passed one. Approval used to turn on the
|
||||
* status alone, so an application sitting at INSPECTION_COMPLETED with a
|
||||
* FAILED result behind it — what a failure produced before INSPECTION_FAILED
|
||||
* existed — offered a live Approve button that the server then refused with
|
||||
* `inspection_not_passed`. This is the same gate the server applies, so the
|
||||
* button is dead here instead of after the click.
|
||||
*/
|
||||
latestInspectionPassed: boolean | null;
|
||||
/**
|
||||
* False while any uploaded document is still unjudged or rejected. Approving
|
||||
* is a statement that every document was checked, so the button stays dead
|
||||
@@ -507,11 +520,19 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||
if (needsCapital && app.capitalAmountVerified == null) {
|
||||
return disabled(reasons.needsCapital);
|
||||
}
|
||||
if (
|
||||
app.licenseType?.inspectionRequired &&
|
||||
app.status !== 'INSPECTION_COMPLETED'
|
||||
) {
|
||||
return disabled(reasons.needsInspection);
|
||||
if (app.licenseType?.inspectionRequired) {
|
||||
if (app.status !== 'INSPECTION_COMPLETED') {
|
||||
return disabled(reasons.needsInspection);
|
||||
}
|
||||
// Mirrors the server's `inspection_not_passed`: the status says a
|
||||
// visit happened, the result says whether it may be approved.
|
||||
if (ctx.latestInspectionPassed !== true) {
|
||||
return disabled(
|
||||
ctx.latestInspectionPassed === false
|
||||
? reasons.inspectionFailed
|
||||
: reasons.needsInspection,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
useRescheduleInspectionMutation,
|
||||
useGetCertificateUrlForOfficerMutation,
|
||||
uploadDocument,
|
||||
type ApplicationRemark,
|
||||
type RemarkTargetType,
|
||||
type StaffEvidenceRequirement,
|
||||
} from "@ema-platform/api";
|
||||
@@ -92,6 +93,7 @@ import {
|
||||
type DecisionSubmission,
|
||||
} from "../../components/DecisionConfirmModal";
|
||||
import { ActivityRail } from "../../components/ActivityRail";
|
||||
import { CorrectedItemsPanel } from "../../components/CorrectedItemsPanel";
|
||||
import { DocumentsTab } from "../../components/DocumentsTab";
|
||||
import { FormDetailsTab } from "../../components/FormDetailsTab";
|
||||
import { ApplicantCard } from "../../components/ApplicantCard";
|
||||
@@ -331,6 +333,16 @@ export function LicenseReviewPage() {
|
||||
) < pendingInspection.scheduledDate.slice(0, 10),
|
||||
);
|
||||
|
||||
// The most recent visit that actually happened. `findForApplication` orders
|
||||
// newest first, so the first COMPLETED row is the one that decides approval —
|
||||
// a re-inspection that passed supersedes the failure before it.
|
||||
const latestConductedInspection = inspections.find(
|
||||
(i) => i.status === 'COMPLETED',
|
||||
);
|
||||
const latestInspectionPassed = latestConductedInspection
|
||||
? latestConductedInspection.result === 'PASSED'
|
||||
: null;
|
||||
|
||||
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
|
||||
useGetAttachmentsQuery(
|
||||
{ ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' },
|
||||
@@ -384,6 +396,100 @@ export function LicenseReviewPage() {
|
||||
[flags, data?.staff, t, localized, roleNameByKey],
|
||||
);
|
||||
|
||||
/**
|
||||
* What the applicant actually changed when they sent the file back.
|
||||
*
|
||||
* A resubmission used to arrive as a bare "round N" badge, which told the
|
||||
* officer that something had been corrected but not what — so re-review meant
|
||||
* re-reading the whole application. `valueChangedAt` is stamped by the edit
|
||||
* itself, so these are the items whose data really moved; the ones the
|
||||
* applicant ticked off without touching are listed separately, because they
|
||||
* are the likeliest reason the file comes back a third time.
|
||||
*
|
||||
* Scoped to the current round: `requestAdjustment` increments the counter and
|
||||
* `resubmit` does not, so the open round is the one just answered.
|
||||
*/
|
||||
/**
|
||||
* A remark target as the officer reads it: section title, document name, or
|
||||
* the staff member's role and name. The wire carries only the key — and for
|
||||
* a staff remark that key is an ApplicationStaff uuid — so every place that
|
||||
* shows a remark (the corrections panel, the activity rail) goes through this.
|
||||
*/
|
||||
const remarkLabel = useMemo(() => {
|
||||
const sectionTitles = new Map(
|
||||
(requirements?.licenseType.formSchema.sections ?? []).map((section) => [
|
||||
section.key,
|
||||
localized(section.title),
|
||||
]),
|
||||
);
|
||||
const documentNames = new Map(
|
||||
(requirements?.documentRequirements ?? []).map((requirement) => [
|
||||
requirement.key,
|
||||
localized(requirement.name),
|
||||
]),
|
||||
);
|
||||
|
||||
return (remark: ApplicationRemark): string => {
|
||||
if (remark.targetType === "FORM_SECTION") {
|
||||
return sectionTitles.get(remark.targetKey) || humaniseKey(remark.targetKey);
|
||||
}
|
||||
if (remark.targetType === "DOCUMENT") {
|
||||
return documentNames.get(remark.targetKey) || humaniseKey(remark.targetKey);
|
||||
}
|
||||
const member = data?.staff.find((s) => s.id === remark.targetKey);
|
||||
return member
|
||||
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey} — ${member.fullName}`
|
||||
: t("review.staffMember", "Staff member");
|
||||
};
|
||||
}, [data?.staff, requirements, localized, roleNameByKey, t]);
|
||||
|
||||
const correction = useMemo(() => {
|
||||
const round = data?.application.adjustmentRound ?? 0;
|
||||
if (!data || round === 0) {
|
||||
return {
|
||||
round,
|
||||
corrected: [],
|
||||
untouched: [],
|
||||
sectionKeys: new Set<string>(),
|
||||
documentKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
const roundRemarks = (data.remarks ?? []).filter(
|
||||
(r) => r.roundNumber === round,
|
||||
);
|
||||
const item = (remark: ApplicationRemark) => ({
|
||||
key: remark.id,
|
||||
label: remarkLabel(remark),
|
||||
remark: remark.remark,
|
||||
changedAt: remark.valueChangedAt ?? remark.resolvedAt ?? remark.createdAt,
|
||||
});
|
||||
|
||||
const changed = roundRemarks.filter((r) => r.valueChangedAt);
|
||||
return {
|
||||
round,
|
||||
corrected: changed.map(item),
|
||||
// Only once the file is actually back. While it is still with the
|
||||
// applicant the round is being worked on, and calling a not-yet-edited
|
||||
// item "marked done but left unchanged" would be an accusation about
|
||||
// work still in progress.
|
||||
untouched:
|
||||
data.application.status === "RESUBMIT_REQUIRED"
|
||||
? []
|
||||
: roundRemarks
|
||||
.filter((r) => !r.valueChangedAt && r.isResolved)
|
||||
.map(item),
|
||||
// Keyed per target type: a section and a document may legitimately
|
||||
// share a key, and a badge on the wrong one would misdirect the officer.
|
||||
sectionKeys: new Set(
|
||||
changed.filter((r) => r.targetType === "FORM_SECTION").map((r) => r.targetKey),
|
||||
),
|
||||
documentKeys: new Set(
|
||||
changed.filter((r) => r.targetType === "DOCUMENT").map((r) => r.targetKey),
|
||||
),
|
||||
};
|
||||
}, [data, remarkLabel]);
|
||||
|
||||
const actions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return resolveActions({
|
||||
@@ -393,6 +499,7 @@ export function LicenseReviewPage() {
|
||||
flaggedCount: flagged.length,
|
||||
hasPendingInspection: Boolean(pendingInspection),
|
||||
inspectionNotYetDue,
|
||||
latestInspectionPassed,
|
||||
allDocumentsAccepted,
|
||||
reasons: {
|
||||
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
|
||||
@@ -401,6 +508,10 @@ export function LicenseReviewPage() {
|
||||
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'),
|
||||
inspectionFailed: t(
|
||||
'review.disabled.inspectionFailed',
|
||||
'The inspection failed — a re-inspection must pass before approval',
|
||||
),
|
||||
inspectionNotYetDue: t('review.disabled.inspectionNotYetDue', {
|
||||
date: pendingInspection?.scheduledDate
|
||||
? showDate(pendingInspection.scheduledDate)
|
||||
@@ -422,7 +533,19 @@ export function LicenseReviewPage() {
|
||||
}),
|
||||
},
|
||||
});
|
||||
}, [data, currentUserId, can, flagged.length, pendingInspection, inspectionNotYetDue, showDate, t]);
|
||||
}, [
|
||||
data,
|
||||
currentUserId,
|
||||
can,
|
||||
flagged.length,
|
||||
pendingInspection,
|
||||
inspectionNotYetDue,
|
||||
latestInspectionPassed,
|
||||
allDocumentsAccepted,
|
||||
documentProgress,
|
||||
showDate,
|
||||
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
|
||||
@@ -935,6 +1058,12 @@ export function LicenseReviewPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<CorrectedItemsPanel
|
||||
round={correction.round}
|
||||
items={correction.corrected}
|
||||
untouched={correction.untouched}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
{/* Zone 1 — sticky summary rail. */}
|
||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||
@@ -1130,6 +1259,7 @@ export function LicenseReviewPage() {
|
||||
formData={app.formData ?? {}}
|
||||
configSections={configSections}
|
||||
currency={app.feeCurrency ?? undefined}
|
||||
correctedKeys={correction.sectionKeys}
|
||||
flags={flags}
|
||||
onToggleFlag={(sectionKey) =>
|
||||
toggleFlag("FORM_SECTION", sectionKey)
|
||||
@@ -1205,6 +1335,7 @@ export function LicenseReviewPage() {
|
||||
attachments={data.attachments}
|
||||
requirements={requirements?.documentRequirements ?? []}
|
||||
formData={app.formData ?? {}}
|
||||
correctedKeys={correction.documentKeys}
|
||||
flags={documentFlags}
|
||||
onToggleFlag={(key) => toggleFlag("DOCUMENT", key)}
|
||||
onFlagRemark={(key, remark) =>
|
||||
@@ -1330,7 +1461,8 @@ export function LicenseReviewPage() {
|
||||
{/* Page-level, not inside the inspection tab: a license type
|
||||
configured without an inspection detail section must still show
|
||||
why approval is blocked if it ever lands here. */}
|
||||
{status === "INSPECTION_FAILED" && (
|
||||
{(status === "INSPECTION_FAILED" ||
|
||||
latestInspectionPassed === false) && (
|
||||
<Alert mt="md" color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
{t(
|
||||
"review.inspectionFailedBlocked",
|
||||
@@ -1358,7 +1490,7 @@ export function LicenseReviewPage() {
|
||||
{/* Zone 3 — activity and audit trail. */}
|
||||
{railOpen && (
|
||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||
<ActivityRail detail={data} />
|
||||
<ActivityRail detail={data} remarkLabel={remarkLabel} />
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
@@ -1848,4 +1980,16 @@ function SummaryRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A camelCase or snake_case key as a heading.
|
||||
*
|
||||
* Only reached when the licence type's config has no label for the key — a
|
||||
* section the schema has since dropped, say. Better than printing
|
||||
* `vesselParticulars` at an officer.
|
||||
*/
|
||||
function humaniseKey(key: string): string {
|
||||
const spaced = key.replace(/([A-Z])/g, " $1").replace(/[_-]+/g, " ");
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1).trim();
|
||||
}
|
||||
|
||||
export default LicenseReviewPage;
|
||||
|
||||
Reference in New Issue
Block a user