Files
emaui/apps/backoffice/src/app/features/license-review/components/FormDetailsTab.tsx

266 lines
9.7 KiB
TypeScript

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