mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
266 lines
9.7 KiB
TypeScript
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();
|
|
}
|