diff --git a/apps/backoffice/src/app/features/license-review/components/ActivityRail.tsx b/apps/backoffice/src/app/features/license-review/components/ActivityRail.tsx index 4d96ca2d9..68c520097 100644 --- a/apps/backoffice/src/app/features/license-review/components/ActivityRail.tsx +++ b/apps/backoffice/src/app/features/license-review/components/ActivityRail.tsx @@ -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 = { status: IconArrowRight, remark: IconMessage, + correction: IconPencilCheck, upload: IconFileUpload, assignment: IconUserCheck, }; @@ -50,7 +58,18 @@ const ICONS: Record = { * 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 ( diff --git a/apps/backoffice/src/app/features/license-review/components/CorrectedItemsPanel.tsx b/apps/backoffice/src/app/features/license-review/components/CorrectedItemsPanel.tsx new file mode 100644 index 000000000..d8e3c5203 --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/components/CorrectedItemsPanel.tsx @@ -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 ( + 0 ? "blue" : "orange"} + variant="light" + icon={} + title={ + + + {t("review.corrected.title", { + count: items.length, + defaultValue_one: "1 item corrected in round {{round}}", + defaultValue_other: + "{{count}} items corrected in round {{round}}", + round, + })} + + {untouched.length > 0 && ( + + {t("review.corrected.untouchedBadge", { + count: untouched.length, + defaultValue: "{{count}} unchanged", + })} + + )} + + } + > + {items.length > 0 && ( + + {items.map((item) => ( + + + {item.label} + + + {t("review.corrected.against", { + remark: item.remark, + defaultValue: "Asked: {{remark}}", + })}{" "} + · {showDate(item.changedAt.slice(0, 10))} + + + ))} + + )} + + {untouched.length > 0 && ( + <> + 0 ? "sm" : 0}> + {t( + "review.corrected.untouched", + "Marked done but left unchanged — re-read these first", + )} + + + {untouched.map((item) => ( + + + {item.label} + + + {t("review.corrected.against", { + remark: item.remark, + defaultValue: "Asked: {{remark}}", + })} + + + ))} + + + )} + + ); +} diff --git a/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx b/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx index 89cf9681f..3d5c68461 100644 --- a/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx +++ b/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx @@ -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>; + /** + * Document keys the applicant actually re-uploaded during the open + * correction round — the files worth re-opening first on a resubmission. + */ + correctedKeys?: Set; /** documentKey -> remark. Owned by the review page. */ flags: Record; 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")} )} + {correctedKeys?.has(attachment.documentKey) && ( + } + > + {t("review.corrected.badge", "Corrected")} + + )} {file?.originalName ?? diff --git a/apps/backoffice/src/app/features/license-review/components/FormDetailsTab.tsx b/apps/backoffice/src/app/features/license-review/components/FormDetailsTab.tsx index 05d620093..8adbc30ed 100644 --- a/apps/backoffice/src/app/features/license-review/components/FormDetailsTab.tsx +++ b/apps/backoffice/src/app/features/license-review/components/FormDetailsTab.tsx @@ -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; /** sectionKey -> remark. Owned by the review page. */ flags: Record; 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({ {section.title} + {correctedKeys?.has(section.key) && ( + } + > + {t('review.corrected.badge', 'Corrected')} + + )} {missing > 0 && ( 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); + }); +}); diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index 6f2f760fb..1a407ecf2 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -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, + ); + } } } diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index 0febc4cd7..8f4b51c39 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -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(), + documentKeys: new Set(), + }; + } + + 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() { + + {/* Zone 1 — sticky summary rail. */} @@ -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) && ( }> {t( "review.inspectionFailedBlocked", @@ -1358,7 +1490,7 @@ export function LicenseReviewPage() { {/* Zone 3 — activity and audit trail. */} {railOpen && ( - + )} @@ -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; diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 28090dba0..52cbd5d1e 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -1120,6 +1120,15 @@ export const am: Translations = { deficienciesHint: "የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።", notificationPreview: "ለአመልካቹ የሚላክ መልእክት", notificationPreviewHint: "በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።", + corrected: { + title_one: "በዙር {{round}} 1 ነጥብ ተስተካክሏል", + title_other: "በዙር {{round}} {{count}} ነጥቦች ተስተካክለዋል", + untouchedBadge_one: "1 አልተለወጠም", + untouchedBadge_other: "{{count}} አልተለወጡም", + untouched: "እንደተጠናቀቀ ምልክት ተደርጎ ግን አልተለወጠም — መጀመሪያ እነዚህን ይመልከቱ", + against: "የተጠየቀው፦ {{remark}}", + badge: "ተስተካክሏል", + }, needsCorrection: "ማስተካከያ ያስፈልገዋል", correctionPlaceholder: "አመልካቹ ምን ማስተካከል አለበት?", verifiedCapital: "የተረጋገጠ ካፒታል (ብር)", @@ -1202,6 +1211,7 @@ export const am: Translations = { needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ", needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ", needsInspection: "የምርመራ ውጤት ያስፈልጋል", + inspectionFailed: "ምርመራው አልተሳካም — ከመጽደቁ በፊት ድጋሚ ምርመራ ማለፍ አለበት", inspectionNotYetDue: "ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።", needsDocumentReviews: @@ -1209,7 +1219,7 @@ export const am: Translations = { needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም", }, inspectionFailedBlocked: - "ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።", + "ምርመራው ስላልተሳካ ማጽደቅና ሰርተፍኬት መስጠት አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።", reasons: { incompleteDocuments: "ያልተሟሉ ሰነዶች", belowCapital: "ካፒታል ከሚያስፈልገው በታች", @@ -1255,6 +1265,7 @@ export const am: Translations = { officer: "ሹም", applicant: "አመልካች", remarkOn: "በ{{target}} ላይ ማስተካከያ ተጠይቋል", + correctedTarget: "{{target}} ተስተካክሏል", uploaded: "{{document}} ተጭኗል", }, documents: { diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 82fb3b144..de361c81a 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -1130,6 +1130,15 @@ export const en = { deficienciesHint: 'Only the ticked items become editable for the applicant.', notificationPreview: 'Message to the applicant', notificationPreviewHint: 'Sent by SMS and email. Edit before confirming if needed.', + corrected: { + title_one: '1 item corrected in round {{round}}', + title_other: '{{count}} items corrected in round {{round}}', + untouchedBadge_one: '1 unchanged', + untouchedBadge_other: '{{count}} unchanged', + untouched: 'Marked done but left unchanged — re-read these first', + against: 'Asked: {{remark}}', + badge: 'Corrected', + }, needsCorrection: 'Needs correction', correctionPlaceholder: 'What must the applicant correct?', verifiedCapital: 'Verified capital (ETB)', @@ -1212,6 +1221,8 @@ export const en = { needsFlags: 'Flag at least one item to request a correction', needsCapital: 'Record the verified capital first', needsInspection: 'Requires an inspection result', + inspectionFailed: + 'The inspection failed — a re-inspection must pass before approval', inspectionNotYetDue: 'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.', needsDocumentReviews: @@ -1219,7 +1230,7 @@ export const en = { needsDocumentsUploaded: 'No documents uploaded to review yet', }, inspectionFailedBlocked: - 'Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.', + 'Approval and certificate issuance are unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.', reasons: { incompleteDocuments: 'Incomplete documents', belowCapital: 'Capital below the required minimum', @@ -1262,6 +1273,7 @@ export const en = { officer: 'Officer', applicant: 'Applicant', remarkOn: 'Correction requested on {{target}}', + correctedTarget: 'Corrected {{target}}', uploaded: 'Uploaded {{document}}', }, documents: { diff --git a/libs/api/src/lib/base-api/mock-data.ts b/libs/api/src/lib/base-api/mock-data.ts index e0a24a21f..1dc1c05e3 100644 --- a/libs/api/src/lib/base-api/mock-data.ts +++ b/libs/api/src/lib/base-api/mock-data.ts @@ -402,6 +402,7 @@ export const mockApplicationDetails: Record = Object.fromEntries( remark: 'Ownership document is blurred — please re-upload a clearer scan.', isResolved: false, resolvedAt: null, + valueChangedAt: null, createdAt: application.submittedAt ?? application.createdAt, }, ] @@ -417,6 +418,7 @@ export const mockApplicationDetails: Record = Object.fromEntries( remark: 'Ownership document is blurred — please re-upload a clearer scan.', isResolved: false, resolvedAt: null, + valueChangedAt: null, createdAt: application.submittedAt ?? application.createdAt, }, ] diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index ea4a9b627..e4b523f72 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -513,6 +513,15 @@ export interface ApplicationRemark { remark: string; isResolved: boolean; resolvedAt: string | null; + /** + * When the applicant actually changed the value this remark points at. + * + * `resolvedAt` only says the item was ticked off — the portal bulk-resolves + * every remark of the round right before resubmitting, so it proves nothing. + * This is stamped by the section save or document re-upload itself, which + * makes it the only trustworthy answer to "what did they actually correct?". + */ + valueChangedAt: string | null; createdAt: string; }