mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user