Files
emaui/apps/backoffice/src/app/features/license-review/components/ActivityRail.tsx
estifanos 9897a9bf78 feat: implement date display utility across various pages
- 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.
2026-08-12 10:22:39 +00:00

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