Merge pull request #39 from Tria-plc/estif-branch-1

Comments from tade
This commit is contained in:
Nati Nigussie
2026-08-28 14:28:41 +03:00
committed by GitHub
14 changed files with 504 additions and 90 deletions

View File

@@ -28,6 +28,7 @@ export type ActionId =
| 'complete-review'
| 'approve-documents'
| 'schedule-inspection'
| 'reschedule-inspection'
| 'record-inspection'
| 'final-approve'
| 'request-adjustment'
@@ -200,6 +201,17 @@ export const ACTIONS: ActionDefinition[] = [
permissions: ['can:create:inspection'],
emphasis: 'filled',
},
{
id: 'reschedule-inspection',
tier: 'primary',
labelKey: 'review.actions.rescheduleInspection',
from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
// Either permission: the team leader who booked the visit holds CREATE,
// the inspector who has to attend holds UPDATE, and both have a reason to
// move it. The server guards the same pair.
permissions: ['can:create:inspection', 'can:update:inspection'],
emphasis: 'light',
},
{
id: 'record-inspection',
tier: 'primary',
@@ -441,6 +453,12 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
// one applies depends on whether an inspection is already booked.
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
// The mirror of the scheduling gate: there is nothing to move until a
// visit is booked, and once one is, moving it is the officer's only
// option until the day arrives.
if (action.id === 'reschedule-inspection' && !ctx.hasPendingInspection) {
return [];
}
// The transition table doesn't know which types need an inspection, so
// `availableEvents` lists approve-documents at UNDER_EVALUATION even for

View File

@@ -32,6 +32,7 @@ import {
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconPaperclip,
IconPencil,
IconQuestionMark,
IconX,
} from "@tabler/icons-react";
@@ -70,6 +71,7 @@ import {
useRequestAdjustmentMutation,
useResumeApplicationMutation,
useScheduleInspectionMutation,
useRescheduleInspectionMutation,
useGetCertificateUrlForOfficerMutation,
uploadDocument,
type RemarkTargetType,
@@ -218,6 +220,7 @@ export function LicenseReviewPage() {
const [finalApprove] = useFinalApproveMutation();
const [rejectApplication] = useRejectApplicationMutation();
const [scheduleInspection] = useScheduleInspectionMutation();
const [rescheduleInspection] = useRescheduleInspectionMutation();
const [recordResult] = useRecordInspectionResultMutation();
const [confirmPayment] = useConfirmPaymentMutation();
const [scheduleIssuance] = useScheduleIssuanceMutation();
@@ -261,6 +264,9 @@ export function LicenseReviewPage() {
const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
"MORNING",
);
/** The booking modal moves an existing visit rather than creating one. */
const [rescheduling, setRescheduling] = useState(false);
const [rescheduleReason, setRescheduleReason] = useState("");
const [issuanceOpen, setIssuanceOpen] = useState(false);
const [issuanceDate, setIssuanceDate] = useState("");
const [resultOpen, setResultOpen] = useState(false);
@@ -311,10 +317,14 @@ export function LicenseReviewPage() {
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
// A visit cannot have an outcome before it happens — mirror of the server's
// inspection_not_yet_due guard, compared instant-to-instant.
// inspection_not_yet_due guard. The column holds a calendar day, so the
// comparison is between day strings in the authority's timezone: parsing
// "2026-08-28" as a Date would read it as UTC midnight, i.e. 03:00 in Addis.
const inspectionNotYetDue = Boolean(
pendingInspection?.scheduledDate &&
new Date(pendingInspection.scheduledDate) > new Date(),
new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Addis_Ababa" }).format(
new Date(),
) < pendingInspection.scheduledDate.slice(0, 10),
);
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
@@ -558,12 +568,29 @@ export function LicenseReviewPage() {
}
}
/** Opens the booking modal seeded with the visit already on the books. */
function openReschedule() {
if (!pendingInspection) return;
setRescheduling(true);
setInspectionDate(pendingInspection.scheduledDate ?? "");
setInspectionTimeSlot(pendingInspection.timeSlot ?? "MORNING");
setRescheduleReason("");
setInspectionOpen(true);
}
/** Actions with their own dedicated form open that; the rest confirm. */
function handleAction(action: ResolvedAction) {
switch (action.id) {
case "schedule-inspection":
setRescheduling(false);
setInspectionDate("");
setInspectionTimeSlot("MORNING");
setRescheduleReason("");
setInspectionOpen(true);
return;
case "reschedule-inspection":
openReschedule();
return;
case "schedule-issuance":
setIssuanceOpen(true);
return;
@@ -1246,21 +1273,48 @@ export function LicenseReviewPage() {
</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,
<Group gap="xs">
{inspection.status === "SCHEDULED" &&
inspection.id === pendingInspection?.id &&
can([
"can:create:inspection",
"can:update:inspection",
]) && (
<Tooltip
label={t(
"review.actions.rescheduleInspection",
"Reschedule inspection",
)}
</Badge>
>
<ActionIcon
variant="subtle"
size="sm"
aria-label={t(
"review.actions.rescheduleInspection",
"Reschedule inspection",
)}
onClick={openReschedule}
>
<IconPencil size={16} />
</ActionIcon>
</Tooltip>
)}
<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>
</Group>
))}
</Stack>
@@ -1430,7 +1484,11 @@ export function LicenseReviewPage() {
<Modal
opened={inspectionOpen}
onClose={() => setInspectionOpen(false)}
title={t("review.actions.scheduleInspection", "Schedule inspection")}
title={
rescheduling
? t("review.actions.rescheduleInspection", "Reschedule inspection")
: t("review.actions.scheduleInspection", "Schedule inspection")
}
>
<Stack>
<AmharicDatePicker
@@ -1446,36 +1504,72 @@ export function LicenseReviewPage() {
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
]}
/>
{rescheduling && (
<Textarea
label={t("review.rescheduleReason", "Why is it moving?")}
description={t(
"review.rescheduleReasonHint",
"Kept in the audit trail and sent to the applicant.",
)}
value={rescheduleReason}
onChange={(e) => setRescheduleReason(e.currentTarget.value)}
autosize
minRows={2}
/>
)}
<ModalFooter>
{/* Mantine strips pointer events from a disabled control, so the
tooltip wraps a span — same trick as DecisionBar's ActionButton;
a disabled button must still say why. */}
<Tooltip
label={t("review.pickDate", "Pick a date first")}
disabled={Boolean(inspectionDate)}
>
<span>
<button type="button" hidden aria-hidden />
</span>
</Tooltip>
<span style={{ display: "inline-flex" }}>
<ActionIcon
variant="filled"
size="lg"
disabled={!inspectionDate}
aria-label={t("review.schedule", "Schedule")}
aria-label={
rescheduling
? t("review.reschedule", "Reschedule")
: t("review.schedule", "Schedule")
}
onClick={() =>
run(
async () => {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
}).unwrap();
if (rescheduling) {
// Guarded by the action's own gate, which only offers
// rescheduling while a booking exists.
if (!pendingInspection) return;
await rescheduleInspection({
inspectionId: pendingInspection.id,
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
...(rescheduleReason.trim()
? { reason: rescheduleReason.trim() }
: {}),
}).unwrap();
} else {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
}).unwrap();
}
setInspectionOpen(false);
},
t("review.done.scheduled", "Inspection scheduled"),
rescheduling
? t("review.done.rescheduled", "Inspection rescheduled")
: t("review.done.scheduled", "Inspection scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</span>
</Tooltip>
</ModalFooter>
</Stack>
</Modal>
@@ -1492,34 +1586,36 @@ export function LicenseReviewPage() {
onChange={setIssuanceDate}
/>
<ModalFooter>
{/* Span-wrapped like DecisionBar's ActionButton — Mantine strips
pointer events from a disabled control, and a disabled button
must still say why. */}
<Tooltip
label={t("review.pickDate", "Pick a date and time first")}
label={t("review.pickDate", "Pick a date first")}
disabled={Boolean(issuanceDate)}
>
<span>
<button type="button" hidden aria-hidden />
<span style={{ display: "inline-flex" }}>
<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>
</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>

View File

@@ -184,7 +184,7 @@ export function SeafarerRegistryPage() {
</SimpleGrid>
{/* Search + filters */}
<Paper withBorder radius="lg" p="xl">
<Paper withBorder radius="lg" p={{ base: 'md', sm: 'xl' }}>
<Group mb="sm" gap="sm" justify="space-between">
<TextInput
placeholder="Search by name, seafarer ID, or seaman book…"
@@ -215,6 +215,7 @@ export function SeafarerRegistryPage() {
<Text fz="xs" c="dimmed" mb="sm">{filtered.length} seafarer(s) found</Text>
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
@@ -278,6 +279,7 @@ export function SeafarerRegistryPage() {
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Paper>
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />

View File

@@ -988,6 +988,9 @@ export const am: Translations = {
morning: "ጠዋት",
afternoon: "ከሰዓት በኋላ",
schedule: "ያዝ",
reschedule: "አዛውር",
rescheduleReason: "ለምን ይዛወራል?",
rescheduleReasonHint: "በኦዲት መዝገብ ውስጥ ተይዞ ለአመልካቹ ይላካል።",
pickDate: "መጀመሪያ ቀን ይምረጡ",
passed: "አልፏል",
failed: "ወድቋል",
@@ -1024,6 +1027,7 @@ export const am: Translations = {
completeReview: "ግምገማ አጠናቅቅ",
approveDocuments: "ሰነዶችን አጽድቅ",
scheduleInspection: "ምርመራ ያዝ",
rescheduleInspection: "ምርመራ አዛውር",
recordInspection: "የምርመራ ውጤት መዝግብ",
finalApprove: "አጽድቅ እና ስጥ",
requestAdjustment: "ማስተካከያ ጠይቅ",
@@ -1177,6 +1181,7 @@ export const am: Translations = {
assign: "እንደገና ተመድቧል",
assignReviewer: "ግምገማ ተመድቧል",
scheduled: "ምርመራ ተይዟል",
rescheduled: "ምርመራ ተዛውሯል",
inspectionPassed: "ምርመራ አልፏል",
inspectionFailed: "ምርመራ ወድቋል",
},

View File

@@ -997,6 +997,9 @@ export const en = {
morning: 'Morning',
afternoon: 'Afternoon',
schedule: 'Schedule',
reschedule: 'Reschedule',
rescheduleReason: 'Why is it moving?',
rescheduleReasonHint: 'Kept in the audit trail and sent to the applicant.',
pickDate: 'Pick a date first',
passed: 'Passed',
failed: 'Failed',
@@ -1033,6 +1036,7 @@ export const en = {
completeReview: 'Complete review',
approveDocuments: 'Approve documents',
scheduleInspection: 'Schedule inspection',
rescheduleInspection: 'Reschedule inspection',
recordInspection: 'Record inspection result',
finalApprove: 'Approve & issue',
requestAdjustment: 'Request adjustment',
@@ -1183,6 +1187,7 @@ export const en = {
assign: 'Reassigned',
assignReviewer: 'Review assigned',
scheduled: 'Inspection scheduled',
rescheduled: 'Inspection rescheduled',
inspectionPassed: 'Inspection passed',
inspectionFailed: 'Inspection failed',
},

View File

@@ -42,14 +42,14 @@ export default defineConfig({
emptyOutDir: true,
reportCompressedSize: true,
},
// Unit tests for the pure helpers behind a screen (formatters, URL state).
// Component tests are deliberately not set up: nothing here renders React,
// so no jsdom environment or setup file is needed.
// test: {
// watch: false,
// globals: true,
// environment: 'node',
// include: ['src/**/*.spec.ts'],
// reporters: ['default'],
// },
// Unit tests for the pure helpers behind a screen (formatters, URL state,
// queue views). Component tests are deliberately not set up: nothing here
// renders React, so no jsdom environment or setup file is needed.
test: {
watch: false,
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
reporters: ['default'],
},
});

View File

@@ -37,6 +37,12 @@ interface Props {
flagged?: Record<string, string>;
/** When set, only flagged slots accept a new upload. */
restrictToFlagged?: boolean;
/**
* Requirement keys opened because a flagged section drives their condition —
* a category correction can make documents newly required, and those have to
* be uploadable even though the officer flagged no document.
*/
alsoUnlocked?: string[];
onUploaded: () => void;
readOnly?: boolean;
}
@@ -55,6 +61,7 @@ export function DocumentSlots({
ownerId,
flagged = {},
restrictToFlagged = false,
alsoUnlocked = [],
onUploaded,
readOnly,
}: Props) {
@@ -106,7 +113,11 @@ export function DocumentSlots({
const uploaded = Boolean(existing?.files?.length);
const fileUrl = existing?.files?.[0]?.url;
const flagRemark = flagged[requirement.key];
const locked = readOnly || (restrictToFlagged && !flagRemark);
const locked =
readOnly ||
(restrictToFlagged &&
!flagRemark &&
!alsoUnlocked.includes(requirement.key));
return (
<Card

View File

@@ -81,6 +81,11 @@ export function LicenseCard({
// only for a cached response from before those fields existed.
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
const expired = license.status === 'EXPIRED' || days < 0;
// Suspended, cancelled and superseded are none of them "valid until" their
// expiry date — the card used to say exactly that, because only EXPIRED was
// treated as not-current. A suspended licence read as a live one with a grey
// badge.
const current = license.status === 'ACTIVE' && !expired;
const renewable = license.renewable ?? false;
const showDate = useDateDisplayer();
const localized = useLocalized();
@@ -100,9 +105,15 @@ export function LicenseCard({
<Badge
size="sm"
variant="light"
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
color={expired ? 'red' : current ? 'teal' : 'gray'}
>
{expired ? t('licensing.card.expired') : license.status}
{/* The raw enum was rendered here, so an Amharic page showed
"SUSPENDED" among otherwise translated text. */}
{expired
? t('licensing.card.expired')
: t(`licensing.card.status.${license.status}`, {
defaultValue: license.status,
})}
</Badge>
</Group>
@@ -113,8 +124,19 @@ export function LicenseCard({
<Text size="sm" fw={500}>
{expired
? t('licensing.card.expiredOn', { date: showDate(license.expiryDate) })
: t('licensing.card.validUntil', { date: showDate(license.expiryDate) })}
: current
? t('licensing.card.validUntil', { date: showDate(license.expiryDate) })
: t(`licensing.card.status.${license.status}`, {
defaultValue: license.status,
})}
</Text>
{/* Why it stopped being current. The API returns it; the card threw
it away, leaving the holder to guess. */}
{!current && license.statusReason && (
<Text size="xs" c="dimmed" mt={2}>
{t('licensing.card.statusReason', { reason: license.statusReason })}
</Text>
)}
</Box>
<RequirePermission
anyOf={[

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Badge,
Box,
@@ -20,6 +21,7 @@ import {
IconBuildingWarehouse,
IconChevronRight,
IconFileText,
IconLock,
IconShieldOff,
IconShip,
IconTrendingUp,
@@ -45,9 +47,9 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
CARGO_FREIGHT: IconBuildingWarehouse,
SHIPPING_AGENCY: IconShip,
INVESTMENT: IconTrendingUp,
// The three below are filtered out of this catalogue today
// (requiresOperatorMode is false for all of them), and are listed only so
// the record stays total if that ever changes.
// The three below appear only when the applicant has declared a licence
// type in them (see the family filter below); listed here so the record
// stays total either way.
MARITIME_PERSONNEL: IconShip,
VESSEL_SERVICES: IconAnchor,
WAIVER_SERVICES: IconShieldOff,
@@ -81,15 +83,19 @@ export function LicenseCatalogue() {
const { groups, orphans } = useMemo(() => {
const active = (types?.items ?? [])
.filter((t) => t.isActive)
// Logistics licences only: this is the operator catalogue, not the
// seafarer certificate or vessel/seafarer document catalogue — those
// have their own entry points. `familyKind` is the real data-model
// classification (set on the type at seed time); `requiresOperatorMode`
// was the proxy this used before that column existed and happened to
// agree for every type seeded so far, but a type can only be trusted to
// stay in sync with the catalogue it belongs in if the catalogue reads
// its actual family instead of a flag with a different purpose.
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE')
// The logistics family, plus whatever this applicant actually declared.
//
// `familyKind` is the real data-model classification and is what keeps
// browse-all to the operator catalogue rather than every certificate and
// document type in the system. But it is not what decides eligibility:
// the Operations tab also offers the personal registrations (seafarer,
// vessel) and the seafarer endorsement, which are DOCUMENT/CERTIFICATE
// family, so an applicant who declared one of those was shown an empty
// catalogue — allowed to file, and offered nothing to file. Each of
// those keys already has an entry point at `/licensing/<key>/apply`
// (a router redirect for SEAFARER_REGISTRATION and SEAMAN_BOOK, the
// generic wizard for the rest), so the card leads somewhere real.
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE' || declared.has(t.id))
// Only what the applicant operates as. The server enforces the same rule
// on create; this is what stops them starting an application they will
// be refused at the end of.
@@ -273,8 +279,8 @@ function LicenseTypeCard({
withBorder
radius="md"
padding="md"
style={{ cursor: 'pointer', height: '100%' }}
onClick={() => onSelect(type)}
style={{ cursor: canApply ? 'pointer' : 'default', height: '100%' }}
onClick={canApply ? () => onSelect(type) : undefined}
>
<Stack gap="xs" justify="space-between" h="100%">
<Box>
@@ -282,11 +288,19 @@ function LicenseTypeCard({
<Text fw={600} size="sm" lh={1.35}>
{localized(type.name)}
</Text>
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
{canApply ? (
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
) : (
<IconLock
size={16}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
)}
</Group>
{type.description && (
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
@@ -325,13 +339,45 @@ function LicenseTypeCard({
mt="sm"
size="xs"
variant="light"
color={canApply ? undefined : 'gray'}
disabled={!canApply}
rightSection={<IconArrowRight size={14} />}
>
{canApply
? t('licensing.catalogue.startApplication')
: t('licensing.catalogue.addToOperations')}
{t('licensing.catalogue.startApplication')}
</Button>
{/* A disabled button on its own only says "no". This says why, and
where to go about it — the licence is offered against a declared
mode of operation, and the server refuses a create for one the
applicant has not declared. Called out rather than set in dimmed
small print: it is the only thing on a locked card the applicant
can act on. */}
{!canApply && (
<Alert
variant="light"
color="orange"
radius="md"
mt="sm"
p="xs"
>
<Text size="xs" lh={1.4}>
{t('licensing.catalogue.lockedHint')}
</Text>
<Anchor
size="xs"
fw={600}
component="button"
type="button"
mt={4}
onClick={(event) => {
// The card is inert while locked, but the anchor inside it
// must not re-trigger anything if that ever changes.
event.stopPropagation();
onSelect(type);
}}
>
{t('licensing.catalogue.addToOperations')}
</Anchor>
</Alert>
)}
</RequirePermission>
</Box>
</Stack>

View File

@@ -32,6 +32,8 @@ import { useTranslation } from "react-i18next";
import {
buildWizardSteps,
conditionHolds,
conditionSections,
sectionsDependingOn,
extractErrorMessage,
extractValidationIssues,
useLocalized,
@@ -361,8 +363,34 @@ export function LicenseApplicationPage() {
// filed.
const roundIsItemised = isAdjusting && roundRemarks.length > 0;
// An answer the officer flagged can decide which fields *other* sections
// require — the vessel category is the live example. Freeze those and the
// applicant is shown newly-required fields they cannot fill, and cannot
// resubmit; the server unlocks them the same way.
const cascadeUnlocked = useMemo(
() =>
sectionsDependingOn(
config?.licenseType?.formSchema?.sections ?? [],
new Set(Object.keys(flaggedSections)),
),
[config, flaggedSections],
);
const unlockedDocuments = useMemo(
() =>
(config?.documentRequirements ?? [])
.filter((requirement) =>
conditionSections(requirement.conditionExpression).some(
(sectionKey) => sectionKey in flaggedSections,
),
)
.map((requirement) => requirement.key),
[config, flaggedSections],
);
const isSectionLocked = (sectionKey: string) =>
roundIsItemised && !flaggedSections[sectionKey];
roundIsItemised &&
!flaggedSections[sectionKey] &&
!cascadeUnlocked.has(sectionKey);
const staffLocked = roundIsItemised && !hasStaffRemarks;
// Sections that share a group collapse onto one step, so the stepper stays
@@ -942,6 +970,7 @@ export function LicenseApplicationPage() {
ownerId={appId}
flagged={flaggedDocuments}
restrictToFlagged={roundIsItemised}
alsoUnlocked={unlockedDocuments}
readOnly={readOnly}
onUploaded={() => {
refetchAttachments();

View File

@@ -802,6 +802,14 @@ export const am: Translations = {
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
status: {
ACTIVE: 'የፀና',
EXPIRED: 'ጊዜው ያለፈበት',
SUSPENDED: 'የታገደ',
CANCELLED: 'የተሰረዘ',
SUPERSEDED: 'በአዲስ የምስክር ወረቀት የተተካ',
},
statusReason: 'ምክንያት፦ {{reason}}',
},
catalogue: {
emptyTitle: 'የሚሰሩበትን የስራ ዘርፍ ይንገሩን',
@@ -823,6 +831,8 @@ export const am: Translations = {
evaluationOnly: 'ግምገማ ብቻ',
startApplication: 'ማመልከቻ ጀምር',
addToOperations: 'ወደ ስራ ዘርፎቼ ጨምር',
lockedHint:
'ከተመዘገቡ የስራ ዘርፎችዎ ውስጥ ስላልሆነ እስካሁን ማመልከት አይችሉም።',
},
},
@@ -1278,4 +1288,49 @@ export const am: Translations = {
},
},
},
documents: {
title: 'ሰነዶቼ',
subtitle: 'EMA ያወጣልዎት እያንዳንዱ ሰነድ፣ እንዲሁም ከመዝገቦችዎ ጋር የተያያዙ ፋይሎች።',
tabs: {
license: 'ፈቃዶች',
medical: 'ሕክምና',
seaService: 'የባህር አገልግሎት',
personal: 'የግል መረጃ',
},
issuedTitle: 'በ EMA የተሰጡ ሰነዶች',
licensesTitle: 'የምስክር ወረቀቶች እና ፈቃዶች',
kind: {
SEAMAN_BOOK: 'የመርከበኛ መጽሐፍ',
BTC_BASIC_TRAINING: 'የመሠረታዊ ስልጠና የምስክር ወረቀት (BTC)',
},
documentStatus: {
AWAITING_REGISTRATION: 'ምዝገባ በመጠበቅ ላይ',
PAYMENT_PENDING: 'ክፍያ በመጠበቅ ላይ',
PAID: 'ተከፍሏል',
PAYMENT_CONFIRMED: 'ሰነድ በመዘጋጀት ላይ',
SCHEDULED: 'የመውሰጃ ቀጠሮ ተይዟል',
ISSUED: 'ተሰጥቷል',
REJECTED: 'ተቀባይነት አላገኘም',
CANCELLED: 'ተሰርዟል',
},
view: 'ይመልከቱ',
notIssued: 'እስካሁን አልተሰጠም',
openFailed: 'ሰነዱን መክፈት አልተቻለም',
files: {
none: 'ምንም የተያያዘ ፋይል የለም።',
},
empty: {
licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።',
medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።',
seaService: 'እስካሁን በመዝገብ ላይ የባህር አገልግሎት መዝገብ የለም።',
},
personal: {
description: 'ከመርከበኛ ምዝገባዎ ጋር ያስገቡት ሰነዶች።',
noRegistration: 'እስካሁን የመርከበኛ ምዝገባ ስለሌለዎት በመዝገብ ላይ የግል ሰነዶች የሉም።',
startRegistration: 'ወደ መርከበኛ ምዝገባ ይሂዱ',
uploaded: 'ተሰቅሏል',
missing: 'አልተሰቀለም',
},
},
};

View File

@@ -802,6 +802,14 @@ export const en = {
renewDays_one: 'Renew — expires in {{count}} day',
renewDays_other: 'Renew — expires in {{count}} days',
renewFailed: 'Could not start the renewal',
status: {
ACTIVE: 'Active',
EXPIRED: 'Expired',
SUSPENDED: 'Suspended',
CANCELLED: 'Cancelled',
SUPERSEDED: 'Replaced by a newer certificate',
},
statusReason: 'Reason: {{reason}}',
},
catalogue: {
emptyTitle: 'Tell us what you operate as',
@@ -823,6 +831,8 @@ export const en = {
evaluationOnly: 'Evaluation only',
startApplication: 'Start application',
addToOperations: 'Add to my operations',
lockedHint:
'Not one of your declared operations, so it cannot be applied for yet.',
},
},
@@ -1280,6 +1290,53 @@ export const en = {
},
},
},
documents: {
title: 'My documents',
subtitle:
'Every document EMA has issued you, and the files attached to your records.',
tabs: {
license: 'Licences',
medical: 'Medical',
seaService: 'Sea Service',
personal: 'Personal Data',
},
issuedTitle: 'EMA-issued documents',
licensesTitle: 'Certificates and licences',
kind: {
SEAMAN_BOOK: 'Seaman Book',
BTC_BASIC_TRAINING: 'Basic Training Certificate (BTC)',
},
documentStatus: {
AWAITING_REGISTRATION: 'Awaiting registration',
PAYMENT_PENDING: 'Payment pending',
PAID: 'Paid',
PAYMENT_CONFIRMED: 'Preparing document',
SCHEDULED: 'Pickup scheduled',
ISSUED: 'Issued',
REJECTED: 'Rejected',
CANCELLED: 'Cancelled',
},
view: 'View',
notIssued: 'Not issued yet',
openFailed: 'Could not open the document',
files: {
none: 'No files attached.',
},
empty: {
licenses: 'No certificates or licences have been issued to you yet.',
medical: 'No medical certificates on file yet.',
seaService: 'No sea-service records on file yet.',
},
personal: {
description: 'The documents you submitted with your seafarer registration.',
noRegistration:
'You have no seafarer registration yet, so there are no personal documents on file.',
startRegistration: 'Go to seafarer registration',
uploaded: 'Uploaded',
missing: 'Not uploaded',
},
},
};
export type Translations = typeof en;

View File

@@ -1015,6 +1015,30 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
/** Moves a booked visit: another day, slot, inspector or place. */
rescheduleInspection: builder.mutation<
Inspection,
{
inspectionId: string;
applicationId: string;
scheduledDate: string;
timeSlot: 'MORNING' | 'AFTERNOON';
inspectorId?: string;
location?: string;
reason?: string;
}
>({
query: ({ inspectionId, scheduledDate, timeSlot, inspectorId, location, reason }) => ({
url: `/inspections/${inspectionId}/schedule`,
method: 'PATCH',
// applicationId is for cache invalidation only; the visit knows its
// own application.
body: { scheduledDate, timeSlot, inspectorId, location, reason },
}),
invalidatesTags: (_r, error, { applicationId }) =>
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
getInspections: builder.query<Inspection[], string>({
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
providesTags: () => [listTag('Inspection')],
@@ -1152,6 +1176,7 @@ export const {
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useScheduleInspectionMutation,
useRescheduleInspectionMutation,
useGetInspectionsQuery,
useRecordInspectionResultMutation,
useGetNotificationsQuery,

View File

@@ -598,6 +598,49 @@ interface ConditionLike {
anyOf?: ConditionLike[];
}
/**
* Section keys a condition reads, recursing `anyOf`.
*
* `FieldCondition.field` is a `sectionKey.fieldKey` path, so the prefix names
* the section whose answer decides the condition.
*/
export function conditionSections(
condition: FieldCondition | undefined | null,
): string[] {
if (!condition) return [];
if (condition.anyOf) return condition.anyOf.flatMap(conditionSections);
if (!condition.field) return [];
const [sectionKey] = condition.field.split('.');
return sectionKey ? [sectionKey] : [];
}
/**
* Sections whose visibility hangs on an answer in one of `flagged`.
*
* Mirrors the server's `sectionsDependingOn`: an officer flagging the section
* that holds the vessel category is asking for an answer that decides which
* fields in other sections are required, so those sections have to open too —
* otherwise the applicant sees newly-required fields they cannot edit and
* cannot resubmit.
*/
export function sectionsDependingOn(
sections: FormSectionConfig[],
flagged: Set<string>,
): Set<string> {
const dependent = new Set<string>();
if (flagged.size === 0) return dependent;
for (const section of sections) {
if (flagged.has(section.key)) continue;
const reads = [
section.showWhen,
...(section.fields ?? []).map((field) => field.showWhen),
].flatMap(conditionSections);
if (reads.some((key) => flagged.has(key))) dependent.add(section.key);
}
return dependent;
}
export function conditionHolds(
condition: FieldCondition | undefined | null,
formData: Record<string, Record<string, unknown>>,