Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor

Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical
table component: kept its API plus teammate i18n/feature work, kept the
folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all
27 tables, removed the parallel lib/table implementation, and fixed
pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Nati
2026-08-13 11:31:27 +00:00
182 changed files with 14181 additions and 8141 deletions

View File

@@ -39,6 +39,7 @@ import {
STATUS_COLORS,
STATUS_LABELS,
extractErrorMessage,
useLocalized,
useApproveDocumentsMutation,
useAssignApplicationMutation,
useCompleteReviewMutation,
@@ -46,6 +47,7 @@ import {
useEscalateApplicationMutation,
useFinalApproveMutation,
useGetApplicationForReviewQuery,
useGetAttachmentsQuery,
useGetInspectionsQuery,
useGetAssignableOfficersQuery,
useGetLicenseTypeRequirementsQuery,
@@ -57,7 +59,14 @@ import {
useScheduleInspectionMutation,
type RemarkTargetType,
} from '@ema-platform/api';
import { AdvancedTable, ErrorState } from '@ema-platform/ui';
import {
AdvancedTable,
AmharicDatePicker,
ErrorState,
ModalFooter,
useServerTable,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { usePermissions } from '@ema-platform/auth';
import { useAppSelector } from '../../../../store/hooks';
import { DecisionBar } from '../../components/DecisionBar';
@@ -80,10 +89,10 @@ type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
* that fills it in.
*/
const INSPECTION_CHECKLIST_ITEMS = [
{ key: 'office_premises', label: 'Office premises' },
{ key: 'storage_facilities', label: 'Warehouse / storage facilities' },
{ key: 'vehicles_equipment', label: 'Vehicles / equipment' },
{ key: 'safety_compliance', label: 'Safety & regulatory compliance' },
{ key: 'office_premises', labelKey: 'review.checklist.officePremises', fallback: 'Office premises' },
{ key: 'storage_facilities', labelKey: 'review.checklist.storageFacilities', fallback: 'Warehouse / storage facilities' },
{ key: 'vehicles_equipment', labelKey: 'review.checklist.vehiclesEquipment', fallback: 'Vehicles / equipment' },
{ key: 'safety_compliance', labelKey: 'review.checklist.safetyCompliance', fallback: 'Safety & regulatory compliance' },
] as const;
function buildChecklist(
@@ -91,7 +100,9 @@ function buildChecklist(
) {
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
key: item.key,
label: item.label,
// Persisted as-is (stable English), not the officer's display language —
// this is an audit record, not UI text.
label: item.fallback,
// Untouched rows default to PASS — the segmented control shows exactly
// that, so what the officer saw is what gets recorded.
outcome: outcomes[item.key] ?? 'PASS',
@@ -109,6 +120,8 @@ function buildChecklist(
*/
export function LicenseReviewPage() {
const { t, i18n } = useTranslation();
const showDate = useDateDisplayer();
const localized = useLocalized();
const { id = '' } = useParams();
const { can } = usePermissions();
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? '';
@@ -123,6 +136,13 @@ export function LicenseReviewPage() {
{ idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' },
{ skip: !data?.application.licenseTypeId },
);
// Staff role names are already bilingual on the config — the wire data only
// carries the role key (e.g. 'CAPTAIN'), so this is what turns it back into
// the label an officer reads.
const roleNameByKey = useMemo(
() => new Map(requirements?.staffRoleRequirements.map((r) => [r.roleKey, r.name]) ?? []),
[requirements],
);
const [completeReview] = useCompleteReviewMutation();
const [requestAdjustment] = useRequestAdjustmentMutation();
@@ -140,6 +160,7 @@ export function LicenseReviewPage() {
// silently reassigning to whoever already held the application.
const { data: officers = [] } = useGetAssignableOfficersQuery();
const staffTable = useServerTable();
const [flags, setFlags] = useState<FlagMap>({});
const [capital, setCapital] = useState<number | undefined>();
const [pendingAction, setPendingAction] = useState<ResolvedAction | null>(null);
@@ -201,7 +222,7 @@ export function LicenseReviewPage() {
return {
key,
label: member
? `${member.roleKey}${member.fullName}`
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey}${member.fullName}`
: t('review.staffMember', 'Staff member'),
};
}
@@ -211,7 +232,7 @@ export function LicenseReviewPage() {
return { key, label: key };
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[flags, data?.staff, t],
[flags, data?.staff, t, localized, roleNameByKey],
);
const actions = useMemo(() => {
@@ -267,9 +288,12 @@ export function LicenseReviewPage() {
const app = data.application;
const status = app.status;
const staffPaged = staffTable.paginate(data.staff);
const presentation = presentationFor(app.licenseType?.key);
const sla = computeSla(app);
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
const sla = computeSla(app, undefined, i18n.language, (key, options) => t(key, options as any) as string);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language, (key, options) => t(key, options as any) as string);
const rawThreshold = app.licenseType?.capitalThreshold;
const threshold =
@@ -483,6 +507,12 @@ export function LicenseReviewPage() {
const sections = presentation.detailSections;
const formSections = Object.entries(app.formData ?? {});
// 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]));
return (
<Container size="xl" py="md">
@@ -494,7 +524,7 @@ export function LicenseReviewPage() {
{app.applicationNumber}
</Text>
<Badge color={STATUS_COLORS[status]} variant="light">
{STATUS_LABELS[status]}
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge>
{app.adjustmentRound > 0 && (
<Badge color="orange" variant="light" size="sm">
@@ -531,16 +561,12 @@ export function LicenseReviewPage() {
{t('review.summary', 'Summary')}
</Text>
<Stack gap={6}>
<SummaryRow label={t('review.type', 'Type')} value={app.licenseType?.name?.en} />
<SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} />
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
<SummaryRow label={t('review.kind', 'Kind')} value={app.kind} />
<SummaryRow label={t('review.kind', 'Kind')} value={t(`review.kindValues.${app.kind}`, app.kind)} />
<SummaryRow
label={t('review.submitted', 'Submitted')}
value={
app.submittedAt
? new Date(app.submittedAt).toLocaleDateString(i18n.language)
: undefined
}
value={showDate(app.submittedAt)}
/>
<SummaryRow label={t('review.slaLabel', 'SLA')} value={sla.label} />
</Stack>
@@ -599,12 +625,12 @@ export function LicenseReviewPage() {
key={entry.id}
title={
<Text size="xs" fw={600}>
{STATUS_LABELS[entry.toStatus] ?? entry.toStatus}
{t(`queue.statusValues.${entry.toStatus}`, STATUS_LABELS[entry.toStatus] ?? entry.toStatus)}
</Text>
}
>
<Text size="xs" c="dimmed">
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
{showDate(entry.createdAt)}
</Text>
</Timeline.Item>
))}
@@ -641,11 +667,18 @@ export function LicenseReviewPage() {
<Tabs.Panel value="overview">
<Stack>
{formSections.map(([sectionKey, values]) => (
{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">
{sectionKey.replace(/([A-Z])/g, ' $1')}
{sectionConfig
? localized(sectionConfig.title)
: sectionKey.replace(/([A-Z])/g, ' $1')}
</Text>
<Checkbox
size="xs"
@@ -656,18 +689,21 @@ export function LicenseReviewPage() {
</Group>
<Table withTableBorder>
<Table.Tbody>
{Object.entries(values ?? {}).map(([k, v]) => (
{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">
{k}
{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] && (
@@ -704,7 +740,8 @@ export function LicenseReviewPage() {
/>
)}
</Card>
))}
);
})}
</Stack>
</Tabs.Panel>
@@ -764,22 +801,29 @@ export function LicenseReviewPage() {
</Tabs.Panel>
<Tabs.Panel value="staff">
<Card withBorder padding="md">
<AdvancedTable
columns={reviewStaffColumns(t, {
flags,
onToggleFlag: (member) => toggleFlag('STAFF', member.id),
onRemarkChange: (member, remark) =>
setFlags((p) => ({
...p,
[member.id]: { ...p[member.id], remark },
})),
})}
data={data.staff}
rowKey={(member) => member.id}
onRefresh={refetch}
/>
</Card>
<AdvancedTable
tableName={t('review.tabs.staff', 'Staff')}
columns={reviewStaffColumns(t, {
flags,
roleName: (member) =>
localized(roleNameByKey.get(member.roleKey)) || member.roleKey,
renderEvidence: (member) => (
<StaffEvidenceCell staffId={member.id} fallback={member.documents} />
),
onToggleFlag: (member) => toggleFlag('STAFF', member.id),
onRemarkChange: (member, remark) =>
setFlags((p) => ({
...p,
[member.id]: { ...p[member.id], remark },
})),
})}
data={staffPaged.rows}
itemCount={staffPaged.itemCount}
pageIndex={staffPaged.pageIndex}
onPageChange={staffTable.setPageIndex}
pageSize={staffTable.pageSize}
refresh={refetch}
/>
</Tabs.Panel>
<Tabs.Panel value="inspection">
@@ -795,7 +839,7 @@ export function LicenseReviewPage() {
<div>
<Text size="sm">
{inspection.scheduledDate
? new Date(inspection.scheduledDate).toLocaleString(i18n.language)
? showDate(inspection.scheduledDate)
: t('review.unscheduled', 'Not scheduled')}
</Text>
{inspection.findings && (
@@ -808,7 +852,11 @@ export function LicenseReviewPage() {
variant="light"
color={inspection.result === 'FAILED' ? 'red' : 'teal'}
>
{inspection.result ?? inspection.status}
{inspection.result === 'PASSED'
? t('review.passed', 'Passed')
: inspection.result === 'FAILED'
? t('review.failed', 'Failed')
: t(`review.inspectionStatus.${inspection.status}`, inspection.status)}
</Badge>
</Group>
))}
@@ -863,13 +911,13 @@ export function LicenseReviewPage() {
title={t('review.actions.scheduleInspection', 'Schedule inspection')}
>
<Stack>
<TextInput
type="datetime-local"
<AmharicDatePicker
label={t('review.dateTime', 'Date and time')}
value={inspectionDate}
onChange={(e) => setInspectionDate(e.currentTarget.value)}
onChange={setInspectionDate}
withTime
/>
<Group justify="flex-end">
<ModalFooter>
<Tooltip
label={t('review.pickDate', 'Pick a date and time first')}
disabled={Boolean(inspectionDate)}
@@ -891,7 +939,7 @@ export function LicenseReviewPage() {
run(async () => {
await scheduleInspection({
applicationId: id,
scheduledDate: new Date(inspectionDate).toISOString(),
scheduledDate: inspectionDate,
}).unwrap();
setInspectionOpen(false);
}, t('review.done.scheduled', 'Inspection scheduled'))
@@ -899,7 +947,7 @@ export function LicenseReviewPage() {
>
<IconCheck size={18} />
</ActionIcon>
</Group>
</ModalFooter>
</Stack>
</Modal>
@@ -914,7 +962,7 @@ export function LicenseReviewPage() {
<Stack gap={6}>
{INSPECTION_CHECKLIST_ITEMS.map((item) => (
<Group key={item.key} justify="space-between" wrap="nowrap">
<Text size="sm">{item.label}</Text>
<Text size="sm">{t(item.labelKey, item.fallback)}</Text>
<SegmentedControl
size="xs"
value={checklist[item.key] ?? 'PASS'}
@@ -947,7 +995,7 @@ export function LicenseReviewPage() {
autosize
minRows={3}
/>
<Group grow>
<ModalFooter grow>
<ActionIcon
variant="light"
color="teal"
@@ -996,13 +1044,58 @@ export function LicenseReviewPage() {
>
<IconX size={18} />
</ActionIcon>
</Group>
</ModalFooter>
</Stack>
</Modal>
</Container>
);
}
/**
* Evidence badges for one staff member.
*
* `application-for-review` nests a `documents` array per staff member, but it
* doesn't always carry the uploaded file (the portal's own upload widget
* hits the attachments endpoint directly for the same reason). Query
* attachments by owner here too, so the officer gets a working link instead
* of a badge with nowhere to go.
*/
function StaffEvidenceCell({
staffId,
fallback,
}: {
staffId: string;
fallback?: { id: string; documentKey: string; files: { url?: string }[] }[];
}) {
const { data: attachments } = useGetAttachmentsQuery({
ownerType: 'APPLICATION_STAFF',
ownerId: staffId,
});
const docs = attachments?.length ? attachments : (fallback ?? []);
return (
<Group gap={4}>
{docs.map((doc) => {
const url = doc.files?.[0]?.url;
return (
<Badge
key={doc.id}
size="xs"
variant="light"
component={url ? 'a' : undefined}
href={url}
target={url ? '_blank' : undefined}
rel={url ? 'noreferrer' : undefined}
style={url ? { cursor: 'pointer' } : undefined}
>
{doc.documentKey}
</Badge>
);
})}
</Group>
);
}
function SummaryRow({ label, value }: { label: string; value?: string | null }) {
return (
<Group justify="space-between" gap="xs" wrap="nowrap">