diff --git a/apps/backoffice/src/app/features/license-review/components/ApplicantCard.tsx b/apps/backoffice/src/app/features/license-review/components/ApplicantCard.tsx new file mode 100644 index 000000000..018dac7fe --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/components/ApplicantCard.tsx @@ -0,0 +1,108 @@ +import { Avatar, Badge, Group, Paper, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import type { ApplicationApplicant } from '@ema-platform/api'; +import { useDateDisplayer } from '@ema-platform/shared'; + +interface ApplicantCardProps { + applicant: ApplicationApplicant; +} + +/** + * Who the reviewer is deciding about. + * + * A company licence names itself in the page title (`companyName`); a seafarer + * registration has no company, so the officer's screen led with an application + * number and the human behind it was somewhere in the form answers. This puts + * the identity where it belongs on a person-centric review: name, national ID, + * contact, and — for a seafarer who already holds one — their number and + * standing, which is what says whether this is a first registration or a + * duplicate. + * + * Read-only and sourced from the profile, not the form: this is the record the + * registration will be written onto, so a reviewer comparing the two is exactly + * the intended use. + */ +export function ApplicantCard({ applicant }: ApplicantCardProps) { + const { t } = useTranslation(); + const showDate = useDateDisplayer(); + + const fullName = [applicant.firstName, applicant.middleName, applicant.lastName] + .filter(Boolean) + .join(' '); + const initials = [applicant.firstName, applicant.lastName] + .filter(Boolean) + .map((part) => part?.[0]?.toUpperCase() ?? '') + .join(''); + + return ( + + + + {initials || '—'} + +
+ + {fullName || t('review.nameMissing', 'Name not on profile')} + + {applicant.seafarerNumber ? ( + + + {applicant.seafarerNumber} + + {applicant.seafarerStatus && ( + + {applicant.seafarerStatus} + + )} + + ) : ( + + {t('review.notYetRegistered', 'Not yet registered')} + + )} +
+
+ + + + + + + + + +
+ ); +} + +/** One label/value line, omitted entirely when there is nothing to show. */ +function Row({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( + + + {label} + + + {value} + + + ); +} diff --git a/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx b/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx index f2f757abd..979ab44a8 100644 --- a/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx +++ b/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx @@ -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>; /** documentKey -> remark. Owned by the review page. */ flags: Record; 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 ( @@ -133,25 +142,30 @@ export function DocumentsTab({ - {t('review.documents.completeness', 'Required documents')} + {t("review.documents.completeness", "Required documents")} - + {mandatory.length - missing.length}/{mandatory.length} {missing.length > 0 && ( - } variant="light"> + } + variant="light" + > - {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(", ")} )} @@ -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. */} - - {localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey} + + {localized( + requirementByKey.get(attachment.documentKey)?.name, + ) || attachment.documentKey} {verdict && ( ) : ( ) } > - {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")} )} {flagged && ( - {t('review.documents.flagged', 'Correction requested')} + {t("review.documents.flagged", "Correction requested")} )} - {file?.originalName ?? t('review.documents.noFile', 'No file')} + {file?.originalName ?? + t("review.documents.noFile", "No file")} @@ -221,8 +245,11 @@ export function DocumentsTab({ @@ -233,15 +260,18 @@ export function DocumentsTab({ disabled={!file?.url} onClick={() => setPreview(attachment)} > - {t('review.documents.view', 'View')} + {t("review.documents.view", "View")} @@ -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")} > @@ -266,19 +296,28 @@ export function DocumentsTab({ - decide(attachment.documentKey, 'ACCEPTED', attachment.id) + decide( + attachment.documentKey, + "ACCEPTED", + attachment.id, + ) } > @@ -288,20 +327,25 @@ export function DocumentsTab({ setRejecting((prev) => ({ ...prev, - [attachment.documentKey]: verdict?.reason ?? '', + [attachment.documentKey]: verdict?.reason ?? "", })) } > @@ -310,11 +354,11 @@ export function DocumentsTab({ {verdict && ( - + 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")} /> @@ -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")} )} @@ -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 ? (