diff --git a/apps/backoffice/src/app/features/license-review/components/DecisionBar.tsx b/apps/backoffice/src/app/features/license-review/components/DecisionBar.tsx index 0403cfaad..2658e78a8 100644 --- a/apps/backoffice/src/app/features/license-review/components/DecisionBar.tsx +++ b/apps/backoffice/src/app/features/license-review/components/DecisionBar.tsx @@ -76,9 +76,13 @@ export function DecisionBar({ role="region" aria-label={t('review.decisionBar', 'Decision bar')} > - + {/* Wraps rather than overflows: at narrow widths the nowrap row pushed + the workflow buttons past the viewport edge, so Assign, Escalate and + Hold were simply not there. Wrapping drops them onto a second line + instead of off the screen. */} + {/* Left: where the application stands, and who has it. */} - + {t(`queue.statusValues.${status}`, STATUS_LABELS[status])} @@ -124,7 +128,7 @@ export function DecisionBar({ {/* Right: the decision. */} - + {primary.map((action) => ( onAction(action)} > {t(action.labelKey)} diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index 4c02b4988..8283ca487 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -253,11 +253,18 @@ export interface ResolveContext { needsFlags: string; needsCapital: string; needsInspection: string; + needsDocumentReviews: string; }; /** Number of sections/documents the officer has flagged for correction. */ flaggedCount: number; /** True when an inspection is scheduled and awaiting a result. */ hasPendingInspection: boolean; + /** + * False while any uploaded document is still unjudged or rejected. Approving + * is a statement that every document was checked, so the button stays dead + * until the officer has actually judged each one. + */ + allDocumentsAccepted: boolean; } /** @@ -300,6 +307,13 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] { return disabled(reasons.notAssigned); } + if ( + (action.id === 'approve-documents' || action.id === 'final-approve') && + !ctx.allDocumentsAccepted + ) { + return disabled(reasons.needsDocumentReviews); + } + if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) { return disabled(reasons.needsFlags); } diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index 87b62ca14..7ad62f792 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -49,6 +49,7 @@ import { useFinalApproveMutation, useGetApplicationForReviewQuery, useGetAttachmentsQuery, + useGetDocumentReviewsQuery, useGetInspectionsQuery, useGetAssignableOfficersQuery, useGetLicenseTypeRequirementsQuery, @@ -162,6 +163,9 @@ export function LicenseReviewPage() { // Real officer list, so Assign and Escalate name a person instead of // silently reassigning to whoever already held the application. const { data: officers = [] } = useGetAssignableOfficersQuery(); + // Same cached query the Documents tab reads, so the decision bar reacts the + // moment a verdict is saved. + const { data: documentReviews = [] } = useGetDocumentReviewsQuery(id, { skip: !id }); const staffTable = useServerTable(); const [flags, setFlags] = useState({}); @@ -208,6 +212,19 @@ export function LicenseReviewPage() { }, [flags]); const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED'); + + // Approving means every uploaded document was accepted — one unjudged or + // rejected file is enough to keep the decision buttons dead. + const allDocumentsAccepted = useMemo(() => { + const attachments = data?.attachments ?? []; + if (attachments.length === 0) return false; + const accepted = new Set( + documentReviews + .filter((review) => review.decision === 'ACCEPTED') + .map((review) => review.documentKey), + ); + return attachments.every((a) => accepted.has(a.documentKey)); + }, [data?.attachments, documentReviews]); const flagged = Object.entries(flags); /** @@ -247,6 +264,7 @@ export function LicenseReviewPage() { can, flaggedCount: flagged.length, hasPendingInspection: Boolean(pendingInspection), + allDocumentsAccepted, reasons: { wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'), notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'), @@ -254,9 +272,13 @@ export function LicenseReviewPage() { needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'), needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'), needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'), + needsDocumentReviews: t( + 'review.disabled.needsDocumentReviews', + 'Accept every document first', + ), }, }); - }, [data, currentUserId, can, flagged.length, pendingInspection, t]); + }, [data, currentUserId, can, flagged.length, pendingInspection, allDocumentsAccepted, t]); if (isLoading) { // Skeleton mirrors the real three-zone layout so nothing jumps on load. diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 8a7a03e38..8303efad3 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -949,6 +949,7 @@ export const am: Translations = { needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ", needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ", needsInspection: "የምርመራ ውጤት ያስፈልጋል", + needsDocumentReviews: "መጀመሪያ ሁሉንም ሰነዶች ተቀበል", }, reasons: { incompleteDocuments: "ያልተሟሉ ሰነዶች", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 45ffd001d..e93785a67 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -950,6 +950,7 @@ export const en = { needsFlags: 'Flag at least one item to request a correction', needsCapital: 'Record the verified capital first', needsInspection: 'Requires an inspection result', + needsDocumentReviews: 'Accept every document first', }, reasons: { incompleteDocuments: 'Incomplete documents', diff --git a/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx b/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx index 421af4c35..766a8840e 100644 --- a/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx +++ b/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx @@ -5,6 +5,16 @@ import type { TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; import { AmharicDatePicker } from '@ema-platform/ui'; +// dob comes in as a plain yyyy-MM-dd string; skip on empty/malformed so +// dobRequired's own message takes precedence. +function isAtLeast18(dob: string): boolean { + const birth = new Date(dob); + if (Number.isNaN(birth.getTime())) return true; + const today = new Date(); + const cutoff = new Date(today.getFullYear() - 18, today.getMonth(), today.getDate()); + return birth <= cutoff; +} + export const profileSchema = (t: TFunction) => z.object({ professionId: z.string().min(1, t('profileForm.validation.professionRequired')), @@ -12,7 +22,12 @@ export const profileSchema = (t: TFunction) => middleName: z.string().min(3, t('profileForm.validation.middleNameMin')), lastName: z.string().min(3, t('profileForm.validation.lastNameMin')), gender: z.string().min(1, t('profileForm.validation.genderRequired')), - dob: z.string().min(1, t('profileForm.validation.dobRequired')), + dob: z + .string() + .min(1, t('profileForm.validation.dobRequired')) + .refine((value) => isAtLeast18(value), { + message: t('profileForm.validation.dobMinAge'), + }), pob: z.string().optional(), maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')), }); diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 1f08c7682..73d9a0f34 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -224,7 +224,8 @@ export function ProfilePage() { idType: currentProfile.address?.idType || '', idNumber: currentProfile.address?.idNumber || '', // Stored as a country name; the select works in alpha-2 codes. - nationality: getCountryCode(currentProfile.address?.nationality) || '', + // Default to Ethiopian when no nationality is on record yet. + nationality: getCountryCode(currentProfile.address?.nationality) || 'ET', primaryPhoneNumber: user?.phoneNumber || '', secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '', email: user?.email || '', diff --git a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx index deefbab8b..dafc335fa 100644 --- a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx +++ b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx @@ -70,29 +70,10 @@ function EvidenceModal({ onClose: () => void; }) { const { t } = useTranslation(); - const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery( + const { data: attachments, isLoading } = useGetAttachmentsQuery( { ownerType, ownerId: ownerId ?? '' }, { skip: !ownerId }, ); - const [uploading, setUploading] = useState(false); - - const upload = async (file: File | null) => { - if (!file || !ownerId) return; - setUploading(true); - const result = await uploadDocument({ - ownerType, - ownerId, - documentKey: 'evidence', - file, - }); - setUploading(false); - if (result.ok) { - notify.success(t('seaRecords.evidence.uploaded')); - refetch(); - } else { - notify.error(result.error); - } - }; const files = (attachments ?? []).flatMap((a) => a.files); @@ -119,25 +100,46 @@ function EvidenceModal({ )) )} - - - {(props) => ( - - )} - - ); } +/** + * Evidence picker that lives inside the add/edit form. The file is held in + * component state and uploaded right after the record is saved, because the + * attachment needs an owner id that only exists once the record does. + */ +function EvidenceField({ + file, + onChange, +}: { + file: File | null; + onChange: (file: File | null) => void; +}) { + const { t } = useTranslation(); + return ( + + + + {(props) => ( + + )} + + + {file ? file.name : t('seaRecords.evidence.none')} + + + + ); +} + // ---------------------------------------------------------------- sea service const EMPTY_SEA_SERVICE = { @@ -168,11 +170,14 @@ function SeaServiceTab() { const [evidenceFor, setEvidenceFor] = useState(null); const [form, setForm] = useState(EMPTY_SEA_SERVICE); const [grossTonnage, setGrossTonnage] = useState(''); + const [evidenceFile, setEvidenceFile] = useState(null); + const [uploading, setUploading] = useState(false); const openCreate = () => { setEditing(null); setForm(EMPTY_SEA_SERVICE); setGrossTonnage(''); + setEvidenceFile(null); setModalOpen(true); }; @@ -189,6 +194,7 @@ function SeaServiceTab() { dutiesDescription: record.dutiesDescription ?? '', }); setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : ''); + setEvidenceFile(null); setModalOpen(true); }; @@ -207,13 +213,31 @@ function SeaServiceTab() { ...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}), }; try { + let recordId = editing?.id; if (editing) { await updateRecord({ id: editing.id, body }).unwrap(); - notify.success(t('seaRecords.seaService.updated')); } else { - await createRecord(body).unwrap(); - notify.success(t('seaRecords.seaService.added')); + recordId = (await createRecord(body).unwrap()).id; } + if (evidenceFile && recordId) { + setUploading(true); + const result = await uploadDocument({ + ownerType: 'SEA_SERVICE_RECORD', + ownerId: recordId, + documentKey: 'evidence', + file: evidenceFile, + }); + setUploading(false); + if (!result.ok) { + notify.error(result.error); + return; + } + } + notify.success( + editing + ? t('seaRecords.seaService.updated') + : t('seaRecords.seaService.added'), + ); setModalOpen(false); } catch (error) { notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed'))); @@ -363,6 +387,7 @@ function SeaServiceTab() { setForm({ ...form, dutiesDescription: e.target.value }) } /> + @@ -413,10 +438,13 @@ function MedicalTab() { const [modalOpen, setModalOpen] = useState(false); const [evidenceFor, setEvidenceFor] = useState(null); const [form, setForm] = useState(EMPTY_MEDICAL); + const [evidenceFile, setEvidenceFile] = useState(null); + const [uploading, setUploading] = useState(false); const openCreate = () => { setEditing(null); setForm(EMPTY_MEDICAL); + setEvidenceFile(null); setModalOpen(true); }; @@ -430,6 +458,7 @@ function MedicalTab() { fitnessStatus: certificate.fitnessStatus, restrictions: certificate.restrictions ?? '', }); + setEvidenceFile(null); setModalOpen(true); }; @@ -445,13 +474,31 @@ function MedicalTab() { ...(form.restrictions ? { restrictions: form.restrictions } : {}), }; try { + let certificateId = editing?.id; if (editing) { await updateCertificate({ id: editing.id, body }).unwrap(); - notify.success(t('seaRecords.medical.updated')); } else { - await createCertificate(body).unwrap(); - notify.success(t('seaRecords.medical.added')); + certificateId = (await createCertificate(body).unwrap()).id; } + if (evidenceFile && certificateId) { + setUploading(true); + const result = await uploadDocument({ + ownerType: 'MEDICAL_CERTIFICATE', + ownerId: certificateId, + documentKey: 'evidence', + file: evidenceFile, + }); + setUploading(false); + if (!result.ok) { + notify.error(result.error); + return; + } + } + notify.success( + editing + ? t('seaRecords.medical.updated') + : t('seaRecords.medical.added'), + ); setModalOpen(false); } catch (error) { notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed'))); @@ -575,6 +622,7 @@ function MedicalTab() { } /> )} + diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 6ec7b8348..948de1c30 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -418,6 +418,7 @@ export const am: Translations = { lastNameMin: 'የአያት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት', genderRequired: 'ጾታዎን ይምረጡ', dobRequired: 'የትውልድ ቀንዎን ይምረጡ', + dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት', maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ', nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ', }, diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 792604e12..621209e49 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -418,6 +418,7 @@ export const en = { lastNameMin: 'Last name must be at least 3 characters', genderRequired: 'Select your gender', dobRequired: 'Select your date of birth', + dobMinAge: 'You must be at least 18 years old', maritalStatusRequired: 'Select your marital status', nameParts: 'Enter your first, middle, and last name', },