feat: implement 18+ age validation, enforce document review completion for approvals, and refactor DecisionBar UI

This commit is contained in:
estifanos
2026-08-18 07:43:48 +00:00
parent c6ecb2dd48
commit 3d508acc41
10 changed files with 164 additions and 49 deletions

View File

@@ -76,9 +76,13 @@ export function DecisionBar({
role="region" role="region"
aria-label={t('review.decisionBar', 'Decision bar')} aria-label={t('review.decisionBar', 'Decision bar')}
> >
<Group justify="space-between" wrap="nowrap" gap="md"> {/* 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. */}
<Group justify="space-between" wrap="wrap" gap="sm">
{/* Left: where the application stands, and who has it. */} {/* Left: where the application stands, and who has it. */}
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> <Group gap="sm" wrap="wrap" style={{ minWidth: 0, flex: '1 1 auto' }}>
<Badge color={STATUS_COLORS[status]} variant="light" size="lg"> <Badge color={STATUS_COLORS[status]} variant="light" size="lg">
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])} {t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge> </Badge>
@@ -124,7 +128,7 @@ export function DecisionBar({
</Group> </Group>
{/* Right: the decision. */} {/* Right: the decision. */}
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="wrap" justify="flex-end" style={{ flex: '0 1 auto' }}>
{primary.map((action) => ( {primary.map((action) => (
<ActionButton <ActionButton
key={action.id} key={action.id}
@@ -194,10 +198,17 @@ function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
const button = ( const button = (
<Button <Button
size={size} size={size}
variant={action.emphasis === 'filled' ? 'filled' : action.emphasis ?? 'light'} variant={
action.emphasis === 'filled'
? 'filled'
: action.emphasis === 'subtle'
? 'default'
: 'light'
}
color={action.color} color={action.color}
loading={busy} loading={busy}
disabled={!action.enabled} disabled={!action.enabled}
style={{ flexShrink: 0 }}
onClick={() => onAction(action)} onClick={() => onAction(action)}
> >
{t(action.labelKey)} {t(action.labelKey)}

View File

@@ -253,11 +253,18 @@ export interface ResolveContext {
needsFlags: string; needsFlags: string;
needsCapital: string; needsCapital: string;
needsInspection: string; needsInspection: string;
needsDocumentReviews: string;
}; };
/** Number of sections/documents the officer has flagged for correction. */ /** Number of sections/documents the officer has flagged for correction. */
flaggedCount: number; flaggedCount: number;
/** True when an inspection is scheduled and awaiting a result. */ /** True when an inspection is scheduled and awaiting a result. */
hasPendingInspection: boolean; 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); 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) { if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
return disabled(reasons.needsFlags); return disabled(reasons.needsFlags);
} }

View File

@@ -49,6 +49,7 @@ import {
useFinalApproveMutation, useFinalApproveMutation,
useGetApplicationForReviewQuery, useGetApplicationForReviewQuery,
useGetAttachmentsQuery, useGetAttachmentsQuery,
useGetDocumentReviewsQuery,
useGetInspectionsQuery, useGetInspectionsQuery,
useGetAssignableOfficersQuery, useGetAssignableOfficersQuery,
useGetLicenseTypeRequirementsQuery, useGetLicenseTypeRequirementsQuery,
@@ -162,6 +163,9 @@ export function LicenseReviewPage() {
// Real officer list, so Assign and Escalate name a person instead of // Real officer list, so Assign and Escalate name a person instead of
// silently reassigning to whoever already held the application. // silently reassigning to whoever already held the application.
const { data: officers = [] } = useGetAssignableOfficersQuery(); 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 staffTable = useServerTable();
const [flags, setFlags] = useState<FlagMap>({}); const [flags, setFlags] = useState<FlagMap>({});
@@ -208,6 +212,19 @@ export function LicenseReviewPage() {
}, [flags]); }, [flags]);
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED'); 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); const flagged = Object.entries(flags);
/** /**
@@ -247,6 +264,7 @@ export function LicenseReviewPage() {
can, can,
flaggedCount: flagged.length, flaggedCount: flagged.length,
hasPendingInspection: Boolean(pendingInspection), hasPendingInspection: Boolean(pendingInspection),
allDocumentsAccepted,
reasons: { reasons: {
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'), wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'), 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'), needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'), needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'), 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) { if (isLoading) {
// Skeleton mirrors the real three-zone layout so nothing jumps on load. // Skeleton mirrors the real three-zone layout so nothing jumps on load.

View File

@@ -949,6 +949,7 @@ export const am: Translations = {
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ", needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ", needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
needsInspection: "የምርመራ ውጤት ያስፈልጋል", needsInspection: "የምርመራ ውጤት ያስፈልጋል",
needsDocumentReviews: "መጀመሪያ ሁሉንም ሰነዶች ተቀበል",
}, },
reasons: { reasons: {
incompleteDocuments: "ያልተሟሉ ሰነዶች", incompleteDocuments: "ያልተሟሉ ሰነዶች",

View File

@@ -950,6 +950,7 @@ export const en = {
needsFlags: 'Flag at least one item to request a correction', needsFlags: 'Flag at least one item to request a correction',
needsCapital: 'Record the verified capital first', needsCapital: 'Record the verified capital first',
needsInspection: 'Requires an inspection result', needsInspection: 'Requires an inspection result',
needsDocumentReviews: 'Accept every document first',
}, },
reasons: { reasons: {
incompleteDocuments: 'Incomplete documents', incompleteDocuments: 'Incomplete documents',

View File

@@ -5,6 +5,16 @@ import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AmharicDatePicker } from '@ema-platform/ui'; 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) => export const profileSchema = (t: TFunction) =>
z.object({ z.object({
professionId: z.string().min(1, t('profileForm.validation.professionRequired')), 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')), middleName: z.string().min(3, t('profileForm.validation.middleNameMin')),
lastName: z.string().min(3, t('profileForm.validation.lastNameMin')), lastName: z.string().min(3, t('profileForm.validation.lastNameMin')),
gender: z.string().min(1, t('profileForm.validation.genderRequired')), 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(), pob: z.string().optional(),
maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')), maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
}); });

View File

@@ -224,7 +224,8 @@ export function ProfilePage() {
idType: currentProfile.address?.idType || '', idType: currentProfile.address?.idType || '',
idNumber: currentProfile.address?.idNumber || '', idNumber: currentProfile.address?.idNumber || '',
// Stored as a country name; the select works in alpha-2 codes. // 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 || '', primaryPhoneNumber: user?.phoneNumber || '',
secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '', secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '',
email: user?.email || '', email: user?.email || '',

View File

@@ -70,29 +70,10 @@ function EvidenceModal({
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery( const { data: attachments, isLoading } = useGetAttachmentsQuery(
{ ownerType, ownerId: ownerId ?? '' }, { ownerType, ownerId: ownerId ?? '' },
{ skip: !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); const files = (attachments ?? []).flatMap((a) => a.files);
@@ -119,25 +100,46 @@ function EvidenceModal({
</Group> </Group>
)) ))
)} )}
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<FileButton onChange={upload} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
loading={uploading}
leftSection={<IconFileUpload size={16} />}
>
{t('seaRecords.evidence.upload')}
</Button>
)}
</FileButton>
</RequirePermission>
</Stack> </Stack>
</Modal> </Modal>
); );
} }
/**
* 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 (
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<Group gap="sm" align="center">
<FileButton onChange={onChange} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
leftSection={<IconFileUpload size={16} />}
>
{t('seaRecords.evidence.upload')}
</Button>
)}
</FileButton>
<Text size="sm" c={file ? undefined : 'dimmed'}>
{file ? file.name : t('seaRecords.evidence.none')}
</Text>
</Group>
</RequirePermission>
);
}
// ---------------------------------------------------------------- sea service // ---------------------------------------------------------------- sea service
const EMPTY_SEA_SERVICE = { const EMPTY_SEA_SERVICE = {
@@ -168,11 +170,14 @@ function SeaServiceTab() {
const [evidenceFor, setEvidenceFor] = useState<string | null>(null); const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_SEA_SERVICE); const [form, setForm] = useState(EMPTY_SEA_SERVICE);
const [grossTonnage, setGrossTonnage] = useState<number | ''>(''); const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
setForm(EMPTY_SEA_SERVICE); setForm(EMPTY_SEA_SERVICE);
setGrossTonnage(''); setGrossTonnage('');
setEvidenceFile(null);
setModalOpen(true); setModalOpen(true);
}; };
@@ -189,6 +194,7 @@ function SeaServiceTab() {
dutiesDescription: record.dutiesDescription ?? '', dutiesDescription: record.dutiesDescription ?? '',
}); });
setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : ''); setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : '');
setEvidenceFile(null);
setModalOpen(true); setModalOpen(true);
}; };
@@ -207,13 +213,31 @@ function SeaServiceTab() {
...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}), ...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}),
}; };
try { try {
let recordId = editing?.id;
if (editing) { if (editing) {
await updateRecord({ id: editing.id, body }).unwrap(); await updateRecord({ id: editing.id, body }).unwrap();
notify.success(t('seaRecords.seaService.updated'));
} else { } else {
await createRecord(body).unwrap(); recordId = (await createRecord(body).unwrap()).id;
notify.success(t('seaRecords.seaService.added'));
} }
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); setModalOpen(false);
} catch (error) { } catch (error) {
notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed'))); notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed')));
@@ -363,6 +387,7 @@ function SeaServiceTab() {
setForm({ ...form, dutiesDescription: e.target.value }) setForm({ ...form, dutiesDescription: e.target.value })
} }
/> />
<EvidenceField file={evidenceFile} onChange={setEvidenceFile} />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}> <Button variant="default" onClick={() => setModalOpen(false)}>
{t('common.cancel')} {t('common.cancel')}
@@ -370,7 +395,7 @@ function SeaServiceTab() {
<Button <Button
onClick={save} onClick={save}
disabled={!valid} disabled={!valid}
loading={creating || updating} loading={creating || updating || uploading}
> >
{editing ? t('common.save') : t('seaRecords.seaService.addRecord')} {editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
</Button> </Button>
@@ -413,10 +438,13 @@ function MedicalTab() {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [evidenceFor, setEvidenceFor] = useState<string | null>(null); const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_MEDICAL); const [form, setForm] = useState(EMPTY_MEDICAL);
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
setForm(EMPTY_MEDICAL); setForm(EMPTY_MEDICAL);
setEvidenceFile(null);
setModalOpen(true); setModalOpen(true);
}; };
@@ -430,6 +458,7 @@ function MedicalTab() {
fitnessStatus: certificate.fitnessStatus, fitnessStatus: certificate.fitnessStatus,
restrictions: certificate.restrictions ?? '', restrictions: certificate.restrictions ?? '',
}); });
setEvidenceFile(null);
setModalOpen(true); setModalOpen(true);
}; };
@@ -445,13 +474,31 @@ function MedicalTab() {
...(form.restrictions ? { restrictions: form.restrictions } : {}), ...(form.restrictions ? { restrictions: form.restrictions } : {}),
}; };
try { try {
let certificateId = editing?.id;
if (editing) { if (editing) {
await updateCertificate({ id: editing.id, body }).unwrap(); await updateCertificate({ id: editing.id, body }).unwrap();
notify.success(t('seaRecords.medical.updated'));
} else { } else {
await createCertificate(body).unwrap(); certificateId = (await createCertificate(body).unwrap()).id;
notify.success(t('seaRecords.medical.added'));
} }
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); setModalOpen(false);
} catch (error) { } catch (error) {
notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed'))); notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed')));
@@ -575,6 +622,7 @@ function MedicalTab() {
} }
/> />
)} )}
<EvidenceField file={evidenceFile} onChange={setEvidenceFile} />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}> <Button variant="default" onClick={() => setModalOpen(false)}>
{t('common.cancel')} {t('common.cancel')}
@@ -582,7 +630,7 @@ function MedicalTab() {
<Button <Button
onClick={save} onClick={save}
disabled={!valid} disabled={!valid}
loading={creating || updating} loading={creating || updating || uploading}
> >
{editing ? t('common.save') : t('seaRecords.medical.add')} {editing ? t('common.save') : t('seaRecords.medical.add')}
</Button> </Button>

View File

@@ -418,6 +418,7 @@ export const am: Translations = {
lastNameMin: 'የአያት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት', lastNameMin: 'የአያት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
genderRequired: 'ጾታዎን ይምረጡ', genderRequired: 'ጾታዎን ይምረጡ',
dobRequired: 'የትውልድ ቀንዎን ይምረጡ', dobRequired: 'የትውልድ ቀንዎን ይምረጡ',
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ', maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ', nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
}, },

View File

@@ -418,6 +418,7 @@ export const en = {
lastNameMin: 'Last name must be at least 3 characters', lastNameMin: 'Last name must be at least 3 characters',
genderRequired: 'Select your gender', genderRequired: 'Select your gender',
dobRequired: 'Select your date of birth', dobRequired: 'Select your date of birth',
dobMinAge: 'You must be at least 18 years old',
maritalStatusRequired: 'Select your marital status', maritalStatusRequired: 'Select your marital status',
nameParts: 'Enter your first, middle, and last name', nameParts: 'Enter your first, middle, and last name',
}, },