mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 21:08:13 +00:00
Refactor LicenseApplicationPage and RequireSeafarerProfile components
- 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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
@@ -22,23 +22,26 @@ import {
|
||||
IconFileText,
|
||||
IconRotate,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
} 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';
|
||||
} 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;
|
||||
@@ -58,6 +61,7 @@ export function DocumentsTab({
|
||||
applicationId,
|
||||
attachments,
|
||||
requirements,
|
||||
formData,
|
||||
flags,
|
||||
onToggleFlag,
|
||||
onFlagRemark,
|
||||
@@ -80,16 +84,16 @@ export function DocumentsTab({
|
||||
|
||||
async function decide(
|
||||
documentKey: string,
|
||||
decision: 'ACCEPTED' | 'REJECTED',
|
||||
decision: "ACCEPTED" | "REJECTED",
|
||||
attachmentId?: string,
|
||||
) {
|
||||
const reason = rejecting[documentKey]?.trim();
|
||||
if (decision === 'REJECTED' && !reason) {
|
||||
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: '',
|
||||
color: "red",
|
||||
title: t("review.documents.reasonRequired", "A reason is required"),
|
||||
message: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -98,7 +102,7 @@ export function DocumentsTab({
|
||||
id: applicationId,
|
||||
documentKey,
|
||||
decision,
|
||||
reason: decision === 'REJECTED' ? reason : undefined,
|
||||
reason: decision === "REJECTED" ? reason : undefined,
|
||||
attachmentId,
|
||||
}).unwrap();
|
||||
setRejecting((prev) => {
|
||||
@@ -108,24 +112,29 @@ export function DocumentsTab({
|
||||
});
|
||||
} catch {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('review.documents.saveFailed', 'Could not save the verdict'),
|
||||
message: '',
|
||||
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 !== 'OPTIONAL');
|
||||
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';
|
||||
const isImage = previewFile?.mimeType?.startsWith("image/");
|
||||
const isPdf = previewFile?.mimeType === "application/pdf";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -133,25 +142,30 @@ export function DocumentsTab({
|
||||
<Paper withBorder p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{t('review.documents.completeness', 'Required documents')}
|
||||
{t("review.documents.completeness", "Required documents")}
|
||||
</Text>
|
||||
<Text size="sm" c={missing.length ? 'orange' : 'teal'} fw={600}>
|
||||
<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', {
|
||||
color={missing.length ? "orange" : "teal"}
|
||||
aria-label={t("review.documents.completenessLabel", {
|
||||
value: completeness,
|
||||
defaultValue: '{{value}}% of required documents uploaded',
|
||||
defaultValue: "{{value}}% of required documents uploaded",
|
||||
})}
|
||||
/>
|
||||
{missing.length > 0 && (
|
||||
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
|
||||
<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(', ')}
|
||||
{t("review.documents.missing", "Not yet uploaded")}:{" "}
|
||||
{missing.map((r) => localized(r.name) || r.key).join(", ")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -174,45 +188,55 @@ export function DocumentsTab({
|
||||
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
|
||||
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}}',
|
||||
t("review.documents.reviewedBy", {
|
||||
name: verdict.reviewedByName ?? "—",
|
||||
defaultValue: "Reviewed by {{name}}",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
|
||||
color={
|
||||
verdict.decision === "ACCEPTED" ? "teal" : "red"
|
||||
}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
verdict.decision === 'ACCEPTED' ? (
|
||||
verdict.decision === "ACCEPTED" ? (
|
||||
<IconCheck size={11} />
|
||||
) : (
|
||||
<IconX size={11} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{verdict.decision === 'ACCEPTED'
|
||||
? t('review.documents.accepted', 'Accepted')
|
||||
: t('review.documents.rejected', 'Rejected')}
|
||||
{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')}
|
||||
{t("review.documents.flagged", "Correction requested")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file?.originalName ?? t('review.documents.noFile', 'No file')}
|
||||
{file?.originalName ??
|
||||
t("review.documents.noFile", "No file")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -221,8 +245,11 @@ export function DocumentsTab({
|
||||
<Tooltip
|
||||
label={
|
||||
file?.url
|
||||
? t('review.documents.preview', 'Preview')
|
||||
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
|
||||
? t("review.documents.preview", "Preview")
|
||||
: t(
|
||||
"review.documents.noFileUploaded",
|
||||
"Nothing uploaded yet",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>
|
||||
@@ -233,15 +260,18 @@ export function DocumentsTab({
|
||||
disabled={!file?.url}
|
||||
onClick={() => setPreview(attachment)}
|
||||
>
|
||||
{t('review.documents.view', 'View')}
|
||||
{t("review.documents.view", "View")}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
file?.url
|
||||
? t('review.documents.download', 'Download')
|
||||
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
|
||||
? t("review.documents.download", "Download")
|
||||
: t(
|
||||
"review.documents.noFileUploaded",
|
||||
"Nothing uploaded yet",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>
|
||||
@@ -253,7 +283,7 @@ export function DocumentsTab({
|
||||
download={file?.originalName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t('review.documents.download', 'Download')}
|
||||
aria-label={t("review.documents.download", "Download")}
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</ActionIcon>
|
||||
@@ -266,19 +296,28 @@ export function DocumentsTab({
|
||||
<Tooltip
|
||||
label={
|
||||
file?.url
|
||||
? t('review.documents.accept', 'Accept')
|
||||
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
|
||||
? t("review.documents.accept", "Accept")
|
||||
: t(
|
||||
"review.documents.nothingToJudge",
|
||||
"Nothing uploaded to judge",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<ActionIcon
|
||||
variant={verdict?.decision === 'ACCEPTED' ? 'filled' : 'light'}
|
||||
variant={
|
||||
verdict?.decision === "ACCEPTED" ? "filled" : "light"
|
||||
}
|
||||
color="teal"
|
||||
loading={saving}
|
||||
disabled={!file?.url}
|
||||
aria-label={t('review.documents.accept', 'Accept')}
|
||||
aria-label={t("review.documents.accept", "Accept")}
|
||||
onClick={() =>
|
||||
decide(attachment.documentKey, 'ACCEPTED', attachment.id)
|
||||
decide(
|
||||
attachment.documentKey,
|
||||
"ACCEPTED",
|
||||
attachment.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconCheck size={16} />
|
||||
@@ -288,20 +327,25 @@ export function DocumentsTab({
|
||||
<Tooltip
|
||||
label={
|
||||
file?.url
|
||||
? t('review.documents.reject', 'Reject')
|
||||
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
|
||||
? t("review.documents.reject", "Reject")
|
||||
: t(
|
||||
"review.documents.nothingToJudge",
|
||||
"Nothing uploaded to judge",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<ActionIcon
|
||||
variant={verdict?.decision === 'REJECTED' ? 'filled' : 'light'}
|
||||
variant={
|
||||
verdict?.decision === "REJECTED" ? "filled" : "light"
|
||||
}
|
||||
color="red"
|
||||
disabled={!file?.url}
|
||||
aria-label={t('review.documents.reject', 'Reject')}
|
||||
aria-label={t("review.documents.reject", "Reject")}
|
||||
onClick={() =>
|
||||
setRejecting((prev) => ({
|
||||
...prev,
|
||||
[attachment.documentKey]: verdict?.reason ?? '',
|
||||
[attachment.documentKey]: verdict?.reason ?? "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -310,11 +354,11 @@ export function DocumentsTab({
|
||||
</span>
|
||||
</Tooltip>
|
||||
{verdict && (
|
||||
<Tooltip label={t('review.documents.clear', 'Clear verdict')}>
|
||||
<Tooltip label={t("review.documents.clear", "Clear verdict")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t('review.documents.clear', 'Clear verdict')}
|
||||
aria-label={t("review.documents.clear", "Clear verdict")}
|
||||
onClick={() =>
|
||||
clearReview({
|
||||
id: applicationId,
|
||||
@@ -330,7 +374,7 @@ export function DocumentsTab({
|
||||
size="xs"
|
||||
checked={flagged}
|
||||
onChange={() => onToggleFlag(attachment.documentKey)}
|
||||
label={t('review.documents.includeInAdjustment', 'Send back')}
|
||||
label={t("review.documents.includeInAdjustment", "Send back")}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -342,8 +386,8 @@ export function DocumentsTab({
|
||||
size="xs"
|
||||
autoFocus
|
||||
placeholder={t(
|
||||
'review.documents.rejectReason',
|
||||
'Why must this document be corrected?',
|
||||
"review.documents.rejectReason",
|
||||
"Why must this document be corrected?",
|
||||
)}
|
||||
value={rejecting[attachment.documentKey]}
|
||||
onChange={(e) => {
|
||||
@@ -363,10 +407,10 @@ export function DocumentsTab({
|
||||
loading={saving}
|
||||
disabled={!rejecting[attachment.documentKey]?.trim()}
|
||||
onClick={() =>
|
||||
decide(attachment.documentKey, 'REJECTED', attachment.id)
|
||||
decide(attachment.documentKey, "REJECTED", attachment.id)
|
||||
}
|
||||
>
|
||||
{t('review.documents.confirmReject', 'Reject')}
|
||||
{t("review.documents.confirmReject", "Reject")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
@@ -376,15 +420,20 @@ export function DocumentsTab({
|
||||
mt="sm"
|
||||
size="xs"
|
||||
placeholder={t(
|
||||
'review.documents.adjustmentNote',
|
||||
'What must the applicant correct?',
|
||||
"review.documents.adjustmentNote",
|
||||
"What must the applicant correct?",
|
||||
)}
|
||||
value={flags[attachment.documentKey]}
|
||||
onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)}
|
||||
onChange={(e) =>
|
||||
onFlagRemark(attachment.documentKey, e.currentTarget.value)
|
||||
}
|
||||
error={
|
||||
flags[attachment.documentKey].trim()
|
||||
? undefined
|
||||
: t('review.documents.reasonRequired', 'A reason is required')
|
||||
: t(
|
||||
"review.documents.reasonRequired",
|
||||
"A reason is required",
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -399,8 +448,9 @@ export function DocumentsTab({
|
||||
size="xl"
|
||||
title={
|
||||
preview
|
||||
? localized(requirementByKey.get(preview.documentKey)?.name) || preview.documentKey
|
||||
: ''
|
||||
? 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.
|
||||
@@ -411,22 +461,28 @@ export function DocumentsTab({
|
||||
isPdf ? (
|
||||
<iframe
|
||||
src={previewFile.url}
|
||||
title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
|
||||
style={{ width: '100%', height: '80vh', border: 'none' }}
|
||||
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%' }}
|
||||
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.',
|
||||
"review.documents.noInlinePreview",
|
||||
"This file type cannot be previewed in the browser.",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
@@ -436,7 +492,7 @@ export function DocumentsTab({
|
||||
rel="noreferrer"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
>
|
||||
{t('review.documents.downloadShort', 'Download')}
|
||||
{t("review.documents.downloadShort", "Download")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
@@ -106,7 +106,9 @@ export function LicenseQueuePage() {
|
||||
const density = useAppSelector((state) => state.preferences.density);
|
||||
|
||||
const [view, setView] = useState<SavedViewId>(
|
||||
() => (searchParams.get("view") as SavedViewId) || readLastView(),
|
||||
() =>
|
||||
(searchParams.get("view") as SavedViewId) ||
|
||||
(typeCode === "BTC_BASIC_TRAINING" ? "all" : readLastView()),
|
||||
);
|
||||
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||
@@ -116,6 +118,19 @@ export function LicenseQueuePage() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
// Auto-created BTC requests start at PAYMENT_PENDING, which is not part of
|
||||
// the unassigned officer work pool. A dedicated BTC Queue must therefore
|
||||
// open its all-status view so those requests are visible immediately.
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeCode === "BTC_BASIC_TRAINING" &&
|
||||
!searchParams.has("view") &&
|
||||
view !== "all"
|
||||
) {
|
||||
setView("all");
|
||||
}
|
||||
}, [typeCode, searchParams, view]);
|
||||
|
||||
const urlFilter = useMemo(
|
||||
() => filterFromSearchParams(searchParams),
|
||||
[searchParams],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user