mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
- Added `useDateDisplayer` hook and `dateDisplayer` function to format dates consistently based on the user's language. - Updated multiple components and pages in both backoffice and portal applications to utilize the new date display functionality, ensuring proper formatting for dates in lists, tables, and detail views. - Introduced Ethiopian date formatting for Amharic language support. - Refactored date handling in components such as ExamAppealsPage, ResultPage, SeafarerRegistryPage, and others to improve localization and user experience.
183 lines
5.5 KiB
TypeScript
183 lines
5.5 KiB
TypeScript
import { useMemo } from 'react';
|
|
import {
|
|
Badge,
|
|
Group,
|
|
Paper,
|
|
ScrollArea,
|
|
Text,
|
|
Timeline,
|
|
Tooltip,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconFileUpload,
|
|
IconMessage,
|
|
IconArrowRight,
|
|
IconUserCheck,
|
|
} from '@tabler/icons-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
STATUS_COLORS,
|
|
STATUS_LABELS,
|
|
type ApplicationDetail,
|
|
} from '@ema-platform/api';
|
|
import { useDateDisplayer } from '@ema-platform/shared';
|
|
|
|
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
|
|
|
|
interface ActivityEntry {
|
|
id: string;
|
|
kind: EntryKind;
|
|
at: string;
|
|
actor: string;
|
|
title: string;
|
|
detail?: string;
|
|
color?: string;
|
|
}
|
|
|
|
const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
|
status: IconArrowRight,
|
|
remark: IconMessage,
|
|
upload: IconFileUpload,
|
|
assignment: IconUserCheck,
|
|
};
|
|
|
|
/**
|
|
* Chronological record of everything that has happened to an application.
|
|
*
|
|
* Merged client-side from the three collections the detail endpoint already
|
|
* returns — status transitions, officer remarks and document uploads. There is
|
|
* no single activity-feed endpoint, so this is assembled rather than fetched;
|
|
* the trade-off is that it can only show what the detail payload carries, and
|
|
* notifications sent to the applicant are not among them.
|
|
*/
|
|
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
|
const { t } = useTranslation();
|
|
const showDate = useDateDisplayer();
|
|
|
|
const entries = useMemo<ActivityEntry[]>(() => {
|
|
const merged: ActivityEntry[] = [];
|
|
|
|
for (const history of detail.history ?? []) {
|
|
// A transition that does not move the status is a workflow control
|
|
// (assignment, escalation), not a decision — labelled as such so the
|
|
// trail does not read as "Under Review → Under Review".
|
|
const isAssignment = history.fromStatus === history.toStatus;
|
|
merged.push({
|
|
id: `status-${history.id}`,
|
|
kind: isAssignment ? 'assignment' : 'status',
|
|
at: history.createdAt,
|
|
actor: history.actorName ?? t('review.activity.system', 'System'),
|
|
title: isAssignment
|
|
? t(`review.events.${history.event}`, {
|
|
defaultValue: history.event,
|
|
})
|
|
: `${history.fromStatus ? STATUS_LABELS[history.fromStatus] : '—'} → ${
|
|
STATUS_LABELS[history.toStatus]
|
|
}`,
|
|
detail: history.remark ?? undefined,
|
|
color: STATUS_COLORS[history.toStatus],
|
|
});
|
|
}
|
|
|
|
for (const remark of detail.remarks ?? []) {
|
|
merged.push({
|
|
id: `remark-${remark.id}`,
|
|
kind: 'remark',
|
|
at: remark.createdAt,
|
|
actor: t('review.activity.officer', 'Officer'),
|
|
title: t('review.activity.remarkOn', {
|
|
target: remark.targetKey,
|
|
defaultValue: 'Correction requested on {{target}}',
|
|
}),
|
|
detail: remark.remark,
|
|
color: remark.resolvedAt ? 'teal' : 'orange',
|
|
});
|
|
}
|
|
|
|
for (const attachment of detail.attachments ?? []) {
|
|
const file = attachment.files?.[0];
|
|
if (!file) continue;
|
|
merged.push({
|
|
id: `upload-${attachment.id}`,
|
|
kind: 'upload',
|
|
at: attachment.createdAt ?? detail.application.createdAt,
|
|
actor: t('review.activity.applicant', 'Applicant'),
|
|
title: t('review.activity.uploaded', {
|
|
document: attachment.documentKey,
|
|
defaultValue: 'Uploaded {{document}}',
|
|
}),
|
|
detail: file.originalName,
|
|
color: 'blue',
|
|
});
|
|
}
|
|
|
|
// Newest first: an officer opening a review wants the latest state, not
|
|
// the application's origin story.
|
|
return merged.sort(
|
|
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
|
|
);
|
|
}, [detail, t]);
|
|
|
|
if (entries.length === 0) {
|
|
return (
|
|
<Paper withBorder p="md">
|
|
<Text size="sm" c="dimmed">
|
|
{t('review.activity.empty', 'No activity recorded yet.')}
|
|
</Text>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Paper withBorder p="md" h="100%">
|
|
<Group justify="space-between" mb="sm">
|
|
<Text fw={600} size="sm">
|
|
{t('review.activity.title', 'Activity & audit trail')}
|
|
</Text>
|
|
<Badge variant="light" size="sm">
|
|
{entries.length}
|
|
</Badge>
|
|
</Group>
|
|
|
|
<ScrollArea.Autosize mah={520} type="hover" offsetScrollbars>
|
|
<Timeline bulletSize={20} lineWidth={2}>
|
|
{entries.map((entry) => {
|
|
const EntryIcon = ICONS[entry.kind];
|
|
return (
|
|
<Timeline.Item
|
|
key={entry.id}
|
|
bullet={<EntryIcon size={12} />}
|
|
color={entry.color}
|
|
title={
|
|
<Text size="xs" fw={600}>
|
|
{entry.title}
|
|
</Text>
|
|
}
|
|
>
|
|
<Group gap={4} wrap="nowrap">
|
|
<Text size="xs" c="dimmed" truncate>
|
|
{entry.actor}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
·
|
|
</Text>
|
|
<Tooltip label={showDate(entry.at)} withArrow>
|
|
<Text size="xs" c="dimmed">
|
|
{showDate(entry.at.slice(0, 10))}
|
|
</Text>
|
|
</Tooltip>
|
|
</Group>
|
|
{entry.detail && (
|
|
<Text size="xs" mt={2}>
|
|
{entry.detail}
|
|
</Text>
|
|
)}
|
|
</Timeline.Item>
|
|
);
|
|
})}
|
|
</Timeline>
|
|
</ScrollArea.Autosize>
|
|
</Paper>
|
|
);
|
|
}
|