Merge pull request #11 from Tria-plc/Refactor

Refactor
This commit is contained in:
Nati Nigussie
2026-08-13 14:38:21 +03:00
committed by GitHub
71 changed files with 3408 additions and 2596 deletions

View File

@@ -0,0 +1,29 @@
import { ActionIcon, Group } from '@mantine/core';
import { IconEdit, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Certification } from '../../types/certification';
export function certificationActionsColumn(
t: TFunction,
handlers: {
onEdit: (cert: Certification) => void;
onDelete: (cert: Certification) => void;
},
): AdvancedColumn<Certification> {
return {
header: '',
label: t('certification.columns.actions', 'Actions'),
size: 90,
cell: ({ row }) => (
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(row.original)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(row.original)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
),
};
}

View File

@@ -0,0 +1,28 @@
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Certification } from '../../types/certification';
export function certificationColumns(
t: TFunction,
locale: 'en' | 'am',
): AdvancedColumn<Certification>[] {
return [
{
header: t('certification.columns.name'),
cell: ({ row }) => <Text fz="sm" fw={500}>{row.original.name[locale]}</Text>,
},
{
header: t('certification.columns.description'),
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
},
{
header: t('certification.columns.status'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={row.original.isActive ? 'teal' : 'gray'}>
{row.original.isActive ? t('certification.status.active') : t('certification.status.inactive')}
</Badge>
),
},
];
}

View File

@@ -4,8 +4,6 @@ import {
Title,
Group,
Button,
Badge,
ActionIcon,
Modal,
Text,
TextInput,
@@ -15,15 +13,17 @@ import {
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
import {
useGetCertificationsQuery,
useCreateCertificationMutation,
useUpdateCertificationMutation,
useDeleteCertificationMutation,
} from '../api/certification-api';
import type { Certification } from '../types/certification';
} from '../../api/certification-api';
import type { Certification } from '../../types/certification';
import { certificationColumns } from './columns';
import { certificationActionsColumn } from './actions';
function CertificationForm({
editing,
@@ -122,38 +122,12 @@ export function CertificationPage() {
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
const columns: AdvancedColumn<Certification>[] = [
{
header: t('certification.columns.name'),
cell: ({ row }) => <Text fz="sm" fw={500}>{row.original.name[locale]}</Text>,
},
{
header: t('certification.columns.description'),
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
},
{
header: t('certification.columns.status'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={row.original.isActive ? 'teal' : 'gray'}>
{row.original.isActive ? t('certification.status.active') : t('certification.status.inactive')}
</Badge>
),
},
{
header: '',
label: t('certification.columns.actions', 'Actions'),
size: 90,
cell: ({ row }) => (
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(row.original); setShowForm(true); }}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(row.original); openDelete(); }}>
<IconTrash size={14} />
</ActionIcon>
</Group>
),
},
const columns = [
...certificationColumns(t, locale),
certificationActionsColumn(t, {
onEdit: (cert) => { setEditing(cert); setShowForm(true); },
onDelete: (cert) => { setDeleteTarget(cert); openDelete(); },
}),
];
const page = paginate(certifications);

View File

@@ -0,0 +1,34 @@
import { ActionIcon, Group } from "@mantine/core";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import type { AdvancedColumn } from "@ema-platform/ui";
import type { Profession } from "../../types/configuration";
export function professionActionsColumn(handlers: {
onEdit: (prof: Profession) => void;
onDelete: (prof: Profession) => void;
}): AdvancedColumn<Profession> {
return {
header: "actions",
size: 90,
cell: ({ row }) => (
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => handlers.onEdit(row.original)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDelete(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
),
};
}

View File

@@ -0,0 +1,30 @@
import { Text } from "@mantine/core";
import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui";
import type { Profession } from "../../types/configuration";
export function professionColumns(
t: TFunction,
locale: "en" | "am",
getDeptName: (deptId: string) => string,
): AdvancedColumn<Profession>[] {
return [
{
header: t("configuration.name"),
cell: ({ row }) => row.original.name[locale],
enabled: true,
},
{
header: t("configuration.description"),
cell: ({ row }) => (
<Text size="sm" lineClamp={2} maw={200}>
{row.original.description[locale]}
</Text>
),
},
{
header: t("configuration.department"),
cell: ({ row }) => getDeptName(row.original.departmentId),
},
];
}

View File

@@ -7,7 +7,6 @@ import {
Button,
TextInput,
Textarea,
ActionIcon,
Modal,
Text,
Select,
@@ -18,8 +17,6 @@ import {
import { useForm } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
import {
IconEdit,
IconTrash,
IconPlus,
IconBriefcase,
IconMap,
@@ -33,18 +30,19 @@ import {
AdvancedTable,
useServerTable,
ModalFooter,
type AdvancedColumn,
} from "@ema-platform/ui";
import { LocationPage } from "../../location/pages/LocationPage";
import { CertificationPage } from "../../certification/pages/CertificationPage";
import { LocationPage } from "../../../location/pages/LocationPage";
import { CertificationPage } from "../../../certification/pages/CertificationPage";
import {
useGetOrganizationsQuery,
useGetProfessionsQuery,
useCreateProfessionMutation,
useUpdateProfessionMutation,
useDeleteProfessionMutation,
} from "../api/configuration-api";
import type { Profession } from "../types/configuration";
} from "../../api/configuration-api";
import type { Profession } from "../../types/configuration";
import { professionColumns } from "./columns";
import { professionActionsColumn } from "./actions";
interface ProfFormValues {
nameEn: string;
@@ -285,48 +283,12 @@ function ProfessionTab() {
[departments, locale],
);
const professionColumns: AdvancedColumn<Profession>[] = [
{
header: t("configuration.name"),
cell: ({ row }) => row.original.name[locale],
enabled: true,
},
{
header: t("configuration.description"),
cell: ({ row }) => (
<Text size="sm" lineClamp={2} maw={200}>
{row.original.description[locale]}
</Text>
),
},
{
header: t("configuration.department"),
cell: ({ row }) => getDeptName(row.original.departmentId),
},
{
header: "actions",
size: 90,
cell: ({ row }) => (
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => handleEditProf(row.original)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handleDeleteProf(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
),
},
const columns = [
...professionColumns(t, locale, getDeptName),
professionActionsColumn({
onEdit: handleEditProf,
onDelete: handleDeleteProf,
}),
];
if (isLoading) {
@@ -374,7 +336,7 @@ function ProfessionTab() {
)}
<AdvancedTable
columns={professionColumns}
columns={columns}
data={professions}
tableName={t("configuration.professionsList")}
itemCount={totalCount}

View File

@@ -0,0 +1,31 @@
import { Badge, Text } from '@mantine/core';
import type { AdvancedColumn } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api';
export const dashboardQueueColumns: AdvancedColumn<LicenseApplication>[] = [
{
header: 'Number',
cell: ({ row }) => (
<Text size="sm" fw={500}>
{row.original.applicationNumber}
</Text>
),
},
{
header: 'Company',
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
},
{
header: 'Status',
cell: ({ row }) => (
<Badge variant="light" color={STATUS_COLORS[row.original.status as LicenseStatus]}>
{STATUS_LABELS[row.original.status as LicenseStatus]}
</Badge>
),
},
];

View File

@@ -1,24 +1,18 @@
import { useNavigate } from 'react-router-dom';
import {
Badge,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Table,
Text,
Title,
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import {
STATUS_COLORS,
STATUS_LABELS,
useGetAssignedToMeQuery,
useGetQueueQuery,
type LicenseStatus,
} from '@ema-platform/api';
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
import { dashboardQueueColumns } from './columns';
/**
* Backoffice home.
@@ -31,6 +25,7 @@ export function DashboardPage() {
const navigate = useNavigate();
const queue = useGetQueueQuery();
const mine = useGetAssignedToMeQuery();
const table = useServerTable();
if (queue.isLoading || mine.isLoading) {
return (
@@ -43,6 +38,7 @@ export function DashboardPage() {
const unclaimed = queue.data?.items ?? [];
const inProgress = mine.data?.items ?? [];
const all = [...unclaimed, ...inProgress];
const paged = table.paginate(unclaimed.slice(0, 8));
const stats = [
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
@@ -95,49 +91,18 @@ export function DashboardPage() {
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
</Text>
</Group>
{unclaimed.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
Nothing waiting to be claimed.
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Number</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{unclaimed.slice(0, 8).map((app) => (
<Table.Tr
key={app.id}
style={{ cursor: 'pointer' }}
onClick={() => navigate('/licence-review')}
>
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={STATUS_COLORS[app.status as LicenseStatus]}
>
{STATUS_LABELS[app.status as LicenseStatus]}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<AdvancedTable
tableName="Awaiting claim"
columns={dashboardQueueColumns}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
onRowClick={() => navigate('/licence-review')}
refresh={queue.refetch}
emptyText="Nothing waiting to be claimed."
/>
</Card>
</Container>
);

View File

@@ -0,0 +1,92 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconUserCheck } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
REGISTERED: 'gray',
PRESENT: 'teal',
LATE: 'yellow',
ABSENT: 'red',
WITHDRAWN: 'orange',
DISQUALIFIED: 'red',
};
export const candidateName = (registration: ExamRegistration) =>
[
registration.profile?.firstName,
registration.profile?.middleName,
registration.profile?.lastName,
]
.filter(Boolean)
.join(' ') || registration.profileId.slice(0, 8);
export function examCandidateColumns(
t: TFunction,
handlers: { onRecord: (registration: ExamRegistration) => void },
): AdvancedColumn<ExamRegistration>[] {
return [
{
header: t('exam.candidates.admission'),
cell: ({ row }) => (
<Text fz="sm" ff="monospace" fw={600}>
{row.original.admissionNumber}
</Text>
),
},
{
header: t('exam.candidates.name'),
cell: ({ row }) => <Text fz="sm">{candidateName(row.original)}</Text>,
},
{
header: t('exam.candidates.attempt'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'}
>
{row.original.kind === 'RETAKE'
? t('exam.candidates.retake', { n: row.original.attemptNumber })
: t('exam.candidates.firstSitting')}
</Badge>
),
},
{
header: t('exam.candidates.attendance'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'}
>
{t(`exam.attendance.${row.original.attendanceStatus}`)}
</Badge>
),
},
{
header: t('exam.candidates.remark'),
cell: ({ row }) => (
<Text fz="xs" c="dimmed" maw={220} lineClamp={2}>
{row.original.attendanceRemark ?? '—'}
</Text>
),
},
{
header: '',
label: t('exam.candidates.record'),
align: 'right',
cell: ({ row }) => (
<Button
size="compact-xs"
variant="light"
leftSection={<IconUserCheck size={12} />}
onClick={() => handlers.onRecord(row.original)}
>
{t('exam.candidates.record')}
</Button>
),
},
];
}

View File

@@ -2,35 +2,25 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Button,
Group,
Modal,
Paper,
Select,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconInfoCircle, IconUserCheck } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { IconInfoCircle } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import {
useGetExamRegistrationsQuery,
useRecordAttendanceMutation,
} from '../api/exam-api';
import type { AttendanceStatus, ExamRegistration } from '../types/exam';
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
REGISTERED: 'gray',
PRESENT: 'teal',
LATE: 'yellow',
ABSENT: 'red',
WITHDRAWN: 'orange',
DISQUALIFIED: 'red',
};
} from '../../api/exam-api';
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
import { candidateName, examCandidateColumns } from './columns';
/** Rulings that end the sitting, and so must be explained (US-EXAM-009). */
const NEEDS_REMARK: AttendanceStatus[] = ['WITHDRAWN', 'DISQUALIFIED'];
@@ -51,20 +41,22 @@ const OPTIONS: AttendanceStatus[] = [
*/
export function ExamCandidatesPanel({ examId }: { examId: string }) {
const { t } = useTranslation();
const { data: registrations, isError } = useGetExamRegistrationsQuery(examId);
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
const [target, setTarget] = useState<ExamRegistration | null>(null);
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
const [remark, setRemark] = useState('');
const table = useServerTable();
const candidateName = (registration: ExamRegistration) =>
[
registration.profile?.firstName,
registration.profile?.middleName,
registration.profile?.lastName,
]
.filter(Boolean)
.join(' ') || registration.profileId.slice(0, 8);
const startRecording = (registration: ExamRegistration) => {
setTarget(registration);
setStatus(
registration.attendanceStatus === 'REGISTERED'
? 'PRESENT'
: registration.attendanceStatus,
);
setRemark(registration.attendanceRemark ?? '');
};
const save = async () => {
if (!target) return;
@@ -90,6 +82,8 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
// register; the endpoint refuses them and there is nothing to show.
if (isError) return null;
const paged = table.paginate(registrations ?? []);
return (
<Paper withBorder radius="lg" p="lg">
<Title order={5} mb="md">
@@ -100,75 +94,16 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
{t('exam.candidates.none')}
</Alert>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('exam.candidates.admission')}</Table.Th>
<Table.Th>{t('exam.candidates.name')}</Table.Th>
<Table.Th>{t('exam.candidates.attempt')}</Table.Th>
<Table.Th>{t('exam.candidates.attendance')}</Table.Th>
<Table.Th>{t('exam.candidates.remark')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(registrations ?? []).map((registration) => (
<Table.Tr key={registration.id}>
<Table.Td>
<Text fz="sm" ff="monospace" fw={600}>
{registration.admissionNumber}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{candidateName(registration)}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
>
{registration.kind === 'RETAKE'
? t('exam.candidates.retake', { n: registration.attemptNumber })
: t('exam.candidates.firstSitting')}
</Badge>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'}
>
{t(`exam.attendance.${registration.attendanceStatus}`)}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="xs" c="dimmed" maw={220} lineClamp={2}>
{registration.attendanceRemark ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="light"
leftSection={<IconUserCheck size={12} />}
onClick={() => {
setTarget(registration);
setStatus(
registration.attendanceStatus === 'REGISTERED'
? 'PRESENT'
: registration.attendanceStatus,
);
setRemark(registration.attendanceRemark ?? '');
}}
>
{t('exam.candidates.record')}
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<AdvancedTable
tableName={t('exam.candidates.section')}
columns={examCandidateColumns(t, { onRecord: startRecording })}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
refresh={refetch}
/>
)}
<Modal

View File

@@ -0,0 +1,84 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
OPEN: 'red',
UNDER_REVIEW: 'yellow',
RESOLVED: 'teal',
DISMISSED: 'gray',
};
export function examIncidentColumns(
t: TFunction,
showDate: (date: string | null | undefined) => string,
handlers: { onResolve: (incident: ExamIncident) => void },
): AdvancedColumn<ExamIncident>[] {
return [
{
header: t('exam.incidents.type'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color="orange">
{t(`exam.incidentType.${row.original.type}`)}
</Badge>
),
},
{
header: t('exam.incidents.candidate'),
cell: ({ row }) => (
<Text fz="xs">
{row.original.registration?.admissionNumber ?? t('exam.incidents.wholeRoom')}
</Text>
),
},
{
header: t('exam.incidents.description'),
cell: ({ row }) => (
<>
<Text fz="xs" maw={260} lineClamp={2}>
{row.original.description}
</Text>
{row.original.resolution && (
<Text fz="xs" c="dimmed" maw={260} lineClamp={2}>
{row.original.resolution}
</Text>
)}
</>
),
},
{
header: t('exam.incidents.occurred'),
cell: ({ row }) => <Text fz="xs">{showDate(row.original.occurredAt)}</Text>,
},
{
header: t('exam.incidents.status'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[row.original.status] ?? 'gray'}
>
{t(`exam.incidentStatus.${row.original.status}`)}
</Badge>
),
},
{
header: '',
label: t('exam.incidents.resolve'),
align: 'right',
cell: ({ row }) =>
row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? (
<Button
size="compact-xs"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
onClick={() => handlers.onResolve(row.original)}
>
{t('exam.incidents.resolve')}
</Button>
) : null,
},
];
}

View File

@@ -2,20 +2,18 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Button,
Group,
Modal,
Paper,
Select,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { extractErrorMessage } from '@ema-platform/api';
import {
@@ -23,12 +21,9 @@ import {
useGetExamRegistrationsQuery,
useRecordIncidentMutation,
useResolveIncidentMutation,
} from '../api/exam-api';
import type {
ExamIncident,
ExamIncidentStatus,
ExamIncidentType,
} from '../types/exam';
} from '../../api/exam-api';
import type { ExamIncident, ExamIncidentType } from '../../types/exam';
import { examIncidentColumns } from './columns';
const TYPES: ExamIncidentType[] = [
'MISCONDUCT',
@@ -38,13 +33,6 @@ const TYPES: ExamIncidentType[] = [
'OTHER',
];
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
OPEN: 'red',
UNDER_REVIEW: 'yellow',
RESOLVED: 'teal',
DISMISSED: 'gray',
};
/**
* The session incident log (US-EXAM-010). Invigilators file entries during the
* sitting; a supervisor closes each one with a written ruling that stays on
@@ -53,7 +41,8 @@ const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
export function ExamIncidentsPanel({ examId }: { examId: string }) {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const { data: incidents, isError } = useGetExamIncidentsQuery(examId);
const { data: incidents, isError, refetch } = useGetExamIncidentsQuery(examId);
const table = useServerTable();
const { data: registrations } = useGetExamRegistrationsQuery(examId);
const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation();
const [resolveIncident, { isLoading: isResolving }] = useResolveIncidentMutation();
@@ -98,6 +87,12 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
}
};
const startResolve = (incident: ExamIncident) => {
setResolveTarget(incident);
setOutcome('RESOLVED');
setResolution('');
};
const close = async () => {
if (!resolveTarget || !resolution.trim()) {
notify.error(t('exam.incidents.resolution'));
@@ -121,6 +116,8 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
// endpoint refuses them, so there is nothing to render.
if (isError) return null;
const paged = table.paginate(incidents ?? []);
return (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
@@ -141,74 +138,16 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
{t('exam.incidents.none')}
</Alert>
) : (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('exam.incidents.type')}</Table.Th>
<Table.Th>{t('exam.incidents.candidate')}</Table.Th>
<Table.Th>{t('exam.incidents.description')}</Table.Th>
<Table.Th>{t('exam.incidents.occurred')}</Table.Th>
<Table.Th>{t('exam.incidents.status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(incidents ?? []).map((incident) => (
<Table.Tr key={incident.id}>
<Table.Td>
<Badge size="sm" variant="light" color="orange">
{t(`exam.incidentType.${incident.type}`)}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="xs">
{incident.registration?.admissionNumber ??
t('exam.incidents.wholeRoom')}
</Text>
</Table.Td>
<Table.Td>
<Text fz="xs" maw={260} lineClamp={2}>
{incident.description}
</Text>
{incident.resolution && (
<Text fz="xs" c="dimmed" maw={260} lineClamp={2}>
{incident.resolution}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="xs">{showDate(incident.occurredAt)}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[incident.status] ?? 'gray'}
>
{t(`exam.incidentStatus.${incident.status}`)}
</Badge>
</Table.Td>
<Table.Td>
{(incident.status === 'OPEN' ||
incident.status === 'UNDER_REVIEW') && (
<Button
size="compact-xs"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
onClick={() => {
setResolveTarget(incident);
setOutcome('RESOLVED');
setResolution('');
}}
>
{t('exam.incidents.resolve')}
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<AdvancedTable<ExamIncident>
tableName={t('exam.incidents.section')}
columns={examIncidentColumns(t, showDate, { onResolve: startResolve })}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
refresh={refetch}
/>
)}
<Modal

View File

@@ -13,14 +13,16 @@ import {
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IconSearch } from "@tabler/icons-react";
import type { QuestionBrief, actionTypes } from "../types/exam";
import type { QuestionBrief } from "../types/exam";
type actionTypes = "add" | "remove";
interface QuestionAssignerProps {
available: QuestionBrief[];
assigned: QuestionBrief[];
onChange: (assigned: QuestionBrief[]) => void;
mode?: "manual" | "random";
actions: Dispatch<SetStateAction<actionTypes | undefined>>;
actions?: Dispatch<SetStateAction<actionTypes | undefined>>;
}
function QuestionList({
@@ -143,13 +145,13 @@ export function QuestionAssigner({
const assignSelected = () => {
const toAssign = available.filter((q) => selectedLeft.has(q.id));
onChange([...assigned, ...toAssign]);
actions("add");
actions?.("add");
setSelectedLeft(new Set());
};
const removeSelected = () => {
onChange(assigned.filter((q) => !selectedRight.has(q.id)));
actions("remove");
actions?.("remove");
setSelectedRight(new Set());
};

View File

@@ -39,7 +39,7 @@ import {
IconCheck,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import {
useGetExamQuery,
@@ -456,7 +456,6 @@ export function ExamDetailPage() {
assigned={draftQuestions}
onChange={setDraftQuestions}
mode="manual"
actions={setWhatAction}
/>
<ModalFooter>
<Button variant="default" onClick={closeAssign} size="sm">

View File

@@ -0,0 +1,47 @@
import { ActionIcon, Group } from "@mantine/core";
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui";
import type { Exam } from "../../types/exam";
export function examActionsColumn(
t: TFunction,
handlers: {
onEdit: (exam: Exam) => void;
onDelete: (exam: Exam) => void;
onDetails: (exam: Exam) => void;
},
): AdvancedColumn<Exam> {
return {
header: t("exam.columns.actions"),
align: "right",
cell: ({ row }) => (
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => handlers.onEdit(row.original)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDelete(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDetails(row.original)}
>
<IconDetails size={14} />
</ActionIcon>
</Group>
),
};
}

View File

@@ -0,0 +1,81 @@
import { Badge, Text } from "@mantine/core";
import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui";
import type { Exam } from "../../types/exam";
const STATUS_COLOR: Record<string, string> = {
PENDING: "gray",
ACTIVE: "blue",
COMPLETED: "teal",
CANCELLED: "red",
POSTPONED: "orange",
PUBLISHED: "green",
};
export function examColumns(
t: TFunction,
locale: "en" | "am",
getCertName: (id: string) => string,
onTitleClick: (exam: Exam) => void,
): AdvancedColumn<Exam>[] {
return [
{
header: t("exam.columns.title"),
cell: ({ row }) => (
<Text
fz="sm"
fw={500}
c="blue"
style={{ cursor: "pointer" }}
onClick={() => onTitleClick(row.original)}
>
{row.original.title[locale]}
</Text>
),
},
{
header: t("exam.columns.certification"),
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
},
{
header: t("exam.columns.date"),
cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>,
},
{
header: t("exam.columns.type"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={row.original.type === "WRITTEN" ? "blue" : "orange"}>
{t(`exam.type.${row.original.type}`)}
</Badge>
),
},
{
header: t("exam.columns.form"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={row.original.form === "ESSAY" ? "blue" : "violet"}>
{t(`exam.formType.${row.original.form}`)}
</Badge>
),
},
{
header: t("exam.columns.venue"),
cell: ({ row }) => <Text fz="sm">{row.original.venue}</Text>,
},
{
header: t("exam.columns.questions"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color="gray">
{row.original.questions?.length ?? 0}
</Badge>
),
},
{
header: t("exam.columns.status"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
{t(`exam.status.${row.original.status}`)}
</Badge>
),
},
];
}

View File

@@ -5,8 +5,6 @@ import {
Title,
Group,
Button,
Badge,
ActionIcon,
Modal,
Text,
TextInput,
@@ -17,36 +15,25 @@ import {
NumberInput,
Tabs,
SimpleGrid,
Divider,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import {
IconEdit,
IconTrash,
IconPlus,
IconInfoCircle,
IconClipboardList,
IconDetails,
} from "@tabler/icons-react";
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker, type AdvancedColumn } from "@ema-platform/ui";
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
import {
useGetExamsQuery,
useCreateExamMutation,
useUpdateExamMutation,
useDeleteExamMutation,
} from "../api/exam-api";
import type { Exam } from "../types/exam";
const STATUS_COLOR: Record<string, string> = {
PENDING: "gray",
ACTIVE: "blue",
COMPLETED: "teal",
CANCELLED: "red",
POSTPONED: "orange",
PUBLISHED: "green",
};
} from "../../api/exam-api";
import type { Exam } from "../../types/exam";
import { examColumns } from "./columns";
import { examActionsColumn } from "./actions";
function ExamForm({
editing,
@@ -436,105 +423,19 @@ export function ExamPage() {
/>
);
const columns: AdvancedColumn<Exam>[] = [
{
header: t("exam.columns.title"),
cell: ({ row }) => (
<Text
fz="sm"
fw={500}
c="blue"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/exams/${row.original.id}`)}
>
{row.original.title[locale]}
</Text>
),
},
{
header: t("exam.columns.certification"),
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
},
{
header: t("exam.columns.date"),
cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>,
},
{
header: t("exam.columns.type"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={row.original.type === "WRITTEN" ? "blue" : "orange"}>
{t(`exam.type.${row.original.type}`)}
</Badge>
),
},
{
header: t("exam.columns.form"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={row.original.form === "ESSAY" ? "blue" : "violet"}>
{t(`exam.formType.${row.original.form}`)}
</Badge>
),
},
{
header: t("exam.columns.venue"),
cell: ({ row }) => <Text fz="sm">{row.original.venue}</Text>,
},
{
header: t("exam.columns.questions"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color="gray">
{row.original.questions?.length ?? 0}
</Badge>
),
},
{
header: t("exam.columns.status"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
{t(`exam.status.${row.original.status}`)}
</Badge>
),
},
{
header: t("exam.columns.actions"),
align: "right",
cell: ({ row }) => (
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => {
setEditing(row.original);
setShowForm(true);
}}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => {
setDeleteTarget(row.original);
openDelete();
}}
>
<IconTrash size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => {
navigate(`/exams/${row.original.id}`);
}}
>
<IconDetails size={14} />
</ActionIcon>
</Group>
),
},
const columns = [
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
examActionsColumn(t, {
onEdit: (exam) => {
setEditing(exam);
setShowForm(true);
},
onDelete: (exam) => {
setDeleteTarget(exam);
openDelete();
},
onDetails: (exam) => navigate(`/exams/${exam.id}`),
}),
];
const page = paginate(exams);

View File

@@ -1,63 +0,0 @@
import { Table, Badge, ActionIcon, Text } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../api/item-api';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const STATUS_COLORS: Record<Item['status'], string> = {
DRAFT: 'gray',
ACTIVE: 'green',
ARCHIVED: 'orange',
};
export function ItemTable() {
const { data, isLoading } = useGetItemsQuery({});
const [deleteItem] = useDeleteItemMutation();
const { handleError } = useErrorHandler();
const showDate = useDateDisplayer();
const handleDelete = async (id: string) => {
try {
await deleteItem(id).unwrap();
notify.success('Item deleted');
} catch (e) {
handleError(e);
}
};
if (isLoading) return <Text>Loading...</Text>;
if (!data?.data.length) return <Text c="dimmed">No items found.</Text>;
return (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.data.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>{item.name}</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge>
</Table.Td>
<Table.Td>{showDate(item.createdAt)}</Table.Td>
<Table.Td>
<ActionIcon
color="red"
variant="subtle"
onClick={() => handleDelete(item.id)}
>
<IconTrash size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}

View File

@@ -0,0 +1,28 @@
import { ActionIcon, Group, Tooltip } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Item } from '../../api/item-api';
export function itemActionsColumn(handlers: {
onDelete: (item: Item) => void;
}): AdvancedColumn<Item> {
return {
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => (
<Group gap="xs" wrap="nowrap" justify="flex-end">
<Tooltip label="Delete">
<ActionIcon
color="red"
variant="subtle"
aria-label="Delete"
onClick={() => handlers.onDelete(row.original)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
),
};
}

View File

@@ -0,0 +1,25 @@
import { Badge } from '@mantine/core';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Item } from '../../api/item-api';
const STATUS_COLORS: Record<Item['status'], string> = {
DRAFT: 'gray',
ACTIVE: 'green',
ARCHIVED: 'orange',
};
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
return [
{ header: 'Name', accessorKey: 'name' },
{
header: 'Status',
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]}>{row.original.status}</Badge>
),
},
{
header: 'Created',
cell: ({ row }) => showDate(row.original.createdAt),
},
];
}

View File

@@ -0,0 +1,39 @@
import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../../api/item-api';
import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { itemColumns } from './columns';
import { itemActionsColumn } from './actions';
export function ItemTable() {
const { data, isLoading, refetch } = useGetItemsQuery({});
const [deleteItem] = useDeleteItemMutation();
const { handleError } = useErrorHandler();
const showDate = useDateDisplayer();
const table = useServerTable();
const handleDelete = async (item: Item) => {
try {
await deleteItem(item.id).unwrap();
notify.success('Item deleted');
} catch (e) {
handleError(e);
}
};
const paged = table.paginate(data?.data ?? []);
return (
<AdvancedTable<Item>
tableName="Items"
columns={[...itemColumns(showDate), itemActionsColumn({ onDelete: handleDelete })]}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
isLoading={isLoading}
refresh={refetch}
emptyText="No items found"
/>
);
}

View File

@@ -0,0 +1,100 @@
import { Badge, Button, Text, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
const LICENSE_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
EXPIRED: 'yellow',
SUSPENDED: 'orange',
CANCELLED: 'red',
SUPERSEDED: 'gray',
};
export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
/** Which lifecycle actions make sense from each current status. */
export function actionsFor(license: IssuedLicense): LifecycleAction[] {
switch (license.status) {
case 'ACTIVE':
case 'EXPIRED':
return ['suspend', 'revoke'];
case 'SUSPENDED':
return ['reinstate', 'revoke'];
default:
return [];
}
}
export function licenseRegisterColumns(
localized: (value: Bilingual | undefined) => string,
showDate: (date: string | null | undefined) => string,
handlers: { onStatus: (license: IssuedLicense) => void },
): AdvancedColumn<IssuedLicense>[] {
return [
{
header: 'Certificate №',
cell: ({ row }) => (
<Text size="sm" ff="monospace" fw={600}>
{row.original.certificateNumber}
</Text>
),
},
{
header: 'Type',
cell: ({ row }) => (
<Text size="sm">{localized(row.original.licenseType?.name)}</Text>
),
},
{
header: 'Holder',
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
},
{
header: 'Issued',
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{showDate(row.original.issueDate)}
</Text>
),
},
{
header: 'Expires',
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{showDate(row.original.expiryDate)}
</Text>
),
},
{
header: 'Status',
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={LICENSE_STATUS_COLORS[row.original.status] ?? 'gray'}
>
{row.original.status}
</Badge>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) =>
actionsFor(row.original).length > 0 ? (
<Tooltip label="Suspend / revoke / reinstate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => handlers.onStatus(row.original)}
>
Status
</Button>
</Tooltip>
) : null,
},
];
}

View File

@@ -1,24 +1,19 @@
import { useState } from 'react';
import {
Badge,
Button,
Card,
Center,
Container,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import { IconSearch, IconShieldCog } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { IconSearch } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -29,16 +24,11 @@ import {
useSuspendLicenseMutation,
} from '@ema-platform/api';
import type { IssuedLicense } from '@ema-platform/api';
const LICENSE_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
EXPIRED: 'yellow',
SUSPENDED: 'orange',
CANCELLED: 'red',
SUPERSEDED: 'gray',
};
type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
import {
actionsFor,
licenseRegisterColumns,
type LifecycleAction,
} from './columns';
const ACTIONS: Record<
LifecycleAction,
@@ -61,19 +51,6 @@ const ACTIONS: Record<
},
};
/** Which lifecycle actions make sense from each current status. */
function actionsFor(license: IssuedLicense): LifecycleAction[] {
switch (license.status) {
case 'ACTIVE':
case 'EXPIRED':
return ['suspend', 'revoke'];
case 'SUSPENDED':
return ['reinstate', 'revoke'];
default:
return [];
}
}
function LifecycleModal({
license,
onClose,
@@ -161,7 +138,7 @@ function LifecycleModal({
*/
export function LicenseRegisterPage() {
const [search, setSearch] = useState('');
const { data, isLoading } = useGetLicensesQuery(
const { data, isLoading, refetch } = useGetLicensesQuery(
search.trim() ? { search: search.trim() } : undefined,
);
const [target, setTarget] = useState<IssuedLicense | null>(null);
@@ -169,6 +146,8 @@ export function LicenseRegisterPage() {
const items = data?.items ?? [];
const showDate = useDateDisplayer();
const localized = useLocalized();
const table = useServerTable();
const paged = table.paginate(items);
return (
<Container size="xl" py="md">
@@ -189,83 +168,20 @@ export function LicenseRegisterPage() {
</Group>
<Card withBorder padding={0}>
{isLoading ? (
<Center h={200}>
<Loader />
</Center>
) : items.length === 0 ? (
<Center h={160}>
<Text size="sm" c="dimmed">
{search ? 'No licences match that search.' : 'No licences issued yet.'}
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Certificate </Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Holder</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Expires</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((license) => (
<Table.Tr key={license.id}>
<Table.Td>
<Text size="sm" ff="monospace" fw={600}>
{license.certificateNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{localized(license.licenseType?.name)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{license.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{showDate(license.issueDate)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{showDate(license.expiryDate)}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={LICENSE_STATUS_COLORS[license.status] ?? 'gray'}
>
{license.status}
</Badge>
</Table.Td>
<Table.Td>
{actionsFor(license).length > 0 && (
<Tooltip label="Suspend / revoke / reinstate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => setTarget(license)}
>
Status
</Button>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<AdvancedTable<IssuedLicense>
tableName="Licence register"
columns={licenseRegisterColumns(localized, showDate, { onStatus: setTarget })}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
isLoading={isLoading}
refresh={refetch}
emptyText={
search ? 'No licences match that search.' : 'No licences issued yet.'
}
/>
</Card>
<LifecycleModal license={target} onClose={() => setTarget(null)} />

View File

@@ -0,0 +1,39 @@
import { Button } from "@mantine/core";
import type { TFunction } from "i18next";
import type { LicenseApplication } from "@ema-platform/api";
import type { AdvancedColumn } from "@ema-platform/ui";
export function licenseQueueActionsColumn(
t: TFunction,
handlers: {
claiming: boolean;
onClaim: (id: string) => void;
onOpen: (id: string) => void;
},
): AdvancedColumn<LicenseApplication> {
return {
header: "",
label: t("queue.actionsColumn", "Actions"),
align: "right",
size: 140,
cell: ({ row }) =>
row.original.assignedOfficerId === null &&
row.original.status === "SUBMITTED" ? (
<Button
size="xs"
loading={handlers.claiming}
onClick={() => handlers.onClaim(row.original.id)}
>
{t("queue.claim", "Claim")}
</Button>
) : (
<Button
size="xs"
variant="light"
onClick={() => handlers.onOpen(row.original.id)}
>
{t("queue.review", "Review")}
</Button>
),
};
}

View File

@@ -0,0 +1,142 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { Badge, Checkbox, Text, Tooltip } from "@mantine/core";
import type { TFunction } from "i18next";
import {
APPLICANT_NAME_TYPE_KEYS,
STATUS_COLORS,
STATUS_LABELS,
applicantOrCompanyName,
localized,
type LicenseApplication,
type QueueFilter,
} from "@ema-platform/api";
import type { AdvancedColumn } from "@ema-platform/ui";
import { dateDisplayer } from "@ema-platform/shared";
import { computeSla } from "../../sla";
export function licenseQueueColumns(
t: TFunction,
locale: string,
opts: {
typeCode: string | undefined;
items: LicenseApplication[];
selected: string[];
setSelected: Dispatch<SetStateAction<string[]>>;
allSelected: boolean;
sortableHeader: (
label: string,
field: NonNullable<QueueFilter["sortBy"]>,
) => ReactNode;
},
): AdvancedColumn<LicenseApplication>[] {
const { typeCode, items, selected, setSelected, allSelected, sortableHeader } =
opts;
return [
{
header: (
<Checkbox
aria-label={t("queue.selectAll", "Select all")}
checked={allSelected}
indeterminate={selected.length > 0 && !allSelected}
onChange={() =>
setSelected(allSelected ? [] : items.map((a) => a.id))
}
/>
),
size: 40,
cell: ({ row }) => (
<Checkbox
aria-label={t("queue.selectRow", {
number: row.original.applicationNumber,
defaultValue: "Select {{number}}",
})}
checked={selected.includes(row.original.id)}
onChange={(e) => {
const checked = e.currentTarget.checked;
setSelected((prev) =>
checked
? [...prev, row.original.id]
: prev.filter((id) => id !== row.original.id),
);
}}
/>
),
},
{
header: sortableHeader(t("queue.number", "App #"), "applicationNumber"),
label: t("queue.number", "App #"),
cell: ({ row }) => (
<Text size="sm" fw={500}>
{row.original.applicationNumber}
</Text>
),
},
{
header: sortableHeader(
typeCode && APPLICANT_NAME_TYPE_KEYS.includes(typeCode)
? t("queue.applicant", "Applicant")
: t("queue.company", "Company"),
"companyName",
),
label: t("queue.company", "Company"),
cell: ({ row }) => (
<Text size="sm">{applicantOrCompanyName(row.original) ?? "—"}</Text>
),
},
{
header: t("queue.tin", "TIN"),
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.tinNumber ?? "—"}
</Text>
),
},
{
header: t("queue.typeCol", "Type"),
cell: ({ row }) => (
<Text size="sm">
{localized(row.original.licenseType?.name, locale) || "—"}
</Text>
),
},
{
header: sortableHeader(t("queue.statusCol", "Status"), "status"),
label: t("queue.statusCol", "Status"),
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{t(
`queue.statusValues.${row.original.status}`,
STATUS_LABELS[row.original.status],
)}
</Badge>
),
},
{
header: sortableHeader(
t("queue.submitted", "Submitted"),
"submittedAt",
),
label: t("queue.submitted", "Submitted"),
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{dateDisplayer(row.original.submittedAt, locale)}
</Text>
),
},
{
header: t("queue.sla", "Age / SLA"),
cell: ({ row }) => {
// 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(row.original, undefined, locale, (key, options) => t(key, options as any) as string);
return (
// Colour is never the only signal — the label says the same thing.
<Tooltip label={sla.tooltip} withArrow>
<Badge color={sla.color} variant="light" size="sm">
{sla.label}
</Badge>
</Tooltip>
);
},
},
];
}

View File

@@ -4,7 +4,6 @@ import {
Badge,
Button,
Card,
Checkbox,
Container,
Group,
MultiSelect,
@@ -19,7 +18,6 @@ import {
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
@@ -33,10 +31,7 @@ import {
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
APPLICANT_NAME_TYPE_KEYS,
STATUS_COLORS,
STATUS_LABELS,
applicantOrCompanyName,
extractErrorMessage,
localized,
useClaimApplicationMutation,
@@ -57,8 +52,6 @@ import {
AmharicDatePicker,
type AdvancedColumn,
} from "@ema-platform/ui";
import { dateDisplayer } from "@ema-platform/shared";
import { computeSla } from "../sla";
import {
DEFAULT_VIEW,
SAVED_VIEWS,
@@ -67,11 +60,13 @@ import {
searchParamsFromFilter,
writeLastView,
type SavedViewId,
} from "../queue-views";
import { exportApplicationsCsv } from "../export";
import { setDensity } from "../../../store/preferences.slice";
import { useAppDispatch, useAppSelector } from "../../../store/hooks";
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../useQueueKeyboard";
} from "../../queue-views";
import { exportApplicationsCsv } from "../../export";
import { setDensity } from "../../../../store/preferences.slice";
import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
import { licenseQueueColumns } from "./columns";
import { licenseQueueActionsColumn } from "./actions";
const PAGE_SIZE = 10;
const SEARCH_DEBOUNCE_MS = 300;
@@ -348,137 +343,19 @@ export function LicenseQueuePage() {
const columns: AdvancedColumn<LicenseApplication>[] = useMemo(
() => [
{
header: (
<Checkbox
aria-label={t("queue.selectAll", "Select all")}
checked={allSelected}
indeterminate={selected.length > 0 && !allSelected}
onChange={() =>
setSelected(allSelected ? [] : items.map((a) => a.id))
}
/>
),
size: 40,
cell: ({ row }) => (
<Checkbox
aria-label={t("queue.selectRow", {
number: row.original.applicationNumber,
defaultValue: "Select {{number}}",
})}
checked={selected.includes(row.original.id)}
onChange={(e) => {
const checked = e.currentTarget.checked;
setSelected((prev) =>
checked
? [...prev, row.original.id]
: prev.filter((id) => id !== row.original.id),
);
}}
/>
),
},
{
header: sortableHeader(t("queue.number", "App #"), "applicationNumber"),
label: t("queue.number", "App #"),
cell: ({ row }) => (
<Text size="sm" fw={500}>
{row.original.applicationNumber}
</Text>
),
},
{
header: sortableHeader(
typeCode && APPLICANT_NAME_TYPE_KEYS.includes(typeCode)
? t("queue.applicant", "Applicant")
: t("queue.company", "Company"),
"companyName",
),
label: t("queue.company", "Company"),
cell: ({ row }) => (
<Text size="sm">{applicantOrCompanyName(row.original) ?? "—"}</Text>
),
},
{
header: t("queue.tin", "TIN"),
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.tinNumber ?? "—"}
</Text>
),
},
{
header: t("queue.typeCol", "Type"),
cell: ({ row }) => (
<Text size="sm">
{localized(row.original.licenseType?.name, i18n.language) || "—"}
</Text>
),
},
{
header: sortableHeader(t("queue.statusCol", "Status"), "status"),
label: t("queue.statusCol", "Status"),
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{t(
`queue.statusValues.${row.original.status}`,
STATUS_LABELS[row.original.status],
)}
</Badge>
),
},
{
header: sortableHeader(
t("queue.submitted", "Submitted"),
"submittedAt",
),
label: t("queue.submitted", "Submitted"),
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{dateDisplayer(row.original.submittedAt, i18n.language)}
</Text>
),
},
{
header: t("queue.sla", "Age / SLA"),
cell: ({ row }) => {
// 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(row.original, undefined, i18n.language, (key, options) => t(key, options as any) as string);
return (
// Colour is never the only signal — the label says the same thing.
<Tooltip label={sla.tooltip} withArrow>
<Badge color={sla.color} variant="light" size="sm">
{sla.label}
</Badge>
</Tooltip>
);
},
},
{
header: "",
label: t("queue.actionsColumn", "Actions"),
align: "right",
size: 140,
cell: ({ row }) =>
row.original.assignedOfficerId === null &&
row.original.status === "SUBMITTED" ? (
<Button
size="xs"
loading={claiming}
onClick={() => handleClaim(row.original.id)}
>
{t("queue.claim", "Claim")}
</Button>
) : (
<Button
size="xs"
variant="light"
onClick={() => navigate(`/licence-review/${row.original.id}`)}
>
{t("queue.review", "Review")}
</Button>
),
},
...licenseQueueColumns(t, i18n.language, {
typeCode,
items,
selected,
setSelected,
allSelected,
sortableHeader,
}),
licenseQueueActionsColumn(t, {
claiming,
onClaim: handleClaim,
onOpen: (id) => navigate(`/licence-review/${id}`),
}),
],
[
t,

View File

@@ -0,0 +1,76 @@
import type { ReactNode } from 'react';
import { Checkbox, Text, TextInput } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { ApplicationStaff } from '@ema-platform/api';
import type { AdvancedColumn } from '@ema-platform/ui';
export function reviewStaffColumns(
t: TFunction,
handlers: {
flags: Record<string, { remark: string }>;
/** Bilingual role label resolved from the licence-type config. */
roleName: (member: ApplicationStaff) => string;
/** Evidence badges — needs an attachments query, so the page supplies it. */
renderEvidence: (member: ApplicationStaff) => ReactNode;
onToggleFlag: (member: ApplicationStaff) => void;
onRemarkChange: (member: ApplicationStaff, remark: string) => void;
},
): AdvancedColumn<ApplicationStaff>[] {
const { flags } = handlers;
return [
{
header: t('review.role', 'Role'),
cell: ({ row }) => <Text size="xs">{handlers.roleName(row.original)}</Text>,
},
{
header: t('review.name', 'Name'),
cell: ({ row }) => <Text size="sm">{row.original.fullName}</Text>,
},
{
header: t('review.evidence', 'Evidence'),
cell: ({ row }) => handlers.renderEvidence(row.original),
},
{
// A person's papers are as returnable as a document or a form section:
// an ERB certificate for the wrong person is a defect the applicant has
// to fix, and until now the officer had to describe it under some
// unrelated document.
header: t('review.correction', 'Correction'),
size: 260,
cell: ({ row }) => {
const member = row.original;
return (
<>
<Checkbox
size="xs"
label={t('review.needsCorrection', 'Needs correction')}
checked={Boolean(flags[member.id])}
onChange={() => handlers.onToggleFlag(member)}
/>
{flags[member.id] && (
<TextInput
mt={6}
size="xs"
withAsterisk
placeholder={t(
'review.correctionPlaceholder',
'What must the applicant correct?',
)}
error={
flags[member.id].remark.trim()
? null
: t('review.correctionRequired', 'Say what must be corrected')
}
value={flags[member.id].remark}
onChange={(e) => {
const remark = e.currentTarget.value;
handlers.onRemarkChange(member, remark);
}}
/>
)}
</>
);
},
},
];
}

View File

@@ -59,20 +59,27 @@ import {
useScheduleInspectionMutation,
type RemarkTargetType,
} from '@ema-platform/api';
import { ErrorState, ModalFooter, AmharicDatePicker } 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';
import { useAppSelector } from '../../../../store/hooks';
import { DecisionBar } from '../../components/DecisionBar';
import {
DecisionConfirmModal,
type DecisionSubmission,
} from '../components/DecisionConfirmModal';
import { ActivityRail } from '../components/ActivityRail';
import { DocumentsTab } from '../components/DocumentsTab';
import { computeSla } from '../sla';
import { evaluateEligibility, presentationFor } from '../config/license-types';
import { resolveActions, type ActionId, type ResolvedAction } from '../config/actions';
} from '../../components/DecisionConfirmModal';
import { ActivityRail } from '../../components/ActivityRail';
import { DocumentsTab } from '../../components/DocumentsTab';
import { computeSla } from '../../sla';
import { reviewStaffColumns } from './columns';
import { evaluateEligibility, presentationFor } from '../../config/license-types';
import { resolveActions, type ActionId, type ResolvedAction } from '../../config/actions';
type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
@@ -153,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);
@@ -280,6 +288,7 @@ export function LicenseReviewPage() {
const app = data.application;
const status = app.status;
const staffPaged = staffTable.paginate(data.staff);
const presentation = presentationFor(app.licenseType?.key);
// 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);
@@ -792,75 +801,29 @@ export function LicenseReviewPage() {
</Tabs.Panel>
<Tabs.Panel value="staff">
<Card withBorder padding="md">
<Table withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('review.role', 'Role')}</Table.Th>
<Table.Th>{t('review.name', 'Name')}</Table.Th>
<Table.Th>{t('review.evidence', 'Evidence')}</Table.Th>
<Table.Th w={260}>
{t('review.correction', 'Correction')}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.staff.map((member) => (
<Table.Tr key={member.id}>
<Table.Td>
<Text size="xs">{localized(roleNameByKey.get(member.roleKey)) || member.roleKey}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{member.fullName}</Text>
</Table.Td>
<Table.Td>
<StaffEvidenceCell staffId={member.id} fallback={member.documents} />
</Table.Td>
{/* A person's papers are as returnable as a document
or a form section: an ERB certificate for the wrong
person is a defect the applicant has to fix, and
until now the officer had to describe it under some
unrelated document. */}
<Table.Td>
<Checkbox
size="xs"
label={t('review.needsCorrection', 'Needs correction')}
checked={Boolean(flags[member.id])}
onChange={() => toggleFlag('STAFF', member.id)}
/>
{flags[member.id] && (
<TextInput
mt={6}
size="xs"
withAsterisk
placeholder={t(
'review.correctionPlaceholder',
'What must the applicant correct?',
)}
error={
flags[member.id].remark.trim()
? null
: t(
'review.correctionRequired',
'Say what must be corrected',
)
}
value={flags[member.id].remark}
onChange={(e) => {
const remark = e.currentTarget.value;
setFlags((p) => ({
...p,
[member.id]: { ...p[member.id], remark },
}));
}}
/>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</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">

View File

@@ -0,0 +1,43 @@
import { ActionIcon, Group, Tooltip } from '@mantine/core';
import { IconEdit, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { LocationType } from '../../types/location';
export function locationTypeColumnActions(
t: TFunction,
handlers: {
onEdit: (type: LocationType) => void;
onDelete: (type: LocationType) => void;
},
): AdvancedColumn<LocationType> {
return {
header: '',
label: t('location.actions', 'Actions'),
align: 'right',
cell: ({ row }) => (
<Group gap="xs" wrap="nowrap" justify="flex-end">
<Tooltip label={t('location.edit')}>
<ActionIcon
color="blue"
variant="subtle"
aria-label={t('location.edit')}
onClick={() => handlers.onEdit(row.original)}
>
<IconEdit size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label={t('location.delete')}>
<ActionIcon
color="red"
variant="subtle"
aria-label={t('location.delete')}
onClick={() => handlers.onDelete(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
),
};
}

View File

@@ -0,0 +1,32 @@
import { Badge } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { LocationType } from '../../types/location';
export function locationTypeColumns(
t: TFunction,
locale: 'en' | 'am',
): AdvancedColumn<LocationType>[] {
return [
{
header: t('location.level'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color="gray">
{row.original.level}
</Badge>
),
},
{
header: t('location.code'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color="blue">
{row.original.code}
</Badge>
),
},
{
header: t('location.name'),
cell: ({ row }) => row.original.names[locale],
},
];
}

View File

@@ -6,23 +6,20 @@ import {
Group,
Stack,
NumberInput,
Table,
ActionIcon,
Badge,
Text,
Tooltip,
Divider,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconEdit, IconTrash, IconPlus } from '@tabler/icons-react';
import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
useGetLocationTypesQuery,
useCreateLocationTypeMutation,
useUpdateLocationTypeMutation,
useDeleteLocationTypeMutation,
} from '../api/location-api';
import { notify, useErrorHandler } from '@ema-platform/ui';
} from '../../api/location-api';
import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { locationTypeColumns } from './columns';
import { locationTypeColumnActions } from './actions';
interface LocationTypeFormValues {
code: string;
@@ -35,7 +32,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
const { data: locationTypes, isLoading } = useGetLocationTypesQuery();
const { data: locationTypes, isLoading, refetch } = useGetLocationTypesQuery();
const table = useServerTable();
const [createType] = useCreateLocationTypeMutation();
const [updateType] = useUpdateLocationTypeMutation();
const [deleteType] = useDeleteLocationTypeMutation();
@@ -108,6 +106,7 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
const sortedTypes = locationTypes?.items
? [...locationTypes.items].sort((a, b) => a.level - b.level)
: [];
const paged = table.paginate(sortedTypes);
return (
<Modal
@@ -172,61 +171,24 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
<Divider mb="md" />
{isLoading && <Text c="dimmed">Loading...</Text>}
{!isLoading && sortedTypes.length === 0 && (
<Text c="dimmed" ta="center" py="xl">
{t('location.noTypes')}
</Text>
)}
{sortedTypes.length > 0 && (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('location.level')}</Table.Th>
<Table.Th>{t('location.code')}</Table.Th>
<Table.Th>{t('location.name')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedTypes.map((type) => (
<Table.Tr key={type.id}>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{type.level}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="blue">
{type.code}
</Badge>
</Table.Td>
<Table.Td>{type.names[locale]}</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => handleEdit(type)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handleDelete(type.id)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<AdvancedTable
tableName={t('location.manageTypes')}
columns={[
...locationTypeColumns(t, locale),
locationTypeColumnActions(t, {
onEdit: handleEdit,
onDelete: (type) => handleDelete(type.id),
}),
]}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
isLoading={isLoading}
refresh={refetch}
emptyText={t('location.noTypes')}
/>
</Modal>
);
}

View File

@@ -0,0 +1,36 @@
import { Badge, Text } from '@mantine/core';
import type { AdvancedColumn } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api';
export const logisticsHeadDashboardColumns: AdvancedColumn<LicenseApplication>[] =
[
{
header: 'Number',
cell: ({ row }) => (
<Text size="sm" fw={500}>
{row.original.applicationNumber}
</Text>
),
},
{
header: 'Company',
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
},
{
header: 'Status',
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={STATUS_COLORS[row.original.status as LicenseStatus]}
>
{STATUS_LABELS[row.original.status as LicenseStatus]}
</Badge>
),
},
];

View File

@@ -9,11 +9,11 @@ import {
Loader,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
@@ -21,6 +21,7 @@ import {
useGetQueueQuery,
type LicenseStatus,
} from '@ema-platform/api';
import { logisticsHeadDashboardColumns } from './columns';
/**
* Logistics department overview.
@@ -33,6 +34,7 @@ export function LogisticsHeadDashboardPage() {
const navigate = useNavigate();
const queue = useGetQueueQuery();
const mine = useGetAssignedToMeQuery();
const table = useServerTable();
if (queue.isLoading || mine.isLoading) {
return (
@@ -72,6 +74,8 @@ export function LogisticsHeadDashboardPage() {
)
.slice(0, 8);
const paged = table.paginate(recent);
return (
<Container size="xl" py="md">
<Title order={3} mb="xs">
@@ -110,50 +114,21 @@ export function LogisticsHeadDashboardPage() {
View all <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
</Text>
</Group>
{recent.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
No licence applications yet.
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Number</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{recent.map((app) => (
<Table.Tr
key={app.id}
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/licence-review/${app.id}`)}
>
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLORS[app.status as LicenseStatus]}
>
{STATUS_LABELS[app.status as LicenseStatus]}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<AdvancedTable
tableName="Most recent"
columns={logisticsHeadDashboardColumns}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
refresh={() => {
queue.refetch();
mine.refetch();
}}
emptyText="No licence applications yet."
onRowClick={(app) => navigate(`/licence-review/${app.id}`)}
/>
</Card>
</Grid.Col>

View File

@@ -0,0 +1,99 @@
import { Button, Group } from '@mantine/core';
import { IconCheck, IconPaperclip, IconX } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
export function medicalActionsColumn(
t: TFunction,
handlers: {
ruling: boolean;
onEvidence: (certificate: MedicalCertificate) => void;
onVerify: (certificate: MedicalCertificate) => void;
onReject: (certificate: MedicalCertificate) => void;
},
): AdvancedColumn<MedicalCertificate> {
return {
header: '',
label: t('recordVerification.columns.actions', 'Actions'),
align: 'right',
cell: ({ row }) => (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<IconPaperclip size={14} />}
onClick={() => handlers.onEvidence(row.original)}
>
{t('recordVerification.evidence', 'Evidence')}
</Button>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => handlers.onReject(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
</Group>
),
};
}
export function seaServiceActionsColumn(
t: TFunction,
handlers: {
ruling: boolean;
onEvidence: (record: SeaServiceRecord) => void;
onVerify: (record: SeaServiceRecord) => void;
onReject: (record: SeaServiceRecord) => void;
},
): AdvancedColumn<SeaServiceRecord> {
return {
header: '',
label: t('recordVerification.columns.actions', 'Actions'),
align: 'right',
cell: ({ row }) => (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<IconPaperclip size={14} />}
onClick={() => handlers.onEvidence(row.original)}
>
{t('recordVerification.evidence', 'Evidence')}
</Button>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => handlers.onReject(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
</Group>
),
};
}

View File

@@ -0,0 +1,131 @@
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type {
MedicalCertificate,
SeaServiceRecord,
SeafarerProfileSummary,
} from '@ema-platform/api';
export function ownerName(profile?: SeafarerProfileSummary): string {
if (!profile) return '—';
return (
[profile.firstName, profile.middleName, profile.lastName]
.filter(Boolean)
.join(' ') || '—'
);
}
export function medicalColumns(
t: TFunction,
showDate: (date: string) => string,
): AdvancedColumn<MedicalCertificate>[] {
return [
{
header: t('recordVerification.columns.seafarer', 'Seafarer'),
label: t('recordVerification.columns.seafarer', 'Seafarer'),
accessorKey: 'profile.firstName',
cell: ({ row }) => (
<div>
<Text size="sm" fw={500}>
{ownerName(row.original.profile)}
</Text>
{row.original.profile?.seafarerNumber && (
<Text size="xs" c="dimmed" ff="monospace">
{row.original.profile.seafarerNumber}
</Text>
)}
</div>
),
},
{
header: t('recordVerification.columns.issuer', 'Issuer'),
label: t('recordVerification.columns.issuer', 'Issuer'),
accessorKey: 'issuerName',
cell: ({ row }) => (
<div>
<Text size="sm">{row.original.issuerName}</Text>
{row.original.certificateNumber && (
<Text size="xs" c="dimmed">
{row.original.certificateNumber}
</Text>
)}
</div>
),
},
{
header: t('recordVerification.columns.validity', 'Validity'),
label: t('recordVerification.columns.validity', 'Validity'),
cell: ({ row }) => (
<Text size="sm">
{showDate(row.original.issueDate)} {showDate(row.original.expiryDate)}
</Text>
),
},
{
header: t('recordVerification.columns.fitness', 'Fitness'),
label: t('recordVerification.columns.fitness', 'Fitness'),
accessorKey: 'fitnessStatus',
cell: ({ row }) => (
<Badge size="sm" variant="light">
{t(`recordVerification.fitness.${row.original.fitnessStatus}`, row.original.fitnessStatus)}
</Badge>
),
},
];
}
export function seaServiceColumns(
t: TFunction,
showDate: (date: string) => string,
): AdvancedColumn<SeaServiceRecord>[] {
return [
{
header: t('recordVerification.columns.seafarer', 'Seafarer'),
label: t('recordVerification.columns.seafarer', 'Seafarer'),
accessorKey: 'profile.firstName',
cell: ({ row }) => (
<div>
<Text size="sm" fw={500}>
{ownerName(row.original.profile)}
</Text>
{row.original.profile?.seafarerNumber && (
<Text size="xs" c="dimmed" ff="monospace">
{row.original.profile.seafarerNumber}
</Text>
)}
</div>
),
},
{
header: t('recordVerification.columns.vessel', 'Vessel'),
label: t('recordVerification.columns.vessel', 'Vessel'),
accessorKey: 'vesselName',
cell: ({ row }) => (
<div>
<Text size="sm">{row.original.vesselName}</Text>
{row.original.imoNumber && (
<Text size="xs" c="dimmed">
IMO {row.original.imoNumber}
</Text>
)}
</div>
),
},
{
header: t('recordVerification.columns.rank', 'Rank'),
label: t('recordVerification.columns.rank', 'Rank'),
accessorKey: 'rank',
cell: ({ row }) => <Text size="sm">{row.original.rank}</Text>,
},
{
header: t('recordVerification.columns.period', 'Period'),
label: t('recordVerification.columns.period', 'Period'),
cell: ({ row }) => (
<Text size="sm">
{showDate(row.original.engagementDate)} {showDate(row.original.dischargeDate)}
</Text>
),
},
];
}

View File

@@ -17,12 +17,10 @@ import {
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconEye,
IconInbox,
IconPaperclip,
IconStethoscope,
IconX,
} from '@tabler/icons-react';
import { AdvancedTable, notify, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
@@ -34,23 +32,12 @@ import {
useVerifyMedicalCertificateMutation,
useVerifySeaServiceRecordMutation,
} from '@ema-platform/api';
import type {
MedicalCertificate,
SeaServiceRecord,
SeafarerProfileSummary,
} from '@ema-platform/api';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
import { medicalColumns, seaServiceColumns, ownerName } from './columns';
import { medicalActionsColumn, seaServiceActionsColumn } from './actions';
const PAGE_SIZE = 10;
function ownerName(profile?: SeafarerProfileSummary): string {
if (!profile) return '—';
return (
[profile.firstName, profile.middleName, profile.lastName]
.filter(Boolean)
.join(' ') || '—'
);
}
/** Reject dialog — the remark is what the seafarer sees and must act on. */
function RejectModal({
title,
@@ -274,220 +261,60 @@ export function MedicalVerificationPage() {
setSeaServicePage(0);
}, []);
const medicalColumns: AdvancedColumn<MedicalCertificate>[] = useMemo(
const medicalTableColumns: AdvancedColumn<MedicalCertificate>[] = useMemo(
() => [
{
header: t('recordVerification.columns.seafarer', 'Seafarer'),
label: t('recordVerification.columns.seafarer', 'Seafarer'),
accessorKey: 'profile.firstName',
cell: ({ row }) => (
<div>
<Text size="sm" fw={500}>
{ownerName(row.original.profile)}
</Text>
{row.original.profile?.seafarerNumber && (
<Text size="xs" c="dimmed" ff="monospace">
{row.original.profile.seafarerNumber}
</Text>
)}
</div>
),
},
{
header: t('recordVerification.columns.issuer', 'Issuer'),
label: t('recordVerification.columns.issuer', 'Issuer'),
accessorKey: 'issuerName',
cell: ({ row }) => (
<div>
<Text size="sm">{row.original.issuerName}</Text>
{row.original.certificateNumber && (
<Text size="xs" c="dimmed">
{row.original.certificateNumber}
</Text>
)}
</div>
),
},
{
header: t('recordVerification.columns.validity', 'Validity'),
label: t('recordVerification.columns.validity', 'Validity'),
cell: ({ row }) => (
<Text size="sm">
{showDate(row.original.issueDate)} {showDate(row.original.expiryDate)}
</Text>
),
},
{
header: t('recordVerification.columns.fitness', 'Fitness'),
label: t('recordVerification.columns.fitness', 'Fitness'),
accessorKey: 'fitnessStatus',
cell: ({ row }) => (
<Badge size="sm" variant="light">
{t(`recordVerification.fitness.${row.original.fitnessStatus}`, row.original.fitnessStatus)}
</Badge>
),
},
{
header: '',
label: t('recordVerification.columns.actions', 'Actions'),
align: 'right',
cell: ({ row }) => (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<IconPaperclip size={14} />}
onClick={() =>
setAttachmentModal({
ownerType: 'MEDICAL_CERTIFICATE',
ownerId: row.original.id,
title: t('recordVerification.evidenceTitleMedical', {
name: ownerName(row.original.profile),
defaultValue: 'Evidence for {{name}} Medical Certificate',
}),
})
}
>
{t('recordVerification.evidence', 'Evidence')}
</Button>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={rulingMedical}
onClick={() =>
rule(
() =>
verifyMedical({
id: row.original.id,
outcome: 'VERIFIED',
}).unwrap(),
t('recordVerification.certificateVerified', 'Certificate verified'),
)
}
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => setRejectMedical(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
</Group>
),
},
...medicalColumns(t, showDate),
medicalActionsColumn(t, {
ruling: rulingMedical,
onEvidence: (certificate) =>
setAttachmentModal({
ownerType: 'MEDICAL_CERTIFICATE',
ownerId: certificate.id,
title: t('recordVerification.evidenceTitleMedical', {
name: ownerName(certificate.profile),
defaultValue: 'Evidence for {{name}} Medical Certificate',
}),
}),
onVerify: (certificate) =>
rule(
() =>
verifyMedical({
id: certificate.id,
outcome: 'VERIFIED',
}).unwrap(),
t('recordVerification.certificateVerified', 'Certificate verified'),
),
onReject: (certificate) => setRejectMedical(certificate),
}),
],
[rulingMedical, rule, verifyMedical, showDate, t],
);
const seaServiceColumns: AdvancedColumn<SeaServiceRecord>[] = useMemo(
const seaServiceTableColumns: AdvancedColumn<SeaServiceRecord>[] = useMemo(
() => [
{
header: t('recordVerification.columns.seafarer', 'Seafarer'),
label: t('recordVerification.columns.seafarer', 'Seafarer'),
accessorKey: 'profile.firstName',
cell: ({ row }) => (
<div>
<Text size="sm" fw={500}>
{ownerName(row.original.profile)}
</Text>
{row.original.profile?.seafarerNumber && (
<Text size="xs" c="dimmed" ff="monospace">
{row.original.profile.seafarerNumber}
</Text>
)}
</div>
),
},
{
header: t('recordVerification.columns.vessel', 'Vessel'),
label: t('recordVerification.columns.vessel', 'Vessel'),
accessorKey: 'vesselName',
cell: ({ row }) => (
<div>
<Text size="sm">{row.original.vesselName}</Text>
{row.original.imoNumber && (
<Text size="xs" c="dimmed">
IMO {row.original.imoNumber}
</Text>
)}
</div>
),
},
{
header: t('recordVerification.columns.rank', 'Rank'),
label: t('recordVerification.columns.rank', 'Rank'),
accessorKey: 'rank',
cell: ({ row }) => <Text size="sm">{row.original.rank}</Text>,
},
{
header: t('recordVerification.columns.period', 'Period'),
label: t('recordVerification.columns.period', 'Period'),
cell: ({ row }) => (
<Text size="sm">
{showDate(row.original.engagementDate)} {showDate(row.original.dischargeDate)}
</Text>
),
},
{
header: '',
label: t('recordVerification.columns.actions', 'Actions'),
align: 'right',
cell: ({ row }) => (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<IconPaperclip size={14} />}
onClick={() =>
setAttachmentModal({
ownerType: 'SEA_SERVICE_RECORD',
ownerId: row.original.id,
title: t('recordVerification.evidenceTitleSeaService', {
name: ownerName(row.original.profile),
defaultValue: 'Evidence for {{name}} Sea Service Record',
}),
})
}
>
{t('recordVerification.evidence', 'Evidence')}
</Button>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={rulingSeaService}
onClick={() =>
rule(
() =>
verifySeaService({
id: row.original.id,
outcome: 'VERIFIED',
}).unwrap(),
t('recordVerification.seaServiceVerified', 'Sea-service record verified'),
)
}
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => setRejectSeaService(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
</Group>
),
},
...seaServiceColumns(t, showDate),
seaServiceActionsColumn(t, {
ruling: rulingSeaService,
onEvidence: (record) =>
setAttachmentModal({
ownerType: 'SEA_SERVICE_RECORD',
ownerId: record.id,
title: t('recordVerification.evidenceTitleSeaService', {
name: ownerName(record.profile),
defaultValue: 'Evidence for {{name}} Sea Service Record',
}),
}),
onVerify: (record) =>
rule(
() =>
verifySeaService({
id: record.id,
outcome: 'VERIFIED',
}).unwrap(),
t('recordVerification.seaServiceVerified', 'Sea-service record verified'),
),
onReject: (record) => setRejectSeaService(record),
}),
],
[rulingSeaService, rule, verifySeaService, showDate, t],
);
@@ -522,7 +349,7 @@ export function MedicalVerificationPage() {
<Tabs.Panel value="medical" pt="md">
<AdvancedTable
columns={medicalColumns}
columns={medicalTableColumns}
data={pagedMedical}
tableName={t('recordVerification.tabs.medical', {
count: pendingMedicalList.length,
@@ -541,7 +368,7 @@ export function MedicalVerificationPage() {
<Tabs.Panel value="sea-service" pt="md">
<AdvancedTable
columns={seaServiceColumns}
columns={seaServiceTableColumns}
data={pagedSeaService}
tableName={t('recordVerification.tabs.seaService', {
count: pendingSeaServiceList.length,

View File

@@ -0,0 +1,26 @@
import { Button } from '@mantine/core';
import { IconEdit } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { LicenseType } from '@ema-platform/api';
import type { AdvancedColumn } from '@ema-platform/ui';
export function paymentConfigActionsColumn(
t: TFunction,
handlers: { onEdit: (type: LicenseType) => void },
): AdvancedColumn<LicenseType> {
return {
header: '',
size: 90,
align: 'right',
cell: ({ row }) => (
<Button
size="xs"
variant="light"
leftSection={<IconEdit size={14} />}
onClick={() => handlers.onEdit(row.original)}
>
{t('paymentConfig.edit', 'Edit')}
</Button>
),
};
}

View File

@@ -0,0 +1,96 @@
import { Badge, Text, Tooltip } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { LicenseType } from '@ema-platform/api';
import type { AdvancedColumn } from '@ema-platform/ui';
function feeText(amount: string | number | null, currency: string): string {
if (amount === null || amount === '') return '—';
const value = Number(amount);
if (!Number.isFinite(value)) return '—';
return `${value.toLocaleString('en-US')} ${currency}`;
}
export function paymentConfigColumns(
t: TFunction,
localized: (value: LicenseType['name']) => string,
): AdvancedColumn<LicenseType>[] {
return [
{
header: t('paymentConfig.columns.type', 'Licence type'),
cell: ({ row }) => (
<>
<Text size="sm" fw={600}>
{localized(row.original.name)}
</Text>
<Text size="xs" c="dimmed" ff="monospace">
{row.original.key}
</Text>
</>
),
},
{
header: t('paymentConfig.columns.newApplication', 'New application'),
cell: ({ row }) => (
<Text size="sm" fw={500}>
{feeText(row.original.feeNewApplication, row.original.feeCurrency)}
</Text>
),
},
{
header: t('paymentConfig.columns.renewal', 'Renewal'),
cell: ({ row }) => {
const type = row.original;
if (type.feeNewApplication === null) {
// No charge at all, so "same as new" would be noise.
return (
<Text size="sm" c="dimmed">
</Text>
);
}
if (type.feeRenewal === null) {
return (
<Tooltip
label={t(
'paymentConfig.renewalSameTooltip',
'No separate renewal fee — renewal is charged at the new-application rate',
)}
>
<Text size="sm" c="dimmed">
{feeText(type.feeNewApplication, type.feeCurrency)}{' '}
<Text span size="xs" c="dimmed">
{t('paymentConfig.renewalSameAsNew', '(same as new)')}
</Text>
</Text>
</Tooltip>
);
}
return (
<Text size="sm" fw={500}>
{feeText(type.feeRenewal, type.feeCurrency)}
</Text>
);
},
},
{
header: t('paymentConfig.columns.charged', 'Charged?'),
cell: ({ row }) =>
row.original.issuesCertificate ? (
<Badge variant="light" color="teal" size="sm">
{t('paymentConfig.chargedOnApproval', 'On approval')}
</Badge>
) : (
<Tooltip
label={t(
'paymentConfig.chargedNotChargedTooltip',
'This licence type ends with an EMA decision and never reaches a payment stage',
)}
>
<Badge variant="light" color="gray" size="sm">
{t('paymentConfig.chargedNotCharged', 'Not charged')}
</Badge>
</Tooltip>
),
},
];
}

View File

@@ -21,7 +21,6 @@ import {
import {
IconAlertTriangle,
IconCreditCard,
IconEdit,
IconInfoCircle,
IconLock,
} from '@tabler/icons-react';
@@ -40,6 +39,8 @@ import {
useUpdateLicenseFeesMutation,
} from '@ema-platform/api';
import type { LicenseType } from '@ema-platform/api';
import { paymentConfigColumns } from './columns';
import { paymentConfigActionsColumn } from './actions';
/**
* Licence fee configuration.
@@ -52,13 +53,6 @@ import type { LicenseType } from '@ema-platform/api';
* without that permission can read the figures but the save is refused.
*/
function feeText(amount: string | number | null, currency: string): string {
if (amount === null || amount === '') return '—';
const value = Number(amount);
if (!Number.isFinite(value)) return '—';
return `${value.toLocaleString('en-US')} ${currency}`;
}
export function PaymentConfigPage() {
const { t } = useTranslation();
const localized = useLocalized();
@@ -94,98 +88,8 @@ export function PaymentConfigPage() {
const page = paginate(types);
const columns: AdvancedColumn<LicenseType>[] = [
{
header: t('paymentConfig.columns.type', 'Licence type'),
cell: ({ row }) => (
<>
<Text size="sm" fw={600}>
{localized(row.original.name)}
</Text>
<Text size="xs" c="dimmed" ff="monospace">
{row.original.key}
</Text>
</>
),
},
{
header: t('paymentConfig.columns.newApplication', 'New application'),
cell: ({ row }) => (
<Text size="sm" fw={500}>
{feeText(row.original.feeNewApplication, row.original.feeCurrency)}
</Text>
),
},
{
header: t('paymentConfig.columns.renewal', 'Renewal'),
cell: ({ row }) => {
const type = row.original;
if (type.feeNewApplication === null) {
// No charge at all, so "same as new" would be noise.
return (
<Text size="sm" c="dimmed">
</Text>
);
}
if (type.feeRenewal === null) {
return (
<Tooltip
label={t(
'paymentConfig.renewalSameTooltip',
'No separate renewal fee — renewal is charged at the new-application rate',
)}
>
<Text size="sm" c="dimmed">
{feeText(type.feeNewApplication, type.feeCurrency)}{' '}
<Text span size="xs" c="dimmed">
{t('paymentConfig.renewalSameAsNew', '(same as new)')}
</Text>
</Text>
</Tooltip>
);
}
return (
<Text size="sm" fw={500}>
{feeText(type.feeRenewal, type.feeCurrency)}
</Text>
);
},
},
{
header: t('paymentConfig.columns.charged', 'Charged?'),
cell: ({ row }) =>
row.original.issuesCertificate ? (
<Badge variant="light" color="teal" size="sm">
{t('paymentConfig.chargedOnApproval', 'On approval')}
</Badge>
) : (
<Tooltip
label={t(
'paymentConfig.chargedNotChargedTooltip',
'This licence type ends with an EMA decision and never reaches a payment stage',
)}
>
<Badge variant="light" color="gray" size="sm">
{t('paymentConfig.chargedNotCharged', 'Not charged')}
</Badge>
</Tooltip>
),
},
{
header: '',
size: 90,
align: 'right',
cell: ({ row }) => (
<Button
size="xs"
variant="light"
leftSection={<IconEdit size={14} />}
onClick={() => setEditing(row.original)}
>
{t('paymentConfig.edit', 'Edit')}
</Button>
),
},
...paymentConfigColumns(t, localized),
paymentConfigActionsColumn(t, { onEdit: setEditing }),
];
return (

View File

@@ -0,0 +1,66 @@
import { ActionIcon, Button, Group } from '@mantine/core';
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Question } from '../../types/question';
export function questionActionsColumn(
t: TFunction,
handlers: {
isSubmittingReview: boolean;
onSubmitForApproval: (question: Question) => void;
onReview: (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => void;
onEdit: (question: Question) => void;
onDelete: (question: Question) => void;
},
): AdvancedColumn<Question> {
return {
header: '',
label: t('question.columns.actions', 'Actions'),
cell: ({ row }) => {
const q = row.original;
return (
<Group gap="xs">
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
<Button
size="compact-xs"
variant="light"
leftSection={<IconSend size={12} />}
loading={handlers.isSubmittingReview}
onClick={() => handlers.onSubmitForApproval(q)}
>
{t('question.qc.submit')}
</Button>
)}
{q.status === 'PENDING_APPROVAL' && (
<>
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
{t('question.qc.approve')}
</Button>
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
{t('question.qc.reject')}
</Button>
</>
)}
{q.status === 'APPROVED' && (
<Button
size="compact-xs"
variant="subtle"
color="dark"
leftSection={<IconGavel size={12} />}
onClick={() => handlers.onReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Button>
)}
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
);
},
};
}

View File

@@ -0,0 +1,52 @@
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Question, QuestionStatus } from '../../types/question';
/** Bank items travel DRAFT → PENDING_APPROVAL → APPROVED (US-EXAM-003). */
const QC_COLOR: Record<QuestionStatus, string> = {
DRAFT: 'gray',
PENDING_APPROVAL: 'yellow',
APPROVED: 'teal',
REJECTED: 'red',
RETIRED: 'dark',
};
export function questionColumns(
t: TFunction,
opts: {
locale: 'en' | 'am';
getCertName: (id: string) => string;
},
): AdvancedColumn<Question>[] {
return [
{
header: t('question.columns.title'),
cell: ({ row }) => <Text fz="sm" maw={300} lineClamp={2}>{row.original.title[opts.locale]}</Text>,
},
{
header: t('question.columns.certification'),
cell: ({ row }) => <Text fz="sm">{opts.getCertName(row.original.certificationId)}</Text>,
},
{
header: t('question.columns.form'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={row.original.form === 'ESSAY' ? 'blue' : 'violet'}>
{t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)}
</Badge>
),
},
{
header: t('question.columns.points'),
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.points}</Text>,
},
{
header: t('question.qc.column'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={QC_COLOR[row.original.status] ?? 'gray'} title={row.original.reviewRemark ?? undefined}>
{t(`question.qc.${row.original.status}`)}
</Badge>
),
},
];
}

View File

@@ -5,7 +5,6 @@ import {
Group,
Button,
Badge,
ActionIcon,
Modal,
Text,
TextInput,
@@ -17,17 +16,10 @@ import {
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import {
IconEdit,
IconTrash,
IconPlus,
IconInfoCircle,
IconSend,
IconGavel,
} from '@tabler/icons-react';
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
import {
useGetQuestionsQuery,
useCreateQuestionMutation,
@@ -35,17 +27,10 @@ import {
useDeleteQuestionMutation,
useSubmitQuestionMutation,
useReviewQuestionMutation,
} from '../api/question-api';
import type { Question, QuestionForm, QuestionStatus } from '../types/question';
/** Bank items travel DRAFT → PENDING_APPROVAL → APPROVED (US-EXAM-003). */
const QC_COLOR: Record<QuestionStatus, string> = {
DRAFT: 'gray',
PENDING_APPROVAL: 'yellow',
APPROVED: 'teal',
REJECTED: 'red',
RETIRED: 'dark',
};
} from '../../api/question-api';
import type { Question, QuestionForm } from '../../types/question';
import { questionColumns } from './columns';
import { questionActionsColumn } from './actions';
function QuestionForm({
editing,
@@ -219,83 +204,14 @@ export function QuestionPage() {
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
const columns: AdvancedColumn<Question>[] = [
{
header: t('question.columns.title'),
cell: ({ row }) => <Text fz="sm" maw={300} lineClamp={2}>{row.original.title[locale]}</Text>,
},
{
header: t('question.columns.certification'),
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
},
{
header: t('question.columns.form'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={row.original.form === 'ESSAY' ? 'blue' : 'violet'}>
{t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)}
</Badge>
),
},
{
header: t('question.columns.points'),
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.points}</Text>,
},
{
header: t('question.qc.column'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={QC_COLOR[row.original.status] ?? 'gray'} title={row.original.reviewRemark ?? undefined}>
{t(`question.qc.${row.original.status}`)}
</Badge>
),
},
{
header: '',
label: t('question.columns.actions', 'Actions'),
cell: ({ row }) => {
const q = row.original;
return (
<Group gap="xs">
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
<Button
size="compact-xs"
variant="light"
leftSection={<IconSend size={12} />}
loading={isSubmittingReview}
onClick={() => handleSubmitForApproval(q)}
>
{t('question.qc.submit')}
</Button>
)}
{q.status === 'PENDING_APPROVAL' && (
<>
<Button size="compact-xs" variant="light" color="teal" onClick={() => openReview(q, 'APPROVED')}>
{t('question.qc.approve')}
</Button>
<Button size="compact-xs" variant="light" color="red" onClick={() => openReview(q, 'REJECTED')}>
{t('question.qc.reject')}
</Button>
</>
)}
{q.status === 'APPROVED' && (
<Button
size="compact-xs"
variant="subtle"
color="dark"
leftSection={<IconGavel size={12} />}
onClick={() => openReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Button>
)}
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(q); setShowForm(true); }}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(q); openDelete(); }}>
<IconTrash size={14} />
</ActionIcon>
</Group>
);
},
},
...questionColumns(t, { locale, getCertName }),
questionActionsColumn(t, {
isSubmittingReview,
onSubmitForApproval: handleSubmitForApproval,
onReview: openReview,
onEdit: (q) => { setEditing(q); setShowForm(true); },
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
}),
];
return (

View File

@@ -0,0 +1,59 @@
import { NumberInput, Text, TextInput } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { QuestionBrief } from '../../../exam/types/exam';
export function recordResultColumns(
t: TFunction,
locale: 'en' | 'am',
handlers: {
scores: Record<string, number>;
questionRemarks: Record<string, string>;
onScoreChange: (questionId: string, value: number) => void;
onRemarkChange: (questionId: string, value: string) => void;
},
): AdvancedColumn<QuestionBrief>[] {
return [
{
header: t('result.recordModal.question'),
cell: ({ row }) => (
<Text fz="sm" maw={250} lineClamp={2}>
{row.original.title[locale]}
</Text>
),
},
{
header: t('result.recordModal.maxPoints'),
cell: ({ row }) => (
<Text fz="sm" fw={600}>
{row.original.points}
</Text>
),
},
{
header: t('result.recordModal.score'),
cell: ({ row }) => (
<NumberInput
value={handlers.scores[row.original.id] ?? 0}
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
min={0}
max={row.original.points}
size="xs"
style={{ width: 80 }}
/>
),
},
{
header: t('result.recordModal.remark'),
cell: ({ row }) => (
<TextInput
placeholder={t('result.recordModal.remarkOptional')}
value={handlers.questionRemarks[row.original.id] ?? ''}
onChange={(e) => handlers.onRemarkChange(row.original.id, e.currentTarget.value)}
size="xs"
style={{ minWidth: 160 }}
/>
),
},
];
}

View File

@@ -5,10 +5,8 @@ import {
Stack,
Select,
Divider,
Table,
Text,
Badge,
NumberInput,
Paper,
SimpleGrid,
Group,
@@ -17,11 +15,12 @@ import {
Alert,
} from '@mantine/core';
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { AdvancedTable, ModalFooter, notify, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { useCreateResultMutation } from '../api/result-api';
import { useGetExamRegistrationsQuery } from '../../exam/api/exam-api';
import type { Exam } from '../../exam/types/exam';
import { recordResultColumns } from './columns';
import { useCreateResultMutation } from '../../api/result-api';
import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api';
import type { Exam } from '../../../exam/types/exam';
function InfoRow({ label, value }: { label: string; value: string }) {
return (
@@ -43,7 +42,6 @@ export function RecordResultModal({
}) {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
const [seafarerSearch, setSeafarerSearch] = useState('');
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
const [scores, setScores] = useState<Record<string, number>>({});
@@ -57,8 +55,10 @@ export function RecordResultModal({
skip: !opened,
});
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
const table = useServerTable();
const questions = exam.questions ?? [];
const pagedQuestions = table.paginate(questions);
const seafarerOptions = (registrations ?? [])
.filter((registration) =>
@@ -167,43 +167,20 @@ export function RecordResultModal({
{selectedSeafarerId && questions.length > 0 && (
<>
<Divider label={t('result.recordModal.scorePerQuestion')} labelPosition="center" />
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('result.recordModal.question')}</Table.Th>
<Table.Th>{t('result.recordModal.maxPoints')}</Table.Th>
<Table.Th>{t('result.recordModal.score')}</Table.Th>
<Table.Th>{t('result.recordModal.remark')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{questions.map((q) => (
<Table.Tr key={q.id}>
<Table.Td><Text fz="sm" maw={250} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
<Table.Td>
<NumberInput
value={scores[q.id] ?? 0}
onChange={(v) => handleScoreChange(q.id, Number(v))}
min={0}
max={q.points}
size="xs"
style={{ width: 80 }}
/>
</Table.Td>
<Table.Td>
<TextInput
placeholder={t('result.recordModal.remarkOptional')}
value={questionRemarks[q.id] ?? ''}
onChange={(e) => handleQuestionRemarkChange(q.id, e.currentTarget.value)}
size="xs"
style={{ minWidth: 160 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<AdvancedTable
tableName={t('result.recordModal.title')}
columns={recordResultColumns(t, locale, {
scores,
questionRemarks,
onScoreChange: handleScoreChange,
onRemarkChange: handleQuestionRemarkChange,
})}
data={pagedQuestions.rows}
itemCount={pagedQuestions.itemCount}
pageIndex={pagedQuestions.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
/>
<Paper withBorder p="sm" radius="md" bg="gray.0">
<SimpleGrid cols={3} spacing="sm">

View File

@@ -0,0 +1,74 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconGavel } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { ExamAppeal } from '../../types/result';
export function examAppealsColumns(
t: TFunction,
locale: 'en' | 'am',
showDate: (date: string | null | undefined) => string,
handlers: { onDecide: (appeal: ExamAppeal) => void },
): AdvancedColumn<ExamAppeal>[] {
return [
{
header: t('result.appeals.number'),
cell: ({ row }) => (
<Text fz="sm" ff="monospace" fw={600}>
{row.original.appealNumber}
</Text>
),
},
{
header: t('result.appeals.candidate'),
cell: ({ row }) => (
<Text fz="sm">
{row.original.profile
? `${row.original.profile.firstName} ${row.original.profile.lastName}`
: row.original.profileId.slice(0, 8)}
</Text>
),
},
{
header: t('result.appeals.exam'),
cell: ({ row }) => (
<>
<Text fz="sm">
{row.original.result?.exam?.title?.[locale] ?? '—'}
</Text>
<Badge size="xs" variant="light" color="gray">
{row.original.result?.status} · {row.original.result?.totalScore}
</Badge>
</>
),
},
{
header: t('result.appeals.reason'),
cell: ({ row }) => (
<Text fz="xs" maw={300} lineClamp={3}>
{row.original.reason}
</Text>
),
},
{
header: t('result.appeals.lodged'),
cell: ({ row }) => <Text fz="xs">{showDate(row.original.createdAt)}</Text>,
},
{
header: '',
label: t('result.appeals.decide'),
align: 'right',
cell: ({ row }) => (
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconGavel size={12} />}
onClick={() => handlers.onDecide(row.original)}
>
{t('result.appeals.decide')}
</Button>
),
},
];
}

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Button,
Center,
Group,
@@ -11,20 +10,20 @@ import {
Paper,
Select,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconGavel, IconInfoCircle } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { IconInfoCircle } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { extractErrorMessage } from '@ema-platform/api';
import {
useGetPendingAppealsQuery,
useDecideAppealMutation,
} from '../api/result-api';
import type { ExamAppeal } from '../types/result';
} from '../../api/result-api';
import { examAppealsColumns } from './columns';
import type { ExamAppeal } from '../../types/result';
/**
* The appeals desk (US-EXAM-016).
@@ -37,7 +36,8 @@ export function ExamAppealsPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const showDate = useDateDisplayer();
const { data: appeals, isLoading, isError } = useGetPendingAppealsQuery();
const { data: appeals, isLoading, isError, refetch } = useGetPendingAppealsQuery();
const table = useServerTable();
const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation();
const [target, setTarget] = useState<ExamAppeal | null>(null);
@@ -75,6 +75,8 @@ export function ExamAppealsPage() {
);
}
const paged = table.paginate(appeals ?? []);
return (
<Stack gap="lg">
<div>
@@ -85,73 +87,23 @@ export function ExamAppealsPage() {
</div>
<Paper withBorder radius="md">
{(appeals ?? []).length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('result.appeals.none')}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>{t('result.appeals.number')}</Table.Th>
<Table.Th>{t('result.appeals.candidate')}</Table.Th>
<Table.Th>{t('result.appeals.exam')}</Table.Th>
<Table.Th>{t('result.appeals.reason')}</Table.Th>
<Table.Th>{t('result.appeals.lodged')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(appeals ?? []).map((appeal) => (
<Table.Tr key={appeal.id}>
<Table.Td>
<Text fz="sm" ff="monospace" fw={600}>
{appeal.appealNumber}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">
{appeal.profile
? `${appeal.profile.firstName} ${appeal.profile.lastName}`
: appeal.profileId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">
{appeal.result?.exam?.title?.[locale] ?? '—'}
</Text>
<Badge size="xs" variant="light" color="gray">
{appeal.result?.status} · {appeal.result?.totalScore}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="xs" maw={300} lineClamp={3}>
{appeal.reason}
</Text>
</Table.Td>
<Table.Td>
<Text fz="xs">{showDate(appeal.createdAt)}</Text>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconGavel size={12} />}
onClick={() => {
setTarget(appeal);
setOutcome('UPHELD');
setRemark('');
}}
>
{t('result.appeals.decide')}
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<AdvancedTable<ExamAppeal>
tableName={t('result.appeals.title')}
columns={examAppealsColumns(t, locale, showDate, {
onDecide: (appeal) => {
setTarget(appeal);
setOutcome('UPHELD');
setRemark('');
},
})}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
refresh={refetch}
emptyText={t('result.appeals.none')}
/>
</Paper>
<Modal

View File

@@ -0,0 +1,55 @@
import { Button, Group } from '@mantine/core';
import { IconEye, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Result } from '../../types/result';
export type QcAction = 'moderate' | 'approve' | 'return';
export function resultActionsColumn(
t: TFunction,
handlers: {
onQc: (result: Result, action: QcAction) => void;
onViewDetail: (result: Result) => void;
onDelete: (result: Result) => void;
},
): AdvancedColumn<Result> {
return {
header: '',
label: t('result.columns.actions', 'Actions'),
cell: ({ row }) => {
const r = row.original;
return (
<Group gap="xs">
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
<>
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
{t('result.review.moderate')}
</Button>
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
{t('result.review.approve')}
</Button>
</>
)}
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
{t('result.review.return')}
</Button>
)}
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
{t('result.action.viewEdit')}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => handlers.onDelete(r)}
>
{t('result.action.delete')}
</Button>
</Group>
);
},
};
}

View File

@@ -0,0 +1,78 @@
import { Badge, Box, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Result, ResultReviewStatus } from '../../types/result';
export const STATUS_COLOR: Record<string, string> = {
PASSED: 'teal',
FAILED: 'red',
};
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
export const REVIEW_COLOR: Record<ResultReviewStatus, string> = {
MARKED: 'gray',
MODERATED: 'yellow',
APPROVED: 'blue',
PUBLISHED: 'teal',
};
export function resultColumns(
t: TFunction,
locale: 'en' | 'am',
showDate: (date: string) => string,
getExamTitle: (id: string) => string,
): AdvancedColumn<Result>[] {
return [
{
header: t('result.columns.seafarer'),
cell: ({ row }) => (
<Text fz="sm" fw={500}>
{row.original.seafarer
? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}`
: row.original.seafarerId.slice(0, 8)}
</Text>
),
},
{
header: t('result.columns.exam'),
cell: ({ row }) => (
<Text fz="sm">{row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)}</Text>
),
},
{
header: t('result.columns.totalScore'),
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.totalScore}</Text>,
},
{
header: t('result.columns.status'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[row.original.status]}
leftSection={
<Box
w={6}
h={6}
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
/>
}
>
{t(`result.status.${row.original.status}`)}
</Badge>
),
},
{
header: t('result.review.column'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={REVIEW_COLOR[row.original.reviewStatus] ?? 'gray'}>
{t(`result.review.${row.original.reviewStatus}`)}
</Badge>
),
},
{
header: t('result.columns.date'),
cell: ({ row }) => <Text fz="sm">{showDate(row.original.createdAt)}</Text>,
},
];
}

View File

@@ -18,14 +18,11 @@ import {
Divider,
Button,
ThemeIcon,
Box,
TextInput,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconInfoCircle,
IconEye,
IconTrash,
IconUser,
IconCertificate,
IconDeviceFloppy,
@@ -37,7 +34,7 @@ import {
IconSearch,
IconSend,
} from '@tabler/icons-react';
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import type { BilingualValue } from '@ema-platform/ui';
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
@@ -50,26 +47,13 @@ import {
useApproveResultMutation,
useReturnResultMutation,
usePublishExamResultsMutation,
} from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api';
import { RecordResultModal } from '../components/RecordResultModal';
import type { Result, ResultBreakdown, ResultReviewStatus } from '../types/result';
import type { Exam } from '../../exam/types/exam';
const STATUS_COLOR: Record<string, string> = {
PASSED: 'teal',
FAILED: 'red',
};
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
const REVIEW_COLOR: Record<ResultReviewStatus, string> = {
MARKED: 'gray',
MODERATED: 'yellow',
APPROVED: 'blue',
PUBLISHED: 'teal',
};
type QcAction = 'moderate' | 'approve' | 'return';
} from '../../api/result-api';
import { useGetExamsQuery } from '../../../exam/api/exam-api';
import { RecordResultModal } from '../../components/RecordResultModal';
import type { Result, ResultBreakdown } from '../../types/result';
import type { Exam } from '../../../exam/types/exam';
import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns';
import { resultActionsColumn, type QcAction } from './actions';
function ResultStat({
label,
@@ -297,96 +281,13 @@ export function ResultPage() {
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
const columns: AdvancedColumn<Result>[] = [
{
header: t('result.columns.seafarer'),
cell: ({ row }) => (
<Text fz="sm" fw={500}>
{row.original.seafarer
? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}`
: row.original.seafarerId.slice(0, 8)}
</Text>
),
},
{
header: t('result.columns.exam'),
cell: ({ row }) => (
<Text fz="sm">{row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)}</Text>
),
},
{
header: t('result.columns.totalScore'),
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.totalScore}</Text>,
},
{
header: t('result.columns.status'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[row.original.status]}
leftSection={
<Box
w={6}
h={6}
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
/>
}
>
{t(`result.status.${row.original.status}`)}
</Badge>
),
},
{
header: t('result.review.column'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={REVIEW_COLOR[row.original.reviewStatus] ?? 'gray'}>
{t(`result.review.${row.original.reviewStatus}`)}
</Badge>
),
},
{
header: t('result.columns.date'),
cell: ({ row }) => <Text fz="sm">{showDate(row.original.createdAt)}</Text>,
},
{
header: '',
label: t('result.columns.actions', 'Actions'),
cell: ({ row }) => {
const r = row.original;
return (
<Group gap="xs">
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
<>
<Button size="compact-xs" variant="light" color="yellow" onClick={() => openQc(r, 'moderate')}>
{t('result.review.moderate')}
</Button>
<Button size="compact-xs" variant="light" color="blue" onClick={() => openQc(r, 'approve')}>
{t('result.review.approve')}
</Button>
</>
)}
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => openQc(r, 'return')}>
{t('result.review.return')}
</Button>
)}
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => viewDetail(r)}>
{t('result.action.viewEdit')}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => { setDeleteTarget(r); openDelete(); }}
>
{t('result.action.delete')}
</Button>
</Group>
);
},
},
const columns = [
...resultColumns(t, locale, showDate, getExamTitle),
resultActionsColumn(t, {
onQc: openQc,
onViewDetail: viewDetail,
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
}),
];
const page = paginate(filtered);

View File

@@ -0,0 +1,28 @@
import { Button, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { ProfileRow } from './columns';
export function seafarerStatusActionColumn(
t: TFunction,
handlers: { onStatus: (profile: ProfileRow) => void },
): AdvancedColumn<ProfileRow> {
return {
header: '',
label: t('seafarerRegistry.columns.actions', 'Actions'),
cell: ({ row }) =>
row.original.seafarerNumber ? (
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => handlers.onStatus(row.original)}
>
{t('seafarerRegistry.statusAction', 'Status')}
</Button>
</Tooltip>
) : null,
};
}

View File

@@ -0,0 +1,93 @@
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
export interface ProfileRow {
id: string;
firstName: string;
middleName?: string;
lastName: string;
gender?: string;
type?: string;
isComplete?: boolean;
seafarerNumber?: string | null;
seafarerStatus?: string | null;
seafarerDepartment?: string | null;
seafarerStatusReason?: string | null;
profession?: { name?: { en?: string } };
address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string };
}
export const SEAFARER_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
PENDING: 'yellow',
SUSPENDED: 'orange',
INACTIVE: 'gray',
};
export const DEPARTMENT_LABELS: Record<string, string> = {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
};
export function seafarerRegistryColumns(
t: TFunction,
handlers: { onDetail: (profile: ProfileRow) => void },
): AdvancedColumn<ProfileRow>[] {
return [
{
header: t('seafarerRegistry.columns.name', 'Name'),
cell: ({ row }) => (
<Text
size="sm"
fw={500}
style={{ cursor: 'pointer' }}
onClick={() => handlers.onDetail(row.original)}
>
{[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')}
</Text>
),
},
{
header: t('seafarerRegistry.columns.number', 'Seafarer №'),
cell: ({ row }) => <Text size="sm" ff="monospace">{row.original.seafarerNumber ?? '—'}</Text>,
},
{
header: t('seafarerRegistry.columns.department', 'Department'),
cell: ({ row }) => (
<Text size="sm">
{row.original.seafarerDepartment
? t(
`seafarerRegistry.departments.${row.original.seafarerDepartment}`,
DEPARTMENT_LABELS[row.original.seafarerDepartment] ?? row.original.seafarerDepartment,
)
: '—'}
</Text>
),
},
{
header: t('seafarerRegistry.columns.idNumber', 'ID number'),
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>,
},
{
header: t('seafarerRegistry.columns.phone', 'Phone'),
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>,
},
{
header: t('seafarerRegistry.columns.status', 'Status'),
cell: ({ row }) =>
row.original.seafarerNumber ? (
<Badge size="sm" variant="light" color={SEAFARER_STATUS_COLORS[row.original.seafarerStatus ?? ''] ?? 'gray'}>
{t(`seafarerRegistry.status.${row.original.seafarerStatus}`, row.original.seafarerStatus ?? '')}
</Badge>
) : (
<Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}>
{row.original.isComplete
? t('seafarerRegistry.notRegistered', 'Not registered')
: t('seafarerRegistry.incomplete', 'Incomplete')}
</Badge>
),
},
];
}

View File

@@ -17,15 +17,13 @@ import {
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAnchor,
IconSearch,
IconShieldCog,
IconStethoscope,
} from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -34,29 +32,13 @@ import {
useGetSeaServiceForProfileQuery,
useUpdateSeafarerStatusMutation,
} from '@ema-platform/api';
interface ProfileRow {
id: string;
firstName: string;
middleName?: string;
lastName: string;
gender?: string;
type?: string;
isComplete?: boolean;
seafarerNumber?: string | null;
seafarerStatus?: string | null;
seafarerDepartment?: string | null;
seafarerStatusReason?: string | null;
profession?: { name?: { en?: string } };
address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string };
}
const SEAFARER_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
PENDING: 'yellow',
SUSPENDED: 'orange',
INACTIVE: 'gray',
};
import {
DEPARTMENT_LABELS,
SEAFARER_STATUS_COLORS,
seafarerRegistryColumns,
type ProfileRow,
} from './columns';
import { seafarerStatusActionColumn } from './actions';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
@@ -64,12 +46,6 @@ const RECORD_STATUS_COLORS: Record<string, string> = {
REJECTED: 'red',
};
const DEPARTMENT_LABELS: Record<string, string> = {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
};
/** The registered seafarer's records, read-only (verification is module 06). */
function SeafarerDetailDrawer({
profile,
@@ -376,77 +352,9 @@ export function SeafarerRegistryPage() {
});
const page = paginate(items);
const columns: AdvancedColumn<ProfileRow>[] = [
{
header: t('seafarerRegistry.columns.name', 'Name'),
cell: ({ row }) => (
<Text
size="sm"
fw={500}
style={{ cursor: 'pointer' }}
onClick={() => setDetail(row.original)}
>
{[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')}
</Text>
),
},
{
header: t('seafarerRegistry.columns.number', 'Seafarer №'),
cell: ({ row }) => <Text size="sm" ff="monospace">{row.original.seafarerNumber ?? '—'}</Text>,
},
{
header: t('seafarerRegistry.columns.department', 'Department'),
cell: ({ row }) => (
<Text size="sm">
{row.original.seafarerDepartment
? t(
`seafarerRegistry.departments.${row.original.seafarerDepartment}`,
DEPARTMENT_LABELS[row.original.seafarerDepartment] ?? row.original.seafarerDepartment,
)
: '—'}
</Text>
),
},
{
header: t('seafarerRegistry.columns.idNumber', 'ID number'),
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>,
},
{
header: t('seafarerRegistry.columns.phone', 'Phone'),
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>,
},
{
header: t('seafarerRegistry.columns.status', 'Status'),
cell: ({ row }) =>
row.original.seafarerNumber ? (
<Badge size="sm" variant="light" color={SEAFARER_STATUS_COLORS[row.original.seafarerStatus ?? ''] ?? 'gray'}>
{t(`seafarerRegistry.status.${row.original.seafarerStatus}`, row.original.seafarerStatus ?? '')}
</Badge>
) : (
<Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}>
{row.original.isComplete
? t('seafarerRegistry.notRegistered', 'Not registered')
: t('seafarerRegistry.incomplete', 'Incomplete')}
</Badge>
),
},
{
header: '',
label: t('seafarerRegistry.columns.actions', 'Actions'),
cell: ({ row }) =>
row.original.seafarerNumber ? (
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => setStatusTarget(row.original)}
>
{t('seafarerRegistry.statusAction', 'Status')}
</Button>
</Tooltip>
) : null,
},
const columns = [
...seafarerRegistryColumns(t, { onDetail: setDetail }),
seafarerStatusActionColumn(t, { onStatus: setStatusTarget }),
];
return (

View File

@@ -0,0 +1,95 @@
import { Badge, Button, Text, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Vessel } from '@ema-platform/api';
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
};
export const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
export function vesselRegistrationQueueColumns(
showDate: (value: string | number | Date | null | undefined) => string,
handlers: {
onStatus: (vessel: Vessel) => void;
},
): AdvancedColumn<Vessel>[] {
return [
{
header: 'Registration №',
cell: ({ row }) => (
<Text size="sm" ff="monospace" fw={600}>
{row.original.registrationNumber}
</Text>
),
},
{
header: 'Vessel',
cell: ({ row }) => (
<>
<Text size="sm" fw={500}>
{row.original.name}
</Text>
<Text size="xs" c="dimmed">
{row.original.vesselType ?? '—'}
{row.original.imoNumber ? ` · IMO ${row.original.imoNumber}` : ''}
</Text>
</>
),
},
{
header: 'Category',
cell: ({ row }) => CATEGORY_LABELS[row.original.category] ?? row.original.category,
},
{
header: 'Owner',
cell: ({ row }) => <Text size="sm">{row.original.ownerName ?? '—'}</Text>,
},
{
header: 'Registered',
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{showDate(row.original.registeredAt)}
</Text>
),
},
{
header: 'Status',
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={VESSEL_STATUS_COLORS[row.original.status]}
>
{row.original.status}
</Badge>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => (
<Tooltip label="Suspend / deregister / reinstate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={(e) => {
e.stopPropagation();
handlers.onStatus(row.original);
}}
>
Status
</Button>
</Tooltip>
),
},
];
}

View File

@@ -5,7 +5,6 @@ import {
Badge,
Button,
Card,
Center,
Container,
Drawer,
Group,
@@ -18,15 +17,13 @@ import {
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
IconInfoCircle,
IconSearch,
IconShieldCog,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -35,17 +32,10 @@ import {
useUpdateVesselStatusMutation,
} from '@ema-platform/api';
import type { Vessel } from '@ema-platform/api';
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
};
const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
import {
CATEGORY_LABELS,
vesselRegistrationQueueColumns,
} from './columns';
/** Full particulars + incident log, read-only. */
function VesselDetailDrawer({
@@ -235,7 +225,7 @@ function StatusModal({
*/
export function VesselRegistrationQueuePage() {
const [search, setSearch] = useState('');
const { data, isLoading } = useGetVesselsQuery(
const { data, isLoading, refetch } = useGetVesselsQuery(
search.trim() ? { search: search.trim() } : undefined,
);
const [detail, setDetail] = useState<Vessel | null>(null);
@@ -243,6 +233,8 @@ export function VesselRegistrationQueuePage() {
const items = data?.items ?? [];
const showDate = useDateDisplayer();
const table = useServerTable();
const paged = table.paginate(items);
return (
<Container size="xl" py="md">
@@ -267,91 +259,21 @@ export function VesselRegistrationQueuePage() {
/>
</Group>
<Card withBorder padding={0}>
{isLoading ? (
<Center h={200}>
<Loader />
</Center>
) : items.length === 0 ? (
<Center h={160}>
<Text size="sm" c="dimmed">
{search
? 'No vessels match that search.'
: 'No vessels registered yet.'}
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Registration </Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Registered</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((vessel) => (
<Table.Tr
key={vessel.id}
style={{ cursor: 'pointer' }}
onClick={() => setDetail(vessel)}
>
<Table.Td>
<Text size="sm" ff="monospace" fw={600}>
{vessel.registrationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{vessel.name}
</Text>
<Text size="xs" c="dimmed">
{vessel.vesselType ?? '—'}
{vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''}
</Text>
</Table.Td>
<Table.Td>
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
<Text size="sm">{vessel.ownerName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{showDate(vessel.registeredAt)}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={VESSEL_STATUS_COLORS[vessel.status]}
>
{vessel.status}
</Badge>
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
<Tooltip label="Suspend / deregister / reinstate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => setStatusTarget(vessel)}
>
Status
</Button>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<AdvancedTable
tableName="Vessel register"
columns={vesselRegistrationQueueColumns(showDate, { onStatus: setStatusTarget })}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
isLoading={isLoading}
refresh={refetch}
onRowClick={(vessel) => setDetail(vessel)}
emptyText={
search ? 'No vessels match that search.' : 'No vessels registered yet.'
}
/>
<VesselDetailDrawer vessel={detail} onClose={() => setDetail(null)} />
<StatusModal

View File

@@ -0,0 +1,60 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconCertificate } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
export function certificateColumns(deps: {
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
onDownload: (license: IssuedLicense) => void;
}): AdvancedColumn<IssuedLicense>[] {
return [
{
header: 'Certificate №',
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.certificateNumber}
</Text>
),
},
{
header: 'Type',
cell: ({ row }) => deps.localized(row.original.licenseType?.name),
},
{
header: 'Issued',
cell: ({ row }) => deps.showDate(row.original.issueDate),
},
{
header: 'Expires',
cell: ({ row }) => deps.showDate(row.original.expiryDate),
},
{
header: 'Status',
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={row.original.status === 'ACTIVE' ? 'green' : 'red'}
>
{row.original.status}
</Badge>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => (
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => deps.onDownload(row.original)}
>
Download
</Button>
),
},
];
}

View File

@@ -7,14 +7,12 @@ import {
List,
Loader,
Stack,
Table,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconCertificate,
IconCircleCheck,
IconCircleX,
IconInfoCircle,
@@ -33,8 +31,9 @@ import {
useGetMySeaTimeQuery,
} from '@ema-platform/api';
import { useCurrentProfile } from '@ema-platform/auth';
import { notify } from '@ema-platform/ui';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { certificateColumns } from './columns';
const CERTIFICATE_TYPE_KEYS = [
'CERTIFICATE_OF_COMPETENCY',
@@ -72,10 +71,11 @@ export function CertificatesPage() {
const { data: medicals } = useGetMyMedicalCertificatesQuery();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const { data: licenses, refetch: refetchLicenses } = useGetMyLicensesQuery();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const showDate = useDateDisplayer();
const localized = useLocalized();
const issuedTable = useServerTable();
const registered =
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
@@ -113,6 +113,8 @@ export function CertificatesPage() {
);
}
const pagedIssued = issuedTable.paginate(issued);
return (
<Stack maw={860} mx="auto">
<Title order={2}>My Certificates</Title>
@@ -220,61 +222,21 @@ export function CertificatesPage() {
<Stack gap="xs">
<Title order={4}>Issued certificates</Title>
{issued.length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No certificates issued yet.
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Certificate </Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Expires</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{issued.map((license) => (
<Table.Tr key={license.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{license.certificateNumber}
</Text>
</Table.Td>
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
<Table.Td>{showDate(license.issueDate)}</Table.Td>
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={license.status === 'ACTIVE' ? 'green' : 'red'}
>
{license.status}
</Badge>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => download(license.id)}
>
Download
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<AdvancedTable
tableName="Issued certificates"
columns={certificateColumns({
localized,
showDate,
onDownload: (license) => download(license.id),
})}
data={pagedIssued.rows}
itemCount={pagedIssued.itemCount}
pageIndex={pagedIssued.pageIndex}
onPageChange={issuedTable.setPageIndex}
pageSize={issuedTable.pageSize}
refresh={refetchLicenses}
emptyText="No certificates issued yet."
/>
</Stack>
</Stack>
);

View File

@@ -0,0 +1,52 @@
import { Badge, Progress, Text } from '@mantine/core';
import type { AdvancedColumn } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
localized,
} from '@ema-platform/api';
import type { LicenseApplication } from '@ema-platform/api';
export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
[
{
header: 'Application',
cell: ({ row }) => (
<>
<Text size="sm" fw={600}>
{row.original.applicationNumber}
</Text>
<Text size="xs" c="dimmed">
{row.original.companyName ?? '—'}
</Text>
</>
),
},
{
header: 'Licence',
cell: ({ row }) => (
<Text size="sm">{localized(row.original.licenseType?.name) || '—'}</Text>
),
},
{
header: 'Status',
cell: ({ row }) => (
<Badge variant="light" color={STATUS_COLORS[row.original.status]}>
{STATUS_LABELS[row.original.status]}
</Badge>
),
},
{
header: 'Progress',
size: 180,
cell: ({ row }) => (
<Progress
value={STATUS_PROGRESS[row.original.status]}
color={STATUS_COLORS[row.original.status]}
size="sm"
radius="xl"
/>
),
},
];

View File

@@ -4,7 +4,6 @@ import { useSelector } from 'react-redux';
import {
Alert,
Anchor,
Badge,
Box,
Button,
Card,
@@ -13,10 +12,8 @@ import {
Group,
Loader,
Paper,
Progress,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
@@ -30,21 +27,20 @@ import {
IconFileText,
IconShieldCheck,
} from '@tabler/icons-react';
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
import { ProfileCompletionNudge } from '../../../profile/components/ProfileCompletionNudge';
import {
APPLICANT_ACTION_STATUSES,
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
useLocalized,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard';
import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard';
import { dashboardApplicationColumns } from './columns';
/**
* The applicant's home screen.
@@ -82,7 +78,7 @@ export function DashboardPage() {
state.auth.user?.name?.en || state.auth.user?.username || '',
);
const { data: applications, isLoading } = useGetMyApplicationsQuery();
const { data: applications, isLoading, refetch } = useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl, { isLoading: isDownloading }] =
useGetCertificateUrlMutation();
@@ -207,6 +203,7 @@ export function DashboardPage() {
<ApplicationTable
applications={items.slice(0, 6)}
navigate={navigate}
onRefresh={refetch}
/>
)}
</Section>
@@ -453,68 +450,33 @@ function Section({
function ApplicationTable({
applications,
navigate,
onRefresh,
}: {
applications: LicenseApplication[];
navigate: (path: string) => void;
onRefresh: () => void;
}) {
const localized = useLocalized();
const table = useServerTable();
const paged = table.paginate(applications);
return (
<Card withBorder radius="md" padding={0}>
<Table.ScrollContainer minWidth={640}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Application</Table.Th>
<Table.Th>Licence</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th w={180}>Progress</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{applications.map((app) => (
<Table.Tr
key={app.id}
style={{ cursor: 'pointer' }}
onClick={() =>
navigate(
app.licenseType?.key
? `/licensing/${app.licenseType.key}/applications/${app.id}`
: '/licensing/applications',
)
}
>
<Table.Td>
<Text size="sm" fw={600}>
{app.applicationNumber}
</Text>
<Text size="xs" c="dimmed">
{app.companyName ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{localized(app.licenseType?.name) || '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
</Badge>
</Table.Td>
<Table.Td>
<Progress
value={STATUS_PROGRESS[app.status]}
color={STATUS_COLORS[app.status]}
size="sm"
radius="xl"
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Card>
<AdvancedTable
tableName="My applications"
columns={dashboardApplicationColumns}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
verticalSpacing="sm"
refresh={onRefresh}
onRowClick={(app) =>
navigate(
app.licenseType?.key
? `/licensing/${app.licenseType.key}/applications/${app.id}`
: '/licensing/applications',
)
}
/>
);
}

View File

@@ -0,0 +1,145 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconFileText, IconGavel } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual } from '@ema-platform/api';
import type {
AttendanceStatus,
MyAppeal,
MyRegistration,
MyResult,
} from './index';
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
REGISTERED: 'gray',
PRESENT: 'teal',
LATE: 'yellow',
ABSENT: 'red',
WITHDRAWN: 'orange',
DISQUALIFIED: 'red',
};
export function registrationColumns(deps: {
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
onDownloadSlip: (registration: MyRegistration) => void;
}): AdvancedColumn<MyRegistration>[] {
return [
{
header: 'Admission №',
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.admissionNumber}
</Text>
),
},
{
header: 'Examination',
cell: ({ row }) => deps.localized(row.original.exam?.title) || '—',
},
{
header: 'Date',
cell: ({ row }) => deps.showDate(row.original.exam?.date),
},
{
header: 'Venue',
cell: ({ row }) => row.original.exam?.venue ?? '—',
},
{
header: 'Attempt',
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'}
>
{row.original.kind === 'RETAKE'
? `Retake · ${row.original.attemptNumber}`
: 'First sitting'}
</Badge>
),
},
{
header: 'Attendance',
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'}
>
{row.original.attendanceStatus}
</Badge>
),
},
{
header: 'Slip',
cell: ({ row }) => (
<Button
size="compact-xs"
variant="light"
leftSection={<IconFileText size={13} />}
onClick={() => deps.onDownloadSlip(row.original)}
>
Slip
</Button>
),
},
];
}
export function resultColumns(deps: {
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
appeals: MyAppeal[];
onAppeal: (result: MyResult) => void;
}): AdvancedColumn<MyResult>[] {
return [
{
header: 'Examination',
cell: ({ row }) => deps.localized(row.original.exam?.title) || '—',
},
{
header: 'Published',
cell: ({ row }) => deps.showDate(row.original.publishedAt),
},
{
header: 'Score',
cell: ({ row }) => (
<Text fw={600} size="sm">
{row.original.totalScore}
</Text>
),
},
{
header: 'Outcome',
cell: ({ row }) => (
<Badge
variant="light"
color={row.original.status === 'PASSED' ? 'teal' : 'red'}
>
{row.original.status}
</Badge>
),
},
{
header: 'Appeal',
cell: ({ row }) => {
const appeal = deps.appeals.find((a) => a.resultId === row.original.id);
return appeal ? (
<Badge size="sm" variant="light" color="grape">
{appeal.appealNumber} · {appeal.status}
</Badge>
) : (
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconGavel size={13} />}
onClick={() => deps.onAppeal(row.original)}
>
Appeal
</Button>
);
},
},
];
}

View File

@@ -7,13 +7,12 @@ import {
Loader,
Modal,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconClipboardList, IconFileText, IconGavel } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { IconClipboardList } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
useApiQuery,
@@ -22,8 +21,9 @@ import {
openAuthedDocument,
useLocalized,
} from '@ema-platform/api';
import { registrationColumns, resultColumns } from './columns';
interface OpenExam {
export interface OpenExam {
id: string;
title: { en?: string; am?: string };
date: string;
@@ -32,7 +32,7 @@ interface OpenExam {
certification?: { name?: { en?: string } };
}
type AttendanceStatus =
export type AttendanceStatus =
| 'REGISTERED'
| 'PRESENT'
| 'ABSENT'
@@ -40,7 +40,7 @@ type AttendanceStatus =
| 'WITHDRAWN'
| 'DISQUALIFIED';
interface MyRegistration {
export interface MyRegistration {
id: string;
admissionNumber: string;
createdAt: string;
@@ -50,7 +50,7 @@ interface MyRegistration {
exam?: OpenExam;
}
interface MyResult {
export interface MyResult {
id: string;
totalScore: number;
status: 'PASSED' | 'FAILED';
@@ -59,7 +59,7 @@ interface MyResult {
exam?: OpenExam;
}
interface MyAppeal {
export interface MyAppeal {
id: string;
appealNumber: string;
status: 'SUBMITTED' | 'UNDER_REVIEW' | 'UPHELD' | 'REJECTED';
@@ -68,15 +68,6 @@ interface MyAppeal {
resultId: string;
}
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
REGISTERED: 'gray',
PRESENT: 'teal',
LATE: 'yellow',
ABSENT: 'red',
WITHDRAWN: 'orange',
DISQUALIFIED: 'red',
};
/**
* The candidate's examination home (US-EXAM-007/008/014/015/016): sessions to
* sit, the admission slip to print, published results, and the appeal route
@@ -100,7 +91,11 @@ export function ExamsPage() {
url: '/exams/registrations/mine',
method: 'GET',
});
const { data: results, isLoading: loadingResults } = useApiQuery<MyResult[]>({
const {
data: results,
isLoading: loadingResults,
refetch: refetchResults,
} = useApiQuery<MyResult[]>({
url: '/results/mine',
method: 'GET',
});
@@ -110,6 +105,8 @@ export function ExamsPage() {
} = useApiQuery<MyAppeal[]>({ url: '/results/appeals/mine', method: 'GET' });
const [registerTrigger, { isLoading: registering }] = useApiMutation();
const [appealTrigger, { isLoading: appealing }] = useApiMutation();
const registrationTable = useServerTable();
const resultTable = useServerTable();
const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id));
@@ -182,6 +179,9 @@ export function ExamsPage() {
);
}
const pagedRegistrations = registrationTable.paginate(mine ?? []);
const pagedResults = resultTable.paginate(results ?? []);
return (
<Stack maw={900} mx="auto">
<Title order={2}>Examinations</Title>
@@ -228,145 +228,41 @@ export function ExamsPage() {
<Stack gap="xs">
<Title order={4}>My registrations</Title>
{(mine ?? []).length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No exam registrations yet.
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={720}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Admission </Table.Th>
<Table.Th>Examination</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Venue</Table.Th>
<Table.Th>Attempt</Table.Th>
<Table.Th>Attendance</Table.Th>
<Table.Th>Slip</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(mine ?? []).map((registration) => (
<Table.Tr key={registration.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{registration.admissionNumber}
</Text>
</Table.Td>
<Table.Td>{localized(registration.exam?.title) || '—'}</Table.Td>
<Table.Td>{showDate(registration.exam?.date)}</Table.Td>
<Table.Td>{registration.exam?.venue ?? '—'}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
>
{registration.kind === 'RETAKE'
? `Retake · ${registration.attemptNumber}`
: 'First sitting'}
</Badge>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={
ATTENDANCE_COLOR[registration.attendanceStatus] ??
'gray'
}
>
{registration.attendanceStatus}
</Badge>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="light"
leftSection={<IconFileText size={13} />}
onClick={() => downloadSlip(registration)}
>
Slip
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<AdvancedTable<MyRegistration>
tableName="My registrations"
columns={registrationColumns({
localized,
showDate,
onDownloadSlip: downloadSlip,
})}
data={pagedRegistrations.rows}
itemCount={pagedRegistrations.itemCount}
pageIndex={pagedRegistrations.pageIndex}
onPageChange={registrationTable.setPageIndex}
pageSize={registrationTable.pageSize}
refresh={refetch}
emptyText="No exam registrations yet."
/>
</Stack>
<Stack gap="xs">
<Title order={4}>My results</Title>
{(results ?? []).length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No results have been published yet. Marks appear here once the
authority approves and publishes them.
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={720}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Examination</Table.Th>
<Table.Th>Published</Table.Th>
<Table.Th>Score</Table.Th>
<Table.Th>Outcome</Table.Th>
<Table.Th>Appeal</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(results ?? []).map((result) => {
const appeal = (appeals ?? []).find(
(a) => a.resultId === result.id,
);
return (
<Table.Tr key={result.id}>
<Table.Td>{localized(result.exam?.title) || '—'}</Table.Td>
<Table.Td>{showDate(result.publishedAt)}</Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{result.totalScore}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={result.status === 'PASSED' ? 'teal' : 'red'}
>
{result.status}
</Badge>
</Table.Td>
<Table.Td>
{appeal ? (
<Badge size="sm" variant="light" color="grape">
{appeal.appealNumber} · {appeal.status}
</Badge>
) : (
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconGavel size={13} />}
onClick={() => setAppealFor(result)}
>
Appeal
</Button>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<AdvancedTable<MyResult>
tableName="My results"
columns={resultColumns({
localized,
showDate,
appeals: appeals ?? [],
onAppeal: setAppealFor,
})}
data={pagedResults.rows}
itemCount={pagedResults.itemCount}
pageIndex={pagedResults.pageIndex}
onPageChange={resultTable.setPageIndex}
pageSize={resultTable.pageSize}
refresh={refetchResults}
emptyText="No results have been published yet. Marks appear here once the authority approves and publishes them."
/>
</Stack>
<Modal

View File

@@ -0,0 +1,87 @@
import { Button, Group } from '@mantine/core';
import { IconDownload } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { LicenseApplication } from '@ema-platform/api';
export function applicationActionsColumn(
t: TFunction,
deps: {
bypassEnabled: boolean;
bypassing: boolean;
isPaying: boolean;
onBypass: (app: LicenseApplication) => void;
onCertificate: (app: LicenseApplication) => void;
onPay: (app: LicenseApplication) => void;
onOpen: (app: LicenseApplication) => void;
},
): AdvancedColumn<LicenseApplication> {
return {
header: '',
label: t('common.actions'),
align: 'right',
cell: ({ row }) => {
const app = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
{deps.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
<Button
size="xs"
variant="default"
loading={deps.bypassing}
onClick={() => deps.onBypass(app)}
title="Testing only — marks the fee paid and issues the licence"
>
{t('applications.actions.bypass')}
</Button>
)}
{/* An issued application's primary action is the certificate. It
used to be "View", which opened the application wizard — so
the one thing the applicant came back for was the one thing
the button did not do. */}
{app.status === 'CERTIFICATE_ISSUED' && (
<Button
size="xs"
leftSection={<IconDownload size={14} />}
onClick={() => deps.onCertificate(app)}
>
{t('applications.actions.certificate')}
</Button>
)}
<Button
size="xs"
loading={deps.isPaying && app.status === 'PAYMENT_PENDING'}
variant={
app.status === 'RESUBMIT_REQUIRED' || app.status === 'PAYMENT_PENDING' ? 'filled' : 'subtle'
}
color={
app.status === 'RESUBMIT_REQUIRED'
? 'orange'
: app.status === 'PAYMENT_PENDING'
? 'yellow'
: undefined
}
onClick={() =>
// Paying leaves the SPA for Telebirr, so this is a provider
// hand-off rather than a route change.
app.status === 'PAYMENT_PENDING' ? deps.onPay(app) : deps.onOpen(app)
}
>
{app.status === 'DRAFT'
? t('applications.actions.continue')
: app.status === 'RESUBMIT_REQUIRED'
? t('applications.actions.fixResubmit')
: app.status === 'PAYMENT_PENDING'
? t('applications.actions.pay', {
amount: Number(app.feeAmount ?? 0).toLocaleString(),
currency: app.feeCurrency,
})
: app.status === 'CERTIFICATE_ISSUED'
? t('applications.actions.certificate')
: t('applications.actions.view')}
</Button>
</Group>
);
},
};
}

View File

@@ -0,0 +1,70 @@
import { Badge, Box, Progress, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_PROGRESS,
applicantOrCompanyName,
localized,
type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api';
export function applicationColumns(
t: TFunction,
deps: {
language: string;
showDate: (date: string) => string;
statusLabel: (status: LicenseStatus) => string;
},
): AdvancedColumn<LicenseApplication>[] {
return [
{
header: t('applications.table.licence'),
cell: ({ row }) => (
<Box>
<Text size="sm" fw={600}>
{localized(row.original.licenseType?.name, deps.language) || '—'}
</Text>
<Text size="xs" c="dimmed">
{row.original.applicationNumber}
</Text>
</Box>
),
},
{
header: t('applications.table.applicant'),
cell: ({ row }) => <Text size="sm">{applicantOrCompanyName(row.original) ?? '—'}</Text>,
},
{
header: t('common.status'),
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{deps.statusLabel(row.original.status)}
</Badge>
),
},
{
header: t('applications.table.progress'),
size: 140,
cell: ({ row }) => (
<Progress
value={STATUS_PROGRESS[row.original.status]}
color={STATUS_COLORS[row.original.status]}
size="sm"
radius="xl"
/>
),
},
{
header: t('common.date'),
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.submittedAt
? deps.showDate(row.original.submittedAt)
: t('applications.card.notFiled')}
</Text>
),
},
];
}

View File

@@ -3,14 +3,12 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Box,
Button,
Card,
Container,
Group,
Paper,
Progress,
Select,
SimpleGrid,
Skeleton,
@@ -26,35 +24,33 @@ import {
IconCertificate,
IconClipboardList,
IconClockHour4,
IconDownload,
IconFileText,
IconPlus,
IconSearch,
IconX,
} from '@tabler/icons-react';
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { LicenseCatalogue } from '../components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../components/LicenseCard';
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
import { LicenseCatalogue } from '../../components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../../components/LicenseCard';
import { useApplicationPayment } from '../../../payments/hooks/useApplicationPayment';
import { notifications } from '@mantine/notifications';
import {
APPLICANT_ACTION_STATUSES,
STATUS_COLORS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
applicantOrCompanyName,
extractErrorMessage,
localized,
useBypassPaymentMutation,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetPaymentCapabilitiesQuery,
type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api';
import classes from './MyApplicationsPage.module.css';
import { applicationColumns } from './columns';
import { applicationActionsColumn } from './actions';
import classes from '../MyApplicationsPage.module.css';
/** Days before expiry at which a licence is worth flagging. */
const EXPIRY_WARNING_DAYS = 60;
@@ -238,124 +234,18 @@ export function MyApplicationsPage() {
const allStatuses = Object.keys(STATUS_COLORS) as LicenseStatus[];
const applicationColumns: AdvancedColumn<LicenseApplication>[] = [
{
header: t('applications.table.licence'),
cell: ({ row }) => (
<Box>
<Text size="sm" fw={600}>
{localized(row.original.licenseType?.name, i18n.language) || '—'}
</Text>
<Text size="xs" c="dimmed">
{row.original.applicationNumber}
</Text>
</Box>
),
},
{
header: t('applications.table.applicant'),
cell: ({ row }) => <Text size="sm">{applicantOrCompanyName(row.original) ?? '—'}</Text>,
},
{
header: t('common.status'),
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{statusLabel(row.original.status)}
</Badge>
),
},
{
header: t('applications.table.progress'),
size: 140,
cell: ({ row }) => (
<Progress
value={STATUS_PROGRESS[row.original.status]}
color={STATUS_COLORS[row.original.status]}
size="sm"
radius="xl"
/>
),
},
{
header: t('common.date'),
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.submittedAt
? showDate(row.original.submittedAt)
: t('applications.card.notFiled')}
</Text>
),
},
{
header: '',
label: t('common.actions'),
align: 'right',
cell: ({ row }) => {
const app = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
{capabilities?.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
<Button
size="xs"
variant="default"
loading={bypassing}
onClick={() => handleBypass(app.id)}
title="Testing only — marks the fee paid and issues the licence"
>
{t('applications.actions.bypass')}
</Button>
)}
{/* An issued application's primary action is the certificate. It
used to be "View", which opened the application wizard so
the one thing the applicant came back for was the one thing
the button did not do. */}
{app.status === 'CERTIFICATE_ISSUED' && (
<Button
size="xs"
leftSection={<IconDownload size={14} />}
onClick={() => openCertificateForApplication(app.id)}
>
{t('applications.actions.certificate')}
</Button>
)}
<Button
size="xs"
loading={isPaying && app.status === 'PAYMENT_PENDING'}
variant={
app.status === 'RESUBMIT_REQUIRED' || app.status === 'PAYMENT_PENDING' ? 'filled' : 'subtle'
}
color={
app.status === 'RESUBMIT_REQUIRED'
? 'orange'
: app.status === 'PAYMENT_PENDING'
? 'yellow'
: undefined
}
onClick={() =>
// Paying leaves the SPA for Telebirr, so this is a provider
// hand-off rather than a route change.
app.status === 'PAYMENT_PENDING'
? pay(app.id)
: navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`)
}
>
{app.status === 'DRAFT'
? t('applications.actions.continue')
: app.status === 'RESUBMIT_REQUIRED'
? t('applications.actions.fixResubmit')
: app.status === 'PAYMENT_PENDING'
? t('applications.actions.pay', {
amount: Number(app.feeAmount ?? 0).toLocaleString(),
currency: app.feeCurrency,
})
: app.status === 'CERTIFICATE_ISSUED'
? t('applications.actions.certificate')
: t('applications.actions.view')}
</Button>
</Group>
);
},
},
const columns = [
...applicationColumns(t, { language: i18n.language, showDate, statusLabel }),
applicationActionsColumn(t, {
bypassEnabled: capabilities?.bypassEnabled ?? false,
bypassing,
isPaying,
onBypass: (app) => handleBypass(app.id),
onCertificate: (app) => openCertificateForApplication(app.id),
onPay: (app) => pay(app.id),
onOpen: (app) =>
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
}),
];
return (
@@ -497,7 +387,7 @@ export function MyApplicationsPage() {
/>
) : (
<AdvancedTable
columns={applicationColumns}
columns={columns}
data={page.rows}
tableName={t('applications.tabs.applications')}
itemCount={page.itemCount}

View File

@@ -0,0 +1,98 @@
import { ActionIcon, Group, Tooltip } from '@mantine/core';
import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
export function seaServiceActionsColumn(handlers: {
onEvidence: (record: SeaServiceRecord) => void;
onEdit: (record: SeaServiceRecord) => void;
onDelete: (record: SeaServiceRecord) => void;
}): AdvancedColumn<SeaServiceRecord> {
return {
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const record = row.original;
const locked = record.status !== 'SUBMITTED';
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Evidence">
<ActionIcon
variant="subtle"
onClick={() => handlers.onEvidence(record)}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => handlers.onEdit(record)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => handlers.onDelete(record)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
},
};
}
export function medicalActionsColumn(handlers: {
onEvidence: (certificate: MedicalCertificate) => void;
onEdit: (certificate: MedicalCertificate) => void;
onDelete: (certificate: MedicalCertificate) => void;
}): AdvancedColumn<MedicalCertificate> {
return {
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const certificate = row.original;
const locked = certificate.status !== 'SUBMITTED';
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Scan / evidence">
<ActionIcon
variant="subtle"
onClick={() => handlers.onEvidence(certificate)}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => handlers.onEdit(certificate)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => handlers.onDelete(certificate)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
},
};
}

View File

@@ -0,0 +1,117 @@
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
};
export const FITNESS_OPTIONS = [
{ value: 'FIT', label: 'Fit' },
{ value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' },
{ value: 'UNFIT', label: 'Unfit' },
];
export function seaServiceColumns(
showDate: (date: string) => string,
): AdvancedColumn<SeaServiceRecord>[] {
return [
{
header: 'Vessel',
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.vesselName}
</Text>
{row.original.imoNumber && (
<Text size="xs" c="dimmed">
IMO {row.original.imoNumber}
</Text>
)}
</>
),
},
{ header: 'Rank', accessorKey: 'rank' },
{
header: 'From',
accessorKey: 'engagementDate',
cell: ({ row }) => showDate(row.original.engagementDate),
},
{
header: 'To',
accessorKey: 'dischargeDate',
cell: ({ row }) => showDate(row.original.dischargeDate),
},
{
header: 'Status',
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
</Tooltip>
),
},
];
}
export function medicalColumns(
showDate: (date: string) => string,
): AdvancedColumn<MedicalCertificate>[] {
const today = new Date().toISOString().slice(0, 10);
return [
{
header: 'Issuer',
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.issuerName}
</Text>
{row.original.certificateNumber && (
<Text size="xs" c="dimmed">
{row.original.certificateNumber}
</Text>
)}
</>
),
},
{
header: 'Issued',
accessorKey: 'issueDate',
cell: ({ row }) => showDate(row.original.issueDate),
},
{
header: 'Expires',
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
{showDate(row.original.expiryDate)}
{row.original.expiryDate < today && <Badge color="red">Expired</Badge>}
</Group>
),
},
{
header: 'Fitness',
cell: ({ row }) =>
FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus)
?.label ?? row.original.fitnessStatus,
},
{
header: 'Status',
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
</Tooltip>
),
},
];
}

View File

@@ -1,5 +1,4 @@
import {
ActionIcon,
Alert,
Anchor,
Badge,
@@ -18,20 +17,17 @@ import {
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAnchor,
IconEdit,
IconFileUpload,
IconInfoCircle,
IconPaperclip,
IconPlus,
IconStethoscope,
IconTrash,
} from '@tabler/icons-react';
import { useState } from 'react';
import { AdvancedTable, AmharicDatePicker, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import { AdvancedTable, AmharicDatePicker, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -48,18 +44,8 @@ import {
useUpdateSeaServiceRecordMutation,
} from '@ema-platform/api';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
};
const FITNESS_OPTIONS = [
{ value: 'FIT', label: 'Fit' },
{ value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' },
{ value: 'UNFIT', label: 'Unfit' },
];
import { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns';
import { seaServiceActionsColumn, medicalActionsColumn } from './actions';
/**
* Evidence viewer/uploader shared by both record kinds.
@@ -242,86 +228,13 @@ function SeaServiceTab() {
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
const page = paginate(records ?? []);
const columns: AdvancedColumn<SeaServiceRecord>[] = [
{
header: 'Vessel',
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.vesselName}
</Text>
{row.original.imoNumber && (
<Text size="xs" c="dimmed">
IMO {row.original.imoNumber}
</Text>
)}
</>
),
},
{ header: 'Rank', accessorKey: 'rank' },
{
header: 'From',
accessorKey: 'engagementDate',
cell: ({ row }) => showDate(row.original.engagementDate),
},
{
header: 'To',
accessorKey: 'dischargeDate',
cell: ({ row }) => showDate(row.original.dischargeDate),
},
{
header: 'Status',
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
</Tooltip>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const record = row.original;
const locked = record.status !== 'SUBMITTED';
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Evidence">
<ActionIcon
variant="subtle"
onClick={() => setEvidenceFor(record.id)}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => openEdit(record)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => remove(record)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
},
},
const columns = [
...seaServiceColumns(showDate),
seaServiceActionsColumn({
onEvidence: (record) => setEvidenceFor(record.id),
onEdit: openEdit,
onDelete: remove,
}),
];
return (
@@ -547,100 +460,16 @@ function MedicalTab() {
form.expiryDate &&
form.issueDate < form.expiryDate;
const today = new Date().toISOString().slice(0, 10);
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
const page = paginate(certificates ?? []);
const columns: AdvancedColumn<MedicalCertificate>[] = [
{
header: 'Issuer',
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.issuerName}
</Text>
{row.original.certificateNumber && (
<Text size="xs" c="dimmed">
{row.original.certificateNumber}
</Text>
)}
</>
),
},
{
header: 'Issued',
accessorKey: 'issueDate',
cell: ({ row }) => showDate(row.original.issueDate),
},
{
header: 'Expires',
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
{showDate(row.original.expiryDate)}
{row.original.expiryDate < today && <Badge color="red">Expired</Badge>}
</Group>
),
},
{
header: 'Fitness',
cell: ({ row }) =>
FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus)
?.label ?? row.original.fitnessStatus,
},
{
header: 'Status',
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
</Tooltip>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const certificate = row.original;
const locked = certificate.status !== 'SUBMITTED';
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Scan / evidence">
<ActionIcon
variant="subtle"
onClick={() => setEvidenceFor(certificate.id)}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => openEdit(certificate)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => remove(certificate)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
},
},
const columns = [
...medicalColumns(showDate),
medicalActionsColumn({
onEvidence: (certificate) => setEvidenceFor(certificate.id),
onEdit: openEdit,
onDelete: remove,
}),
];
return (

View File

@@ -0,0 +1,143 @@
import { Badge, Button, Group, Text, Tooltip } from '@mantine/core';
import {
IconAlertTriangle,
IconCertificate,
IconRefresh,
} from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { IssuedLicense, Vessel } from '@ema-platform/api';
const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
};
export function vesselColumns(handlers: {
licenseById: Map<string, IssuedLicense>;
onDownloadCertificate: (vessel: Vessel) => void;
onRenew: (vessel: Vessel) => void;
onReportIncident: (vessel: Vessel) => void;
}): AdvancedColumn<Vessel>[] {
return [
{
header: 'Registration №',
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.registrationNumber}
</Text>
),
},
{
header: 'Vessel',
cell: ({ row }) => (
<>
<Text size="sm" fw={500}>
{row.original.name}
</Text>
<Text size="xs" c="dimmed">
{row.original.vesselType ?? '—'}
{row.original.imoNumber ? ` · IMO ${row.original.imoNumber}` : ''}
</Text>
</>
),
},
{
header: 'Category',
cell: ({ row }) =>
CATEGORY_LABELS[row.original.category] ?? row.original.category,
},
{
header: 'Certificate',
cell: ({ row }) => {
const license = handlers.licenseById.get(row.original.licenseId);
const expiring =
license?.daysUntilExpiry !== undefined &&
license.daysUntilExpiry <= 60;
return license ? (
<Group gap={6} wrap="nowrap">
<Badge
size="sm"
variant="light"
color={
license.status === 'ACTIVE'
? expiring
? 'yellow'
: 'green'
: 'red'
}
>
{license.status === 'ACTIVE' && expiring
? `Expires in ${license.daysUntilExpiry}d`
: license.status}
</Badge>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
);
},
},
{
header: 'Status',
cell: ({ row }) => (
<Badge size="sm" color={VESSEL_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const vessel = row.original;
const renewable =
handlers.licenseById.get(vessel.licenseId)?.renewable ?? false;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Download certificate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => handlers.onDownloadCertificate(vessel)}
>
Certificate
</Button>
</Tooltip>
{renewable && vessel.status === 'REGISTERED' && (
<Tooltip label="Renew the registration">
<Button
size="compact-xs"
variant="light"
leftSection={<IconRefresh size={14} />}
onClick={() => handlers.onRenew(vessel)}
>
Renew
</Button>
</Tooltip>
)}
<Tooltip label="Report accident / incident">
<Button
size="compact-xs"
variant="subtle"
color="orange"
leftSection={<IconAlertTriangle size={14} />}
onClick={() => handlers.onReportIncident(vessel)}
>
Incident
</Button>
</Tooltip>
</Group>
);
},
},
];
}

View File

@@ -11,24 +11,19 @@ import {
Paper,
Progress,
Stack,
Table,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
IconAnchor,
IconArrowRight,
IconCertificate,
IconInfoCircle,
IconPlus,
IconRefresh,
IconShip,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
@@ -43,20 +38,10 @@ import {
useGetMyVesselsQuery,
} from '@ema-platform/api';
import type { Vessel } from '@ema-platform/api';
import { vesselColumns } from './columns';
const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION';
const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
};
/** US-VES-016: the owner reports an accident or incident on their vessel. */
function IncidentModal({
vessel,
@@ -142,13 +127,15 @@ function IncidentModal({
*/
export function VesselRegistrationPage() {
const navigate = useNavigate();
const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery();
const { data: vessels, isLoading: loadingVessels, refetch } = useGetMyVesselsQuery();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const [createApplication] = useCreateApplicationMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const [incidentFor, setIncidentFor] = useState<Vessel | null>(null);
const table = useServerTable();
const pagedVessels = table.paginate(vessels ?? []);
const inFlight = (applications?.items ?? []).filter(
(app) =>
@@ -286,120 +273,21 @@ export function VesselRegistrationPage() {
</Stack>
</Paper>
) : (
<Table.ScrollContainer minWidth={760}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Registration </Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Certificate</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(vessels ?? []).map((vessel) => {
const license = licenseById.get(vessel.licenseId);
const renewable = license?.renewable ?? false;
const expiring =
license?.daysUntilExpiry !== undefined &&
license.daysUntilExpiry <= 60;
return (
<Table.Tr key={vessel.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{vessel.registrationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{vessel.name}
</Text>
<Text size="xs" c="dimmed">
{vessel.vesselType ?? '—'}
{vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''}
</Text>
</Table.Td>
<Table.Td>
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
{license ? (
<Group gap={6} wrap="nowrap">
<Badge
size="sm"
variant="light"
color={
license.status === 'ACTIVE'
? expiring
? 'yellow'
: 'green'
: 'red'
}
>
{license.status === 'ACTIVE' && expiring
? `Expires in ${license.daysUntilExpiry}d`
: license.status}
</Badge>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={VESSEL_STATUS_COLORS[vessel.status]}
>
{vessel.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Download certificate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => downloadCertificate(vessel)}
>
Certificate
</Button>
</Tooltip>
{renewable && vessel.status === 'REGISTERED' && (
<Tooltip label="Renew the registration">
<Button
size="compact-xs"
variant="light"
leftSection={<IconRefresh size={14} />}
onClick={() => renew(vessel)}
>
Renew
</Button>
</Tooltip>
)}
<Tooltip label="Report accident / incident">
<Button
size="compact-xs"
variant="subtle"
color="orange"
leftSection={<IconAlertTriangle size={14} />}
onClick={() => setIncidentFor(vessel)}
>
Incident
</Button>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<AdvancedTable
tableName="My vessels"
columns={vesselColumns({
licenseById,
onDownloadCertificate: downloadCertificate,
onRenew: renew,
onReportIncident: setIncidentFor,
})}
data={pagedVessels.rows}
itemCount={pagedVessels.itemCount}
pageIndex={pagedVessels.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
refresh={refetch}
/>
)}
</Stack>

View File

@@ -0,0 +1,59 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconFileText } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
export function waiverLetterColumns(deps: {
t: TFunction;
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
onDownload: (license: IssuedLicense) => void;
}): AdvancedColumn<IssuedLicense>[] {
const { t } = deps;
return [
{
header: t('waiver.columns.reference', 'Reference'),
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.certificateNumber}
</Text>
),
},
{
header: t('waiver.columns.kind', 'Kind'),
cell: ({ row }) => deps.localized(row.original.licenseType?.name) || '—',
},
{
header: t('waiver.columns.issued', 'Issued'),
cell: ({ row }) => deps.showDate(row.original.issueDate),
},
{
header: t('waiver.columns.status', 'Status'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={row.original.status === 'ACTIVE' ? 'green' : 'red'}
>
{t(`waiver.licenseStatus.${row.original.status}`, row.original.status)}
</Badge>
),
},
{
header: '',
label: t('waiver.columns.actions', 'Actions'),
align: 'right',
cell: ({ row }) => (
<Button
size="compact-xs"
variant="light"
leftSection={<IconFileText size={13} />}
onClick={() => deps.onDownload(row.original)}
>
{t('waiver.letter', 'Letter')}
</Button>
),
},
];
}

View File

@@ -7,15 +7,10 @@ import {
Loader,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconFileText,
IconInfoCircle,
} from '@tabler/icons-react';
import { IconArrowRight, IconInfoCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@@ -28,8 +23,9 @@ import {
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { waiverLetterColumns } from './columns';
const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
@@ -45,10 +41,11 @@ export function WaiverPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: applications, isLoading } = useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const { data: licenses, refetch } = useGetMyLicensesQuery();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const showDate = useDateDisplayer();
const localized = useLocalized();
const lettersTable = useServerTable();
const waiverApplications = (applications?.items ?? []).filter((app) =>
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
@@ -77,6 +74,8 @@ export function WaiverPage() {
);
}
const pagedLetters = lettersTable.paginate(letters);
return (
<Stack maw={900} mx="auto">
<div>
@@ -169,59 +168,22 @@ export function WaiverPage() {
<Stack gap="xs">
<Title order={4}>{t('waiver.issuedLetters', 'Issued waiver letters')}</Title>
{letters.length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
{t('waiver.emptyLetters', 'No waiver letters issued yet.')}
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('waiver.columns.reference', 'Reference')}</Table.Th>
<Table.Th>{t('waiver.columns.kind', 'Kind')}</Table.Th>
<Table.Th>{t('waiver.columns.issued', 'Issued')}</Table.Th>
<Table.Th>{t('waiver.columns.status', 'Status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{letters.map((license) => (
<Table.Tr key={license.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{license.certificateNumber}
</Text>
</Table.Td>
<Table.Td>{localized(license.licenseType?.name) || '—'}</Table.Td>
<Table.Td>{showDate(license.issueDate)}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={license.status === 'ACTIVE' ? 'green' : 'red'}
>
{t(`waiver.licenseStatus.${license.status}`, license.status)}
</Badge>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="light"
leftSection={<IconFileText size={13} />}
onClick={() => download(license.id)}
>
{t('waiver.letter', 'Letter')}
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<AdvancedTable
tableName={t('waiver.issuedLetters', 'Issued waiver letters')}
columns={waiverLetterColumns({
t,
localized,
showDate,
onDownload: (license) => download(license.id),
})}
data={pagedLetters.rows}
itemCount={pagedLetters.itemCount}
pageIndex={pagedLetters.pageIndex}
onPageChange={lettersTable.setPageIndex}
pageSize={lettersTable.pageSize}
refresh={refetch}
emptyText={t('waiver.emptyLetters', 'No waiver letters issued yet.')}
/>
</Stack>
</Stack>
);

View File

@@ -49,6 +49,8 @@ interface AdvancedTableProps<T> {
emptyText?: string;
verticalSpacing?: string | number;
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
/** Makes rows clickable (adds pointer cursor). */
onRowClick?: (row: T) => void;
}
function getByPath(obj: unknown, path?: string): unknown {
@@ -79,6 +81,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
emptyText,
verticalSpacing = "sm",
rowStyle,
onRowClick,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(
@@ -202,7 +205,14 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Table.Tr>
) : (
data.map((row, rowIndex) => (
<Table.Tr key={row.id ?? rowIndex} style={rowStyle?.(row, rowIndex)}>
<Table.Tr
key={row.id ?? rowIndex}
onClick={onRowClick ? () => onRowClick(row) : undefined}
style={{
...(onRowClick ? { cursor: "pointer" } : undefined),
...rowStyle?.(row, rowIndex),
}}
>
{shownColumns.map((col, i) => {
const value = getByPath(row, col.accessorKey);
return (