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

This commit is contained in:
nati14575
2026-08-28 14:39:02 +03:00
18 changed files with 581 additions and 92 deletions

View File

@@ -20,6 +20,7 @@ import {
extractErrorMessage,
openAuthedDocument,
useEnrollBiometricMutation,
useGenerateBsidMutation,
useGetBiometricEnrollmentsQuery,
useGetBiometricSimulateCapabilitiesQuery,
useListSeafarerRegistrationsQuery,
@@ -108,6 +109,11 @@ export function BiometricEnrollmentPage() {
// ALLOW_BIOMETRIC_SIMULATION=true, same shortcut the payment bypass uses.
const { data: capabilities } = useGetBiometricSimulateCapabilitiesQuery();
const simulateEnabled = capabilities?.simulateEnabled ?? false;
const [generateBsid, { isLoading: generatingBsid }] = useGenerateBsidMutation();
// Seeded from the seafarer registration list (which does not carry BSID
// yet) and updated locally once generated — this screen's only source of
// truth for it until the registry surfaces the profile's BSID directly.
const [bsid, setBsid] = useState<string | null>(null);
const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation();
const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation();
const [printing, setPrinting] = useState(false);
@@ -134,6 +140,17 @@ export function BiometricEnrollmentPage() {
}
}
async function handleGenerateBsid() {
if (!profileId) return;
try {
const result = await generateBsid(profileId).unwrap();
setBsid(result.bsid);
notify.success(`BSID ${result.bsid} generated.`);
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not generate BSID.'));
}
}
async function handleRevoke(id: string) {
if (!profileId) return;
try {
@@ -167,7 +184,12 @@ export function BiometricEnrollmentPage() {
/>
{!selected ? (
<ProfilePicker onPick={setSelected} />
<ProfilePicker
onPick={(r) => {
setSelected(r);
setBsid(null);
}}
/>
) : (
<Stack gap="md">
<Card withBorder radius="md" p="md">
@@ -176,7 +198,15 @@ export function BiometricEnrollmentPage() {
<Text fw={600}>{applicantName(selected)}</Text>
<Text fz="xs" c="dimmed" ff="monospace">{selected.seafarerNumber}</Text>
</div>
<Button variant="subtle" size="xs" leftSection={<IconX size={14} />} onClick={() => setSelected(null)}>
<Button
variant="subtle"
size="xs"
leftSection={<IconX size={14} />}
onClick={() => {
setSelected(null);
setBsid(null);
}}
>
Change seafarer
</Button>
</Group>
@@ -204,6 +234,31 @@ export function BiometricEnrollmentPage() {
)}
</Card>
<Card withBorder radius="md" p="md">
<Text fz="sm" fw={600} mb="sm">Biometric Subject ID (BSID)</Text>
<Text fz="xs" c="dimmed" mb="sm">
Required before this registration can be approved. Generating it is final
confirm the capture is good first.
</Text>
<Group justify="space-between">
{bsid ? (
<StatusBadge tone="success" label={`BSID ${bsid}`} />
) : (
<Badge color="gray" variant="light">Not generated</Badge>
)}
{!bsid && (
<Button
size="xs"
onClick={handleGenerateBsid}
loading={generatingBsid}
disabled={!hasActive('FINGERPRINT') && !hasActive('FACE')}
>
Generate BSID
</Button>
)}
</Group>
</Card>
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Text fz="sm" fw={600}>On file</Text>

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

@@ -870,6 +870,7 @@ export const am: Translations = {
DRAFT: "ረቂቅ",
SUBMITTED: "ቀርቧል",
UNDER_REVIEW: "በግምገማ ላይ",
AWAITING_BIOMETRICS: "ባዮሜትሪክ በመጠባበቅ ላይ",
UNDER_EVALUATION: "በምዘና ላይ",
RESUBMIT_REQUIRED: "እንደገና ማቅረብ ያስፈልጋል",
INSPECTION_PENDING: "ምርመራ በመጠባበቅ ላይ",
@@ -989,6 +990,9 @@ export const am: Translations = {
morning: "ጠዋት",
afternoon: "ከሰዓት በኋላ",
schedule: "ያዝ",
reschedule: "አዛውር",
rescheduleReason: "ለምን ይዛወራል?",
rescheduleReasonHint: "በኦዲት መዝገብ ውስጥ ተይዞ ለአመልካቹ ይላካል።",
pickDate: "መጀመሪያ ቀን ይምረጡ",
passed: "አልፏል",
failed: "ወድቋል",
@@ -1025,6 +1029,7 @@ export const am: Translations = {
completeReview: "ግምገማ አጠናቅቅ",
approveDocuments: "ሰነዶችን አጽድቅ",
scheduleInspection: "ምርመራ ያዝ",
rescheduleInspection: "ምርመራ አዛውር",
recordInspection: "የምርመራ ውጤት መዝግብ",
finalApprove: "አጽድቅ እና ስጥ",
requestAdjustment: "ማስተካከያ ጠይቅ",
@@ -1178,6 +1183,7 @@ export const am: Translations = {
assign: "እንደገና ተመድቧል",
assignReviewer: "ግምገማ ተመድቧል",
scheduled: "ምርመራ ተይዟል",
rescheduled: "ምርመራ ተዛውሯል",
inspectionPassed: "ምርመራ አልፏል",
inspectionFailed: "ምርመራ ወድቋል",
},

View File

@@ -877,6 +877,7 @@ export const en = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
@@ -998,6 +999,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',
@@ -1034,6 +1038,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',
@@ -1184,6 +1189,7 @@ export const en = {
assign: 'Reassigned',
assignReviewer: 'Review assigned',
scheduled: 'Inspection scheduled',
rescheduled: 'Inspection rescheduled',
inspectionPassed: 'Inspection passed',
inspectionFailed: 'Inspection failed',
},