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"
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. */}
<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">
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge>
@@ -124,7 +128,7 @@ export function DecisionBar({
</Group>
{/* Right: the decision. */}
<Group gap="xs" wrap="nowrap">
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flex: '0 1 auto' }}>
{primary.map((action) => (
<ActionButton
key={action.id}
@@ -194,10 +198,17 @@ function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
const button = (
<Button
size={size}
variant={action.emphasis === 'filled' ? 'filled' : action.emphasis ?? 'light'}
variant={
action.emphasis === 'filled'
? 'filled'
: action.emphasis === 'subtle'
? 'default'
: 'light'
}
color={action.color}
loading={busy}
disabled={!action.enabled}
style={{ flexShrink: 0 }}
onClick={() => onAction(action)}
>
{t(action.labelKey)}

View File

@@ -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);
}

View File

@@ -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<FlagMap>({});
@@ -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.