mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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)} />
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -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 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
145
apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx
Normal file
145
apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user