mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 19:28:18 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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: "ምርመራ ወድቋል",
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user