mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 17:38:14 +00:00
- Updated imports for better readability in LicenseApplicationPage. - Introduced legacy profile sources for backward compatibility in LicenseApplicationPage. - Enhanced user profile handling by utilizing accountUser in LicenseApplicationPage. - Improved application state management in LicenseApplicationPage to include accountUser. - Refactored RequireSeafarerProfile to check for existing applications and handle navigation accordingly. - Cleaned up navigation items in PortalLayout for better readability and maintainability.
504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Checkbox,
|
|
Drawer,
|
|
Group,
|
|
Paper,
|
|
Progress,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import {
|
|
IconAlertCircle,
|
|
IconCheck,
|
|
IconDownload,
|
|
IconEye,
|
|
IconFileText,
|
|
IconRotate,
|
|
IconX,
|
|
} from "@tabler/icons-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
conditionHolds,
|
|
useClearDocumentReviewMutation,
|
|
useGetDocumentReviewsQuery,
|
|
useLocalized,
|
|
useReviewDocumentMutation,
|
|
type Attachment,
|
|
type DocumentRequirement,
|
|
} from "@ema-platform/api";
|
|
import { notifications } from "@mantine/notifications";
|
|
|
|
interface DocumentsTabProps {
|
|
applicationId: string;
|
|
attachments: Attachment[];
|
|
/** From the licence type config, so completeness is measured against rules. */
|
|
requirements: DocumentRequirement[];
|
|
/** Applicant answers used to evaluate conditional document requirements. */
|
|
formData: Record<string, Record<string, unknown>>;
|
|
/** documentKey -> remark. Owned by the review page. */
|
|
flags: Record<string, string>;
|
|
onToggleFlag: (documentKey: string) => void;
|
|
onFlagRemark: (documentKey: string, remark: string) => void;
|
|
}
|
|
|
|
/**
|
|
* The reviewer's document workspace.
|
|
*
|
|
* Previously a list with View and Download buttons that had no handlers at
|
|
* all — the officer could see that a document existed but not what was in it,
|
|
* which makes "approve documents" an act of faith. This previews inline,
|
|
* measures what is uploaded against what the licence type requires, and lets
|
|
* each document be flagged with its own reason.
|
|
*/
|
|
export function DocumentsTab({
|
|
applicationId,
|
|
attachments,
|
|
requirements,
|
|
formData,
|
|
flags,
|
|
onToggleFlag,
|
|
onFlagRemark,
|
|
}: DocumentsTabProps) {
|
|
const { t } = useTranslation();
|
|
const localized = useLocalized();
|
|
const [preview, setPreview] = useState<Attachment | null>(null);
|
|
const [rejecting, setRejecting] = useState<Record<string, string>>({});
|
|
|
|
// Verdicts are persisted per document, so an accept survives a reload and
|
|
// is visible to whoever picks the application up next.
|
|
const { data: reviews = [] } = useGetDocumentReviewsQuery(applicationId, {
|
|
skip: !applicationId,
|
|
});
|
|
const [reviewDocument, { isLoading: saving }] = useReviewDocumentMutation();
|
|
const [clearReview] = useClearDocumentReviewMutation();
|
|
|
|
const verdictFor = (documentKey: string) =>
|
|
reviews.find((review) => review.documentKey === documentKey);
|
|
|
|
async function decide(
|
|
documentKey: string,
|
|
decision: "ACCEPTED" | "REJECTED",
|
|
attachmentId?: string,
|
|
) {
|
|
const reason = rejecting[documentKey]?.trim();
|
|
if (decision === "REJECTED" && !reason) {
|
|
// The applicant is shown this verbatim, so refuse to send an empty one.
|
|
notifications.show({
|
|
color: "red",
|
|
title: t("review.documents.reasonRequired", "A reason is required"),
|
|
message: "",
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
await reviewDocument({
|
|
id: applicationId,
|
|
documentKey,
|
|
decision,
|
|
reason: decision === "REJECTED" ? reason : undefined,
|
|
attachmentId,
|
|
}).unwrap();
|
|
setRejecting((prev) => {
|
|
const next = { ...prev };
|
|
delete next[documentKey];
|
|
return next;
|
|
});
|
|
} catch {
|
|
notifications.show({
|
|
color: "red",
|
|
title: t("review.documents.saveFailed", "Could not save the verdict"),
|
|
message: "",
|
|
});
|
|
}
|
|
}
|
|
|
|
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
|
|
const requirementByKey = new Map(requirements.map((r) => [r.key, r]));
|
|
const mandatory = requirements.filter(
|
|
(r) =>
|
|
r.mode === "ALWAYS" ||
|
|
(r.mode === "CONDITIONAL" &&
|
|
conditionHolds(r.conditionExpression, formData)),
|
|
);
|
|
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
|
|
const completeness = mandatory.length
|
|
? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100)
|
|
: 100;
|
|
|
|
const previewFile = preview?.files?.[0];
|
|
const isImage = previewFile?.mimeType?.startsWith("image/");
|
|
const isPdf = previewFile?.mimeType === "application/pdf";
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
{/* Completeness against the licence type's own requirement list. */}
|
|
<Paper withBorder p="md">
|
|
<Group justify="space-between" mb="xs">
|
|
<Text fw={600} size="sm">
|
|
{t("review.documents.completeness", "Required documents")}
|
|
</Text>
|
|
<Text size="sm" c={missing.length ? "orange" : "teal"} fw={600}>
|
|
{mandatory.length - missing.length}/{mandatory.length}
|
|
</Text>
|
|
</Group>
|
|
<Progress
|
|
value={completeness}
|
|
color={missing.length ? "orange" : "teal"}
|
|
aria-label={t("review.documents.completenessLabel", {
|
|
value: completeness,
|
|
defaultValue: "{{value}}% of required documents uploaded",
|
|
})}
|
|
/>
|
|
{missing.length > 0 && (
|
|
<Alert
|
|
mt="sm"
|
|
color="orange"
|
|
icon={<IconAlertCircle size={16} />}
|
|
variant="light"
|
|
>
|
|
<Text size="sm">
|
|
{t("review.documents.missing", "Not yet uploaded")}:{" "}
|
|
{missing.map((r) => localized(r.name) || r.key).join(", ")}
|
|
</Text>
|
|
</Alert>
|
|
)}
|
|
</Paper>
|
|
|
|
{attachments.map((attachment) => {
|
|
const file = attachment.files?.[0];
|
|
const flagged = attachment.documentKey in flags;
|
|
const verdict = verdictFor(attachment.documentKey);
|
|
const pendingReject = attachment.documentKey in rejecting;
|
|
return (
|
|
<Paper withBorder p="md" key={attachment.id}>
|
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
|
|
<IconFileText size={20} stroke={1.6} />
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
{/* Badges sit beside the name, not in the outer nowrap row —
|
|
that row also has to fit six action buttons, so a long
|
|
name plus badges there overflowed its box and the
|
|
opaque badge painted over the bleeding text. Nested here
|
|
with its own wrap, the name truncates cleanly instead. */}
|
|
<Group gap={6} wrap="wrap" align="center">
|
|
<Text
|
|
size="sm"
|
|
fw={500}
|
|
truncate
|
|
style={{ maxWidth: "100%" }}
|
|
>
|
|
{localized(
|
|
requirementByKey.get(attachment.documentKey)?.name,
|
|
) || attachment.documentKey}
|
|
</Text>
|
|
{verdict && (
|
|
<Tooltip
|
|
label={
|
|
verdict.reason ??
|
|
t("review.documents.reviewedBy", {
|
|
name: verdict.reviewedByName ?? "—",
|
|
defaultValue: "Reviewed by {{name}}",
|
|
})
|
|
}
|
|
>
|
|
<Badge
|
|
color={
|
|
verdict.decision === "ACCEPTED" ? "teal" : "red"
|
|
}
|
|
variant="light"
|
|
size="sm"
|
|
leftSection={
|
|
verdict.decision === "ACCEPTED" ? (
|
|
<IconCheck size={11} />
|
|
) : (
|
|
<IconX size={11} />
|
|
)
|
|
}
|
|
>
|
|
{verdict.decision === "ACCEPTED"
|
|
? t("review.documents.accepted", "Accepted")
|
|
: t("review.documents.rejected", "Rejected")}
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
{flagged && (
|
|
<Badge color="orange" variant="light" size="sm">
|
|
{t("review.documents.flagged", "Correction requested")}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
<Text size="xs" c="dimmed" truncate>
|
|
{file?.originalName ??
|
|
t("review.documents.noFile", "No file")}
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
|
|
<Group gap="xs" wrap="nowrap">
|
|
<Tooltip
|
|
label={
|
|
file?.url
|
|
? t("review.documents.preview", "Preview")
|
|
: t(
|
|
"review.documents.noFileUploaded",
|
|
"Nothing uploaded yet",
|
|
)
|
|
}
|
|
>
|
|
<span>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
leftSection={<IconEye size={14} />}
|
|
disabled={!file?.url}
|
|
onClick={() => setPreview(attachment)}
|
|
>
|
|
{t("review.documents.view", "View")}
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
<Tooltip
|
|
label={
|
|
file?.url
|
|
? t("review.documents.download", "Download")
|
|
: t(
|
|
"review.documents.noFileUploaded",
|
|
"Nothing uploaded yet",
|
|
)
|
|
}
|
|
>
|
|
<span>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
disabled={!file?.url}
|
|
component="a"
|
|
href={file?.url}
|
|
download={file?.originalName}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
aria-label={t("review.documents.download", "Download")}
|
|
>
|
|
<IconDownload size={16} />
|
|
</ActionIcon>
|
|
</span>
|
|
</Tooltip>
|
|
|
|
{/* Accept / Reject are the officer's own record of having
|
|
checked the file, persisted independently of any
|
|
adjustment round. */}
|
|
<Tooltip
|
|
label={
|
|
file?.url
|
|
? t("review.documents.accept", "Accept")
|
|
: t(
|
|
"review.documents.nothingToJudge",
|
|
"Nothing uploaded to judge",
|
|
)
|
|
}
|
|
>
|
|
<span>
|
|
<ActionIcon
|
|
variant={
|
|
verdict?.decision === "ACCEPTED" ? "filled" : "light"
|
|
}
|
|
color="teal"
|
|
loading={saving}
|
|
disabled={!file?.url}
|
|
aria-label={t("review.documents.accept", "Accept")}
|
|
onClick={() =>
|
|
decide(
|
|
attachment.documentKey,
|
|
"ACCEPTED",
|
|
attachment.id,
|
|
)
|
|
}
|
|
>
|
|
<IconCheck size={16} />
|
|
</ActionIcon>
|
|
</span>
|
|
</Tooltip>
|
|
<Tooltip
|
|
label={
|
|
file?.url
|
|
? t("review.documents.reject", "Reject")
|
|
: t(
|
|
"review.documents.nothingToJudge",
|
|
"Nothing uploaded to judge",
|
|
)
|
|
}
|
|
>
|
|
<span>
|
|
<ActionIcon
|
|
variant={
|
|
verdict?.decision === "REJECTED" ? "filled" : "light"
|
|
}
|
|
color="red"
|
|
disabled={!file?.url}
|
|
aria-label={t("review.documents.reject", "Reject")}
|
|
onClick={() =>
|
|
setRejecting((prev) => ({
|
|
...prev,
|
|
[attachment.documentKey]: verdict?.reason ?? "",
|
|
}))
|
|
}
|
|
>
|
|
<IconX size={16} />
|
|
</ActionIcon>
|
|
</span>
|
|
</Tooltip>
|
|
{verdict && (
|
|
<Tooltip label={t("review.documents.clear", "Clear verdict")}>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
aria-label={t("review.documents.clear", "Clear verdict")}
|
|
onClick={() =>
|
|
clearReview({
|
|
id: applicationId,
|
|
documentKey: attachment.documentKey,
|
|
})
|
|
}
|
|
>
|
|
<IconRotate size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
<Checkbox
|
|
size="xs"
|
|
checked={flagged}
|
|
onChange={() => onToggleFlag(attachment.documentKey)}
|
|
label={t("review.documents.includeInAdjustment", "Send back")}
|
|
/>
|
|
</Group>
|
|
</Group>
|
|
|
|
{pendingReject && (
|
|
<Group mt="sm" gap="xs" align="flex-start" wrap="nowrap">
|
|
<TextInput
|
|
style={{ flex: 1 }}
|
|
size="xs"
|
|
autoFocus
|
|
placeholder={t(
|
|
"review.documents.rejectReason",
|
|
"Why must this document be corrected?",
|
|
)}
|
|
value={rejecting[attachment.documentKey]}
|
|
onChange={(e) => {
|
|
// Read before the updater: React nulls `currentTarget`
|
|
// once the handler returns, and the updater runs later,
|
|
// during the re-render.
|
|
const reason = e.currentTarget.value;
|
|
setRejecting((prev) => ({
|
|
...prev,
|
|
[attachment.documentKey]: reason,
|
|
}));
|
|
}}
|
|
/>
|
|
<Button
|
|
size="compact-sm"
|
|
color="red"
|
|
loading={saving}
|
|
disabled={!rejecting[attachment.documentKey]?.trim()}
|
|
onClick={() =>
|
|
decide(attachment.documentKey, "REJECTED", attachment.id)
|
|
}
|
|
>
|
|
{t("review.documents.confirmReject", "Reject")}
|
|
</Button>
|
|
</Group>
|
|
)}
|
|
|
|
{flagged && (
|
|
<TextInput
|
|
mt="sm"
|
|
size="xs"
|
|
placeholder={t(
|
|
"review.documents.adjustmentNote",
|
|
"What must the applicant correct?",
|
|
)}
|
|
value={flags[attachment.documentKey]}
|
|
onChange={(e) =>
|
|
onFlagRemark(attachment.documentKey, e.currentTarget.value)
|
|
}
|
|
error={
|
|
flags[attachment.documentKey].trim()
|
|
? undefined
|
|
: t(
|
|
"review.documents.reasonRequired",
|
|
"A reason is required",
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</Paper>
|
|
);
|
|
})}
|
|
|
|
<Drawer
|
|
opened={Boolean(preview)}
|
|
onClose={() => setPreview(null)}
|
|
position="right"
|
|
size="xl"
|
|
title={
|
|
preview
|
|
? localized(requirementByKey.get(preview.documentKey)?.name) ||
|
|
preview.documentKey
|
|
: ""
|
|
}
|
|
// Focus is trapped and returned so keyboard users are not dropped at
|
|
// the top of the page when the drawer closes.
|
|
trapFocus
|
|
returnFocus
|
|
>
|
|
{previewFile?.url ? (
|
|
isPdf ? (
|
|
<iframe
|
|
src={previewFile.url}
|
|
title={
|
|
preview?.documentKey ??
|
|
t("review.documents.previewFallback", "document")
|
|
}
|
|
style={{ width: "100%", height: "80vh", border: "none" }}
|
|
/>
|
|
) : isImage ? (
|
|
<img
|
|
src={previewFile.url}
|
|
alt={
|
|
preview?.documentKey ??
|
|
t("review.documents.previewFallback", "document")
|
|
}
|
|
style={{ maxWidth: "100%" }}
|
|
/>
|
|
) : (
|
|
// Anything the browser will not render inline still gets a way out.
|
|
<Stack align="center" gap="sm" py="xl">
|
|
<Text size="sm" c="dimmed">
|
|
{t(
|
|
"review.documents.noInlinePreview",
|
|
"This file type cannot be previewed in the browser.",
|
|
)}
|
|
</Text>
|
|
<Button
|
|
component="a"
|
|
href={previewFile.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
leftSection={<IconDownload size={16} />}
|
|
>
|
|
{t("review.documents.downloadShort", "Download")}
|
|
</Button>
|
|
</Stack>
|
|
)
|
|
) : null}
|
|
</Drawer>
|
|
</Stack>
|
|
);
|
|
}
|