Merge branch 'estif-branch-1' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-09-07 10:25:17 +03:00
11 changed files with 468 additions and 15 deletions

View File

@@ -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 (

View File

@@ -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>
);
}

View File

@@ -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 ??

View File

@@ -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(

View File

@@ -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);
});
});

View File

@@ -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,
);
}
}
}

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -402,6 +402,7 @@ export const mockApplicationDetails: Record<string, any> = 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<string, any> = Object.fromEntries(
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
isResolved: false,
resolvedAt: null,
valueChangedAt: null,
createdAt: application.submittedAt ?? application.createdAt,
},
]

View File

@@ -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;
}