mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 12:58:15 +00:00
Remove obsolete test artifacts and error context files for seafarer registration tests
This commit is contained in:
@@ -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 (
|
||||
<Paper withBorder p="md">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start" mb="sm">
|
||||
<Avatar radius="xl" color="blue" variant="light">
|
||||
{initials || '—'}
|
||||
</Avatar>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" style={{ wordBreak: 'break-word' }}>
|
||||
{fullName || t('review.nameMissing', 'Name not on profile')}
|
||||
</Text>
|
||||
{applicant.seafarerNumber ? (
|
||||
<Group gap={4} mt={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{applicant.seafarerNumber}
|
||||
</Text>
|
||||
{applicant.seafarerStatus && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={applicant.seafarerStatus === 'ACTIVE' ? 'teal' : 'orange'}
|
||||
>
|
||||
{applicant.seafarerStatus}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{t('review.notYetRegistered', 'Not yet registered')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Row label={t('review.applicantGender', 'Gender')} value={applicant.gender} />
|
||||
<Row
|
||||
label={t('review.applicantDob', 'Date of birth')}
|
||||
value={applicant.dob ? showDate(applicant.dob) : null}
|
||||
/>
|
||||
<Row
|
||||
label={t('review.applicantNationality', 'Nationality')}
|
||||
value={applicant.nationality}
|
||||
/>
|
||||
<Row
|
||||
// The id type is the label, so a Fayda number is not read as a passport.
|
||||
label={applicant.idType ?? t('review.applicantId', 'National ID')}
|
||||
value={applicant.idNumber}
|
||||
/>
|
||||
<Row
|
||||
label={t('review.applicantPhone', 'Phone')}
|
||||
value={applicant.primaryPhoneNumber}
|
||||
/>
|
||||
<Row label={t('review.applicantEmail', 'Email')} value={applicant.email} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" ta="right" style={{ wordBreak: 'break-word' }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
conditionHolds,
|
||||
displayFieldValue,
|
||||
useLocalized,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
} from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/** A section as it will be rendered: config where there is some, key otherwise. */
|
||||
interface ResolvedSection {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
fields: { field: FormFieldConfig; value: unknown }[];
|
||||
}
|
||||
|
||||
interface FormDetailsTabProps {
|
||||
/** The application's answers, keyed by section. */
|
||||
formData: Record<string, Record<string, unknown>>;
|
||||
/** The licence type's form schema — the order and labels to render by. */
|
||||
configSections: FormSectionConfig[];
|
||||
currency?: string;
|
||||
/** sectionKey -> remark. Owned by the review page. */
|
||||
flags: Record<string, { remark: string }>;
|
||||
onToggleFlag: (sectionKey: string) => void;
|
||||
onFlagRemark: (sectionKey: string, remark: string) => void;
|
||||
/** Resolves a location id to a readable path, when the tree is loaded. */
|
||||
resolveLocation?: (locationId: string) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the applicant actually filled in, as the reviewing officer reads it.
|
||||
*
|
||||
* Replaces a set of bordered key/value tables built by walking `formData`.
|
||||
* Three things were wrong with that, all of them worse on a person-centric
|
||||
* registration than on a company licence:
|
||||
*
|
||||
* - Values were printed with `String(v)`, so a reviewer deciding on a seafarer
|
||||
* read `O_POSITIVE`, `DECK` and `true` — database codes, not the answers
|
||||
* anybody chose. Now resolved through the same field config that rendered
|
||||
* the input, shared with the applicant's own summary (`displayFieldValue`).
|
||||
* - Order came from jsonb key order, which is arbitrary: the declaration could
|
||||
* appear above the emergency contact. Now the schema's `sortOrder` decides,
|
||||
* which is the order the applicant filled them in.
|
||||
* - A location answer is a uuid. Shown raw it told the reviewer nothing;
|
||||
* resolved, it reads "Addis Ababa → Bole → Woreda 03".
|
||||
*/
|
||||
export function FormDetailsTab({
|
||||
formData,
|
||||
configSections,
|
||||
currency,
|
||||
flags,
|
||||
onToggleFlag,
|
||||
onFlagRemark,
|
||||
resolveLocation,
|
||||
}: FormDetailsTabProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const sections = resolveSections();
|
||||
|
||||
/**
|
||||
* Sections in schema order, each with its fields in schema order.
|
||||
*
|
||||
* Anything present in `formData` but absent from the schema is still shown,
|
||||
* appended after the configured sections — a stale answer from a since-edited
|
||||
* form is exactly the kind of thing a reviewer needs to see, not something to
|
||||
* hide because the config moved on.
|
||||
*/
|
||||
function resolveSections(): ResolvedSection[] {
|
||||
const configured = [...configSections]
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((section) => {
|
||||
const values = formData[section.key] ?? {};
|
||||
const fields = [...(section.fields ?? [])]
|
||||
.filter((f) => conditionHolds(f.showWhen, formData))
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((field) => ({ field, value: values[field.key] }));
|
||||
return {
|
||||
key: section.key,
|
||||
title: localized(section.title) || section.key,
|
||||
description: localized(section.description) || undefined,
|
||||
fields,
|
||||
};
|
||||
})
|
||||
// A section the applicant never reached is noise on a review screen.
|
||||
.filter((s) => s.fields.some((f) => hasValue(f.value)));
|
||||
|
||||
const configuredKeys = new Set(configSections.map((s) => s.key));
|
||||
const orphans: ResolvedSection[] = Object.entries(formData)
|
||||
.filter(([key, values]) => !configuredKeys.has(key) && values)
|
||||
.map(([key, values]) => ({
|
||||
key,
|
||||
title: humanise(key),
|
||||
fields: Object.entries(values).map(([fieldKey, value]) => ({
|
||||
// No config to render by, so it is treated as free text under a
|
||||
// humanised key rather than dropped.
|
||||
field: { key: fieldKey, label: { en: humanise(fieldKey) }, type: 'TEXT' } as FormFieldConfig,
|
||||
value,
|
||||
})),
|
||||
}));
|
||||
|
||||
return [...configured, ...orphans];
|
||||
}
|
||||
|
||||
function display(field: FormFieldConfig, value: unknown): string {
|
||||
// A location is stored as a tree id; the reviewer needs the place.
|
||||
if (isLocationField(field) && typeof value === 'string' && value) {
|
||||
return resolveLocation?.(value) ?? value;
|
||||
}
|
||||
return displayFieldValue(field, value, {
|
||||
language: i18n.language,
|
||||
showDate,
|
||||
currency,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
{sections.map((section) => {
|
||||
const flagged = Boolean(flags[section.key]);
|
||||
const missing = section.fields.filter((f) => !hasValue(f.value)).length;
|
||||
|
||||
return (
|
||||
<Grid.Col span={12} key={section.key}>
|
||||
<Card withBorder padding="md" radius="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{section.title}
|
||||
</Text>
|
||||
{missing > 0 && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
'review.missingAnswers',
|
||||
'Left blank by the applicant',
|
||||
)}
|
||||
>
|
||||
<Badge
|
||||
size="xs"
|
||||
color="gray"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={10} />}
|
||||
>
|
||||
{missing}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
{section.description && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{section.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label={t('review.needsCorrection', 'Needs correction')}
|
||||
checked={flagged}
|
||||
onChange={() => onToggleFlag(section.key)}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
{/* Label above value, two per row — a reviewer scans a definition
|
||||
list far faster than a bordered table of the same answers. */}
|
||||
<Grid gutter="sm">
|
||||
{section.fields.map(({ field, value }) => {
|
||||
const text = display(field, value);
|
||||
const answered = hasValue(value) && text !== '';
|
||||
return (
|
||||
<Grid.Col
|
||||
span={{ base: 12, sm: field.type === 'TEXTAREA' ? 12 : 6 }}
|
||||
key={field.key}
|
||||
>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{localized(field.label) || field.key}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" align="center" mt={2}>
|
||||
{answered && isLocationField(field) && (
|
||||
<IconMapPin size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||
)}
|
||||
<Text
|
||||
size="sm"
|
||||
c={answered ? undefined : 'dimmed'}
|
||||
fs={answered ? undefined : 'italic'}
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
>
|
||||
{answered
|
||||
? text
|
||||
: t('review.notProvided', 'Not provided')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{flagged && (
|
||||
<TextInput
|
||||
mt="sm"
|
||||
size="xs"
|
||||
withAsterisk
|
||||
placeholder={t(
|
||||
'review.correctionPlaceholder',
|
||||
'What must the applicant correct?',
|
||||
)}
|
||||
// Flagging without saying why is what the applicant would
|
||||
// receive: "fix this section", and nothing else.
|
||||
error={
|
||||
flags[section.key].remark.trim()
|
||||
? null
|
||||
: t('review.correctionRequired', 'Say what must be corrected')
|
||||
}
|
||||
value={flags[section.key].remark}
|
||||
onChange={(e) => {
|
||||
// Read here, not inside the updater: React nulls
|
||||
// `currentTarget` when the handler returns, and the updater
|
||||
// runs afterwards during the re-render.
|
||||
onFlagRemark(section.key, e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
return value !== null && value !== undefined && value !== '';
|
||||
}
|
||||
|
||||
/** English-pinned, like the portal's own location override. */
|
||||
function isLocationField(field: Pick<FormFieldConfig, 'key' | 'label'>): boolean {
|
||||
return (
|
||||
field.key === 'locationId' ||
|
||||
(field.label?.en ?? '').trim().toLowerCase() === 'location'
|
||||
);
|
||||
}
|
||||
|
||||
function humanise(key: string): string {
|
||||
const spaced = key.replace(/([A-Z])/g, ' $1').replace(/[_-]+/g, ' ');
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1).trim();
|
||||
}
|
||||
@@ -260,6 +260,28 @@ export interface ResolveContext {
|
||||
hasPendingInspection: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action ids that are workflow events, so `availableEvents` decides them.
|
||||
*
|
||||
* The rest (`schedule-inspection`, `schedule-exam`, the secondary tools) are
|
||||
* screens and side effects rather than transitions, and the server has no
|
||||
* opinion on them — those keep using their own `from` list.
|
||||
*/
|
||||
const WORKFLOW_EVENT_IDS = new Set<ActionId>([
|
||||
'claim',
|
||||
'assign',
|
||||
'escalate',
|
||||
'hold',
|
||||
'resume',
|
||||
'complete-review',
|
||||
'approve-documents',
|
||||
'record-inspection',
|
||||
'final-approve',
|
||||
'request-adjustment',
|
||||
'reject',
|
||||
'confirm-payment',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Which actions to render, and for each, whether it can fire and why not.
|
||||
*
|
||||
@@ -267,16 +289,35 @@ export interface ResolveContext {
|
||||
* are merely unavailable right now are kept and disabled with a reason, so the
|
||||
* officer can see what the next step would be rather than wondering whether
|
||||
* the screen is broken.
|
||||
*
|
||||
* For anything that is a workflow event, `detail.availableEvents` is the
|
||||
* authority on what fires from here — it comes from the same transition table
|
||||
* the server validates against, and it is workflow-profile aware. The local
|
||||
* `from` lists describe the licence course only, so a registration (which skips
|
||||
* evaluation and inspection, and approves straight out of UNDER_REVIEW) was
|
||||
* offered Complete Review — rejected server-side with
|
||||
* `event_not_available_for_service` — while Final Approve, the one action that
|
||||
* would work, was hidden.
|
||||
*/
|
||||
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||
const { detail, currentUserId, can, reasons } = ctx;
|
||||
const app = detail.application;
|
||||
const serverEvents = detail.availableEvents;
|
||||
|
||||
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
|
||||
(action) => {
|
||||
// Status-scoped actions vanish outside their stage rather than piling up
|
||||
// as a column of permanently dead buttons.
|
||||
if (action.from && !action.from.includes(app.status)) return [];
|
||||
if (WORKFLOW_EVENT_IDS.has(action.id)) {
|
||||
// Tolerate an older server that sends no list rather than rendering an
|
||||
// empty action bar.
|
||||
if (serverEvents?.length && !serverEvents.includes(action.id)) return [];
|
||||
if (!serverEvents?.length && action.from && !action.from.includes(app.status)) {
|
||||
return [];
|
||||
}
|
||||
} else if (action.from && !action.from.includes(app.status)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Scheduling and recording are the same slot at the same status; which
|
||||
// one applies depends on whether an inspection is already booked.
|
||||
|
||||
@@ -87,6 +87,23 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
||||
// Person-centric: no company entity, no capital threshold, no staff roles.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
// Opened automatically when a registration is approved, and reviewed like any
|
||||
// other person-centric service. Listed explicitly because neither key matches
|
||||
// the certificate prefixes below, so both fell through to the company-shaped
|
||||
// default and offered an officer Company, Financials and Staff tabs for an
|
||||
// application about one person.
|
||||
SEAMAN_BOOK: {
|
||||
key: 'SEAMAN_BOOK',
|
||||
icon: IconId,
|
||||
// Its own TRB inspection is a real stage, unlike the other personal
|
||||
// services, so the inspection tab stays.
|
||||
detailSections: ['overview', 'documents', 'inspection'],
|
||||
},
|
||||
BTC_BASIC_TRAINING: {
|
||||
key: 'BTC_BASIC_TRAINING',
|
||||
icon: IconShieldCheck,
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
VESSEL_REGISTRATION: {
|
||||
key: 'VESSEL_REGISTRATION',
|
||||
icon: IconAnchor,
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Checkbox,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
@@ -15,11 +13,9 @@ import {
|
||||
SegmentedControl,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
@@ -38,6 +34,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
applicantOrCompanyName,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useApproveDocumentsMutation,
|
||||
@@ -77,6 +74,9 @@ import {
|
||||
} from '../../components/DecisionConfirmModal';
|
||||
import { ActivityRail } from '../../components/ActivityRail';
|
||||
import { DocumentsTab } from '../../components/DocumentsTab';
|
||||
import { FormDetailsTab } from '../../components/FormDetailsTab';
|
||||
import { ApplicantCard } from '../../components/ApplicantCard';
|
||||
import { useGetLocationsQuery } from '../../../location/api/location-api';
|
||||
import { ScheduleExamModal } from '../../components/ScheduleExamModal';
|
||||
import { computeSla } from '../../sla';
|
||||
import { reviewStaffColumns } from './columns';
|
||||
@@ -258,6 +258,45 @@ export function LicenseReviewPage() {
|
||||
});
|
||||
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
|
||||
|
||||
// Location answers are tree ids. The picker the applicant used resolves them
|
||||
// client-side from the same list, so the reviewer reads the place rather than
|
||||
// the uuid. Fetched only when the form actually has a location field.
|
||||
//
|
||||
// Above the early returns because it is a hook: React requires the same hook
|
||||
// order on every render, and the loading and error branches return before the
|
||||
// application is known.
|
||||
const needsLocations = (
|
||||
requirements?.licenseType.formSchema.sections ?? []
|
||||
).some((section) =>
|
||||
(section.fields ?? []).some(
|
||||
(f) =>
|
||||
f.key === 'locationId' ||
|
||||
(f.label?.en ?? '').trim().toLowerCase() === 'location',
|
||||
),
|
||||
);
|
||||
const { data: locationsRes } = useGetLocationsQuery(
|
||||
{ take: 10000 },
|
||||
{ skip: !needsLocations },
|
||||
);
|
||||
const resolveLocation = useMemo(() => {
|
||||
const all = locationsRes?.items ?? [];
|
||||
if (all.length === 0) return undefined;
|
||||
const byId = new Map(all.map((loc) => [loc.id, loc]));
|
||||
return (locationId: string) => {
|
||||
// Walks to the root so the answer reads as a place, not a leaf name:
|
||||
// "Woreda 03" alone does not say which sub-city it belongs to. Bounded by
|
||||
// the map size so a cyclic tree cannot spin the render.
|
||||
const path: string[] = [];
|
||||
let current = byId.get(locationId);
|
||||
let hops = 0;
|
||||
while (current && hops++ <= byId.size) {
|
||||
path.unshift(localized(current.names) || current.code);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return path.length ? path.join(' → ') : undefined;
|
||||
};
|
||||
}, [locationsRes, localized]);
|
||||
|
||||
if (isLoading) {
|
||||
// Skeleton mirrors the real three-zone layout so nothing jumps on load.
|
||||
return (
|
||||
@@ -515,19 +554,40 @@ export function LicenseReviewPage() {
|
||||
}
|
||||
|
||||
const sections = presentation.detailSections;
|
||||
const formSections = Object.entries(app.formData ?? {});
|
||||
const hasFormAnswers = Object.keys(app.formData ?? {}).length > 0;
|
||||
// The bilingual section/field labels the applicant's wizard renders — this
|
||||
// page already fetches them (`requirements` above) but used to fall back to
|
||||
// the raw formData keys, so an officer saw `vesselId` instead of a label in
|
||||
// either language.
|
||||
const configSections = requirements?.licenseType.formSchema.sections ?? [];
|
||||
const sectionsByKey = new Map(configSections.map((s) => [s.key, s]));
|
||||
|
||||
// A person-centric service has no company, so the company-shaped facts are
|
||||
// not merely empty — they are the wrong question. TIN is hidden rather than
|
||||
// shown blank, and the applicant's own identity card takes its place.
|
||||
const isPersonal = !app.companyName;
|
||||
const applicant = data.applicant;
|
||||
const applicantFullName = [
|
||||
applicant?.firstName,
|
||||
applicant?.middleName,
|
||||
applicant?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
// The profile is the reliable name for a personal service — `formData.account`
|
||||
// holds one only for applications filed after that field was added, and the
|
||||
// application number identifies the paperwork rather than the person.
|
||||
const headerName =
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
applicantOrCompanyName(app) ||
|
||||
app.applicationNumber;
|
||||
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{app.companyName ?? app.applicationNumber}</Title>
|
||||
<Title order={3}>{headerName}</Title>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.applicationNumber}
|
||||
@@ -565,13 +625,21 @@ export function LicenseReviewPage() {
|
||||
{/* Zone 1 — sticky summary rail. */}
|
||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||
<Stack style={{ position: 'sticky', top: 16 }}>
|
||||
{/* Who, before what: a person-centric review is about the applicant,
|
||||
and the licence facts below are the context. */}
|
||||
{isPersonal && applicant && <ApplicantCard applicant={applicant} />}
|
||||
|
||||
<Paper withBorder p="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
{t('review.summary', 'Summary')}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
<SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} />
|
||||
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
|
||||
{/* A person has no TIN; showing the row blank invited the
|
||||
reviewer to wonder what was missing. */}
|
||||
{!isPersonal && (
|
||||
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
|
||||
)}
|
||||
<SummaryRow label={t('review.kind', 'Kind')} value={t(`review.kindValues.${app.kind}`, app.kind)} />
|
||||
<SummaryRow
|
||||
label={t('review.submitted', 'Submitted')}
|
||||
@@ -653,7 +721,7 @@ export function LicenseReviewPage() {
|
||||
<Tabs defaultValue={sections[0]}>
|
||||
<Tabs.List mb="md">
|
||||
{/* Tabs with nothing behind them are not rendered at all. */}
|
||||
{sections.includes('overview') && formSections.length > 0 && (
|
||||
{sections.includes('overview') && hasFormAnswers && (
|
||||
<Tabs.Tab value="overview">{t('review.tabs.overview', 'Overview')}</Tabs.Tab>
|
||||
)}
|
||||
{sections.includes('financials') && (
|
||||
@@ -675,83 +743,20 @@ export function LicenseReviewPage() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<Stack>
|
||||
{formSections.map(([sectionKey, values]) => {
|
||||
const sectionConfig = sectionsByKey.get(sectionKey);
|
||||
const fieldsByKey = new Map(
|
||||
(sectionConfig?.fields ?? []).map((f) => [f.key, f]),
|
||||
);
|
||||
return (
|
||||
<Card withBorder key={sectionKey} padding="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm" tt="capitalize">
|
||||
{sectionConfig
|
||||
? localized(sectionConfig.title)
|
||||
: sectionKey.replace(/([A-Z])/g, ' $1')}
|
||||
</Text>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label={t('review.needsCorrection', 'Needs correction')}
|
||||
checked={Boolean(flags[sectionKey])}
|
||||
onChange={() => toggleFlag('FORM_SECTION', sectionKey)}
|
||||
/>
|
||||
</Group>
|
||||
<Table withTableBorder>
|
||||
<Table.Tbody>
|
||||
{Object.entries(values ?? {}).map(([k, v]) => {
|
||||
const fieldConfig = fieldsByKey.get(k);
|
||||
return (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{fieldConfig ? localized(fieldConfig.label) : k}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{v === null ? '—' : String(v)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{flags[sectionKey] && (
|
||||
<TextInput
|
||||
mt="xs"
|
||||
size="xs"
|
||||
withAsterisk
|
||||
placeholder={t(
|
||||
'review.correctionPlaceholder',
|
||||
'What must the applicant correct?',
|
||||
)}
|
||||
// Flagging without saying why is what the applicant
|
||||
// would receive: "fix this section", and nothing else.
|
||||
error={
|
||||
flags[sectionKey].remark.trim()
|
||||
? null
|
||||
: t(
|
||||
'review.correctionRequired',
|
||||
'Say what must be corrected',
|
||||
)
|
||||
}
|
||||
value={flags[sectionKey].remark}
|
||||
onChange={(e) => {
|
||||
// Read here, not inside the updater: React nulls
|
||||
// `currentTarget` when the handler returns, and the
|
||||
// updater runs afterwards during the re-render —
|
||||
// which crashed the page on the first keystroke.
|
||||
const remark = e.currentTarget.value;
|
||||
setFlags((p) => ({
|
||||
...p,
|
||||
[sectionKey]: { ...p[sectionKey], remark },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<FormDetailsTab
|
||||
formData={app.formData ?? {}}
|
||||
configSections={configSections}
|
||||
currency={app.feeCurrency ?? undefined}
|
||||
flags={flags}
|
||||
onToggleFlag={(sectionKey) => toggleFlag('FORM_SECTION', sectionKey)}
|
||||
onFlagRemark={(sectionKey, remark) =>
|
||||
setFlags((p) => ({
|
||||
...p,
|
||||
[sectionKey]: { ...p[sectionKey], remark },
|
||||
}))
|
||||
}
|
||||
resolveLocation={resolveLocation}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="financials">
|
||||
@@ -905,7 +910,11 @@ export function LicenseReviewPage() {
|
||||
|
||||
<DecisionConfirmModal
|
||||
action={pendingAction}
|
||||
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
|
||||
applicantName={
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
t('review.theApplicant', 'the applicant')
|
||||
}
|
||||
applicationNumber={app.applicationNumber}
|
||||
flaggedItems={flaggedItems}
|
||||
officers={officers}
|
||||
@@ -916,7 +925,11 @@ export function LicenseReviewPage() {
|
||||
|
||||
<ScheduleExamModal
|
||||
opened={scheduleExamOpen}
|
||||
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
|
||||
applicantName={
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
t('review.theApplicant', 'the applicant')
|
||||
}
|
||||
loading={schedulingExam}
|
||||
onClose={() => setScheduleExamOpen(false)}
|
||||
onConfirm={async (payload) => {
|
||||
|
||||
Reference in New Issue
Block a user