mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat(vessel-registration): add vessel registration page with incident reporting and certificate download functionality
feat(waiver): implement waiver page with application tracking and letter download feature feat(ui): introduce AdvancedTable component for enhanced table functionality across the application chore: update package-lock.json to remove unnecessary dependencies
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { Certification } from '../../types/certification';
|
||||
|
||||
export function certificationColumnActions(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onEdit: (cert: Certification) => void;
|
||||
onDelete: (cert: Certification) => void;
|
||||
},
|
||||
): AdvancedTableAction<Certification>[] {
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('certification.update'),
|
||||
color: 'blue',
|
||||
icon: <IconEdit size={14} />,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('certification.delete'),
|
||||
color: 'red',
|
||||
icon: <IconTrash size={14} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { Certification } from '../../types/certification';
|
||||
|
||||
export function certificationColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
): AdvancedTableColumn<Certification>[] {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: t('certification.columns.name'),
|
||||
render: (cert) => <Text fz="sm" fw={500}>{cert.name[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: t('certification.columns.description'),
|
||||
render: (cert) => <Text fz="sm" lineClamp={2} maw={250}>{cert.description[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('certification.columns.status'),
|
||||
render: (cert) => (
|
||||
<Badge size="sm" variant="light" color={cert.isActive ? 'teal' : 'gray'}>
|
||||
{cert.isActive ? t('certification.status.active') : t('certification.status.inactive')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -4,9 +4,6 @@ import {
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -18,15 +15,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 } from '@ema-platform/ui';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { certificationColumns } from './columns';
|
||||
import { certificationColumnActions } from './actions';
|
||||
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';
|
||||
|
||||
function CertificationForm({
|
||||
editing,
|
||||
@@ -75,7 +74,7 @@ function CertificationForm({
|
||||
export function CertificationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data, isLoading, isError } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError, refetch } = useGetCertificationsQuery();
|
||||
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||
const [deleteCert] = useDeleteCertificationMutation();
|
||||
@@ -148,48 +147,17 @@ export function CertificationPage() {
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('certification.columns.name')}</Table.Th>
|
||||
<Table.Th>{t('certification.columns.description')}</Table.Th>
|
||||
<Table.Th>{t('certification.columns.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{certifications.map((cert) => (
|
||||
<Table.Tr key={cert.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{cert.name[locale]}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" lineClamp={2} maw={250}>{cert.description[locale]}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={cert.isActive ? 'teal' : 'gray'}>
|
||||
{cert.isActive ? t('certification.status.active') : t('certification.status.inactive')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(cert); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(cert); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{certifications.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('certification.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<AdvancedTable
|
||||
columns={certificationColumns(t, locale)}
|
||||
data={certifications}
|
||||
rowKey={(cert) => cert.id}
|
||||
actions={certificationColumnActions(t, {
|
||||
onEdit: (cert) => { setEditing(cert); setShowForm(true); },
|
||||
onDelete: (cert) => { setDeleteTarget(cert); openDelete(); },
|
||||
})}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('certification.noItems')}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { Profession } from '../../types/configuration';
|
||||
|
||||
export function professionColumnActions(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onEdit: (prof: Profession) => void;
|
||||
onDelete: (prof: Profession) => void;
|
||||
},
|
||||
): AdvancedTableAction<Profession>[] {
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('configuration.edit'),
|
||||
color: 'blue',
|
||||
icon: <IconEdit size={14} />,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('configuration.delete'),
|
||||
color: 'red',
|
||||
icon: <IconTrash size={14} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { Profession } from '../../types/configuration';
|
||||
|
||||
export function professionColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
getDeptName: (deptId: string) => string,
|
||||
): AdvancedTableColumn<Profession>[] {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: t('configuration.name'),
|
||||
render: (prof) => prof.name[locale],
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: t('configuration.description'),
|
||||
render: (prof) => (
|
||||
<Text size="sm" lineClamp={2} maw={200}>{prof.description[locale]}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'department',
|
||||
header: t('configuration.department'),
|
||||
render: (prof) => getDeptName(prof.departmentId),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
Button,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
Select,
|
||||
@@ -19,19 +17,21 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { LocationPage } from '../../location/pages/LocationPage';
|
||||
import { CertificationPage } from '../../certification/pages/CertificationPage';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { professionColumns } from './columns';
|
||||
import { professionColumnActions } from './actions';
|
||||
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';
|
||||
|
||||
interface ProfFormValues {
|
||||
nameEn: string;
|
||||
@@ -132,7 +132,7 @@ function ProfessionTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: deptRes } = useGetOrganizationsQuery();
|
||||
const { data: profRes, isLoading, isError } = useGetProfessionsQuery();
|
||||
const { data: profRes, isLoading, isError, refetch } = useGetProfessionsQuery();
|
||||
const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation();
|
||||
const [updateProfession, { isLoading: isUpdating }] = useUpdateProfessionMutation();
|
||||
const [deleteProfession] = useDeleteProfessionMutation();
|
||||
@@ -243,46 +243,17 @@ function ProfessionTab() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('configuration.name')}</Table.Th>
|
||||
<Table.Th>{t('configuration.description')}</Table.Th>
|
||||
<Table.Th>{t('configuration.department')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{professions.filter((p) => p.isActive).map((prof) => (
|
||||
<Table.Tr key={prof.id}>
|
||||
<Table.Td>{prof.name[locale]}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" lineClamp={2} maw={200}>{prof.description[locale]}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{getDeptName(prof.departmentId)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handleEditProf(prof)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handleDeleteProf(prof)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{professions.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t('configuration.noProfessions')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<AdvancedTable
|
||||
columns={professionColumns(t, locale, getDeptName)}
|
||||
data={professions.filter((p) => p.isActive)}
|
||||
rowKey={(prof) => prof.id}
|
||||
actions={professionColumnActions(t, {
|
||||
onEdit: handleEditProf,
|
||||
onDelete: handleDeleteProf,
|
||||
})}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('configuration.noProfessions')}
|
||||
/>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('configuration.confirmDelete')} size="sm">
|
||||
<Text mb="md">
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
export const dashboardQueueColumns: AdvancedTableColumn<LicenseApplication>[] = [
|
||||
{
|
||||
key: 'applicationNumber',
|
||||
header: 'Number',
|
||||
render: (app) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'companyName',
|
||||
header: 'Company',
|
||||
render: (app) => <Text size="sm">{app.companyName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (app) => (
|
||||
<Badge variant="light" color={STATUS_COLORS[app.status as LicenseStatus]}>
|
||||
{STATUS_LABELS[app.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 } from '@ema-platform/ui';
|
||||
import { dashboardQueueColumns } from './columns';
|
||||
|
||||
/**
|
||||
* Backoffice home.
|
||||
@@ -95,49 +89,14 @@ 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
|
||||
columns={dashboardQueueColumns}
|
||||
data={unclaimed.slice(0, 8)}
|
||||
rowKey={(app) => app.id}
|
||||
onRowClick={() => navigate('/licence-review')}
|
||||
onRefresh={queue.refetch}
|
||||
emptyTitle="Nothing waiting to be claimed."
|
||||
/>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconUserCheck } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } 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 },
|
||||
): AdvancedTableColumn<ExamRegistration>[] {
|
||||
return [
|
||||
{
|
||||
key: 'admission',
|
||||
header: t('exam.candidates.admission'),
|
||||
render: (registration) => (
|
||||
<Text fz="sm" ff="monospace" fw={600}>
|
||||
{registration.admissionNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: t('exam.candidates.name'),
|
||||
render: (registration) => <Text fz="sm">{candidateName(registration)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'attempt',
|
||||
header: t('exam.candidates.attempt'),
|
||||
render: (registration) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attendance',
|
||||
header: t('exam.candidates.attendance'),
|
||||
render: (registration) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.attendance.${registration.attendanceStatus}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'remark',
|
||||
header: t('exam.candidates.remark'),
|
||||
render: (registration) => (
|
||||
<Text fz="xs" c="dimmed" maw={220} lineClamp={2}>
|
||||
{registration.attendanceRemark ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'record',
|
||||
header: '',
|
||||
render: (registration) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconUserCheck size={12} />}
|
||||
onClick={() => handlers.onRecord(registration)}
|
||||
>
|
||||
{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 } 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,21 @@ 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 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;
|
||||
@@ -100,75 +91,12 @@ 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
|
||||
columns={examCandidateColumns(t, { onRecord: startRecording })}
|
||||
data={registrations ?? []}
|
||||
rowKey={(registration) => registration.id}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } 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,
|
||||
handlers: { onResolve: (incident: ExamIncident) => void },
|
||||
): AdvancedTableColumn<ExamIncident>[] {
|
||||
return [
|
||||
{
|
||||
key: 'type',
|
||||
header: t('exam.incidents.type'),
|
||||
render: (incident) => (
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{t(`exam.incidentType.${incident.type}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'candidate',
|
||||
header: t('exam.incidents.candidate'),
|
||||
render: (incident) => (
|
||||
<Text fz="xs">
|
||||
{incident.registration?.admissionNumber ?? t('exam.incidents.wholeRoom')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: t('exam.incidents.description'),
|
||||
render: (incident) => (
|
||||
<>
|
||||
<Text fz="xs" maw={260} lineClamp={2}>
|
||||
{incident.description}
|
||||
</Text>
|
||||
{incident.resolution && (
|
||||
<Text fz="xs" c="dimmed" maw={260} lineClamp={2}>
|
||||
⤷ {incident.resolution}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'occurred',
|
||||
header: t('exam.incidents.occurred'),
|
||||
render: (incident) => <Text fz="xs">{incident.occurredAt?.slice(0, 10)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('exam.incidents.status'),
|
||||
render: (incident) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[incident.status] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.incidentStatus.${incident.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resolve',
|
||||
header: '',
|
||||
render: (incident) =>
|
||||
(incident.status === 'OPEN' || incident.status === 'UNDER_REVIEW') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
onClick={() => handlers.onResolve(incident)}
|
||||
>
|
||||
{t('exam.incidents.resolve')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -2,32 +2,27 @@ 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 } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamIncidentsQuery,
|
||||
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',
|
||||
@@ -37,13 +32,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
|
||||
@@ -51,7 +39,7 @@ const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
*/
|
||||
export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: incidents, isError } = useGetExamIncidentsQuery(examId);
|
||||
const { data: incidents, isError, refetch } = useGetExamIncidentsQuery(examId);
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation();
|
||||
const [resolveIncident, { isLoading: isResolving }] = useResolveIncidentMutation();
|
||||
@@ -96,6 +84,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'));
|
||||
@@ -139,74 +133,12 @@ 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">{incident.occurredAt?.slice(0, 10)}</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
|
||||
columns={examIncidentColumns(t, { onResolve: startResolve })}
|
||||
data={incidents ?? []}
|
||||
rowKey={(incident) => incident.id}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { Exam } from '../../types/exam';
|
||||
|
||||
export function examColumnActions(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onEdit: (exam: Exam) => void;
|
||||
onDelete: (exam: Exam) => void;
|
||||
},
|
||||
): AdvancedTableAction<Exam>[] {
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('exam.update'),
|
||||
color: 'blue',
|
||||
icon: <IconEdit size={14} />,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('exam.delete'),
|
||||
color: 'red',
|
||||
icon: <IconTrash size={14} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } 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,
|
||||
handlers: {
|
||||
locale: 'en' | 'am';
|
||||
getCertName: (id: string) => string;
|
||||
onTitleClick: (exam: Exam) => void;
|
||||
},
|
||||
): AdvancedTableColumn<Exam>[] {
|
||||
return [
|
||||
{
|
||||
key: 'title',
|
||||
header: t('exam.columns.title'),
|
||||
render: (exam) => (
|
||||
<Text
|
||||
fz="sm"
|
||||
fw={500}
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handlers.onTitleClick(exam)}
|
||||
>
|
||||
{exam.title[handlers.locale]}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'certification',
|
||||
header: t('exam.columns.certification'),
|
||||
render: (exam) => <Text fz="sm">{handlers.getCertName(exam.certificationId)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: t('exam.columns.date'),
|
||||
render: (exam) => <Text fz="sm">{exam.date}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: t('exam.columns.type'),
|
||||
render: (exam) => (
|
||||
<Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>
|
||||
{t(`exam.type.${exam.type}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'form',
|
||||
header: t('exam.columns.form'),
|
||||
render: (exam) => (
|
||||
<Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||
{t(`exam.formType.${exam.form}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'venue',
|
||||
header: t('exam.columns.venue'),
|
||||
render: (exam) => <Text fz="sm">{exam.venue}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'questions',
|
||||
header: t('exam.columns.questions'),
|
||||
render: (exam) => (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{exam.questions?.length ?? 0}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('exam.columns.status'),
|
||||
render: (exam) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>
|
||||
{t(`exam.status.${exam.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -5,9 +5,6 @@ import {
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -24,25 +21,18 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
import { IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } 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 { examColumnActions } from './actions';
|
||||
|
||||
function ExamForm({
|
||||
editing,
|
||||
@@ -151,7 +141,7 @@ export function ExamPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError } = useGetExamsQuery();
|
||||
const { data, isLoading, isError, refetch } = useGetExamsQuery();
|
||||
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
||||
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
||||
const [deleteExam] = useDeleteExamMutation();
|
||||
@@ -240,60 +230,21 @@ export function ExamPage() {
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('exam.columns.title')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.certification')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.date')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.type')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.form')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.venue')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.questions')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{exams.map((exam) => (
|
||||
<Table.Tr key={exam.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500} c="blue" style={{ cursor: 'pointer' }} onClick={() => navigate(`/exams/${exam.id}`)}>
|
||||
{exam.title[locale]}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{getCertName(exam.certificationId)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{exam.date}</Text></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>{t(`exam.type.${exam.type}`)}</Badge></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${exam.form}`)}</Badge></Table.Td>
|
||||
<Table.Td><Text fz="sm">{exam.venue}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="gray">{exam.questions?.length ?? 0}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>{t(`exam.status.${exam.status}`)}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(exam); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(exam); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{exams.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={9}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('exam.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<AdvancedTable
|
||||
columns={examColumns(t, {
|
||||
locale,
|
||||
getCertName,
|
||||
onTitleClick: (exam) => navigate(`/exams/${exam.id}`),
|
||||
})}
|
||||
data={exams}
|
||||
rowKey={(exam) => exam.id}
|
||||
actions={examColumnActions(t, {
|
||||
onEdit: (exam) => { setEditing(exam); setShowForm(true); },
|
||||
onDelete: (exam) => { setDeleteTarget(exam); openDelete(); },
|
||||
})}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('exam.noItems')}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
@@ -1,60 +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 } from '@ema-platform/ui';
|
||||
|
||||
const STATUS_COLORS: Record<Item['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
ACTIVE: 'green',
|
||||
ARCHIVED: 'orange',
|
||||
};
|
||||
|
||||
export function ItemTable() {
|
||||
const { data, isLoading } = useGetItemsQuery({});
|
||||
const [deleteItem] = useDeleteItemMutation();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteItem(id).unwrap();
|
||||
notify.success('Item deleted');
|
||||
} catch {
|
||||
notify.error('Failed to delete item');
|
||||
}
|
||||
};
|
||||
|
||||
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>{new Date(item.createdAt).toLocaleDateString()}</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,17 @@
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { Item } from '../../api/item-api';
|
||||
|
||||
export function itemColumnActions(handlers: {
|
||||
onDelete: (item: Item) => void;
|
||||
}): AdvancedTableAction<Item>[] {
|
||||
return [
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
color: 'red',
|
||||
icon: <IconTrash size={16} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import type { AdvancedTableColumn } 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 const itemColumns: AdvancedTableColumn<Item>[] = [
|
||||
{ key: 'name', header: 'Name' },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (item) => <Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Created',
|
||||
render: (item) => new Date(item.createdAt).toLocaleDateString(),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../../api/item-api';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { itemColumns } from './columns';
|
||||
import { itemColumnActions } from './actions';
|
||||
|
||||
export function ItemTable() {
|
||||
const { data, isLoading, refetch } = useGetItemsQuery({});
|
||||
const [deleteItem] = useDeleteItemMutation();
|
||||
|
||||
const handleDelete = async (item: Item) => {
|
||||
try {
|
||||
await deleteItem(item.id).unwrap();
|
||||
notify.success('Item deleted');
|
||||
} catch {
|
||||
notify.error('Failed to delete item');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdvancedTable
|
||||
columns={itemColumns}
|
||||
data={data?.data ?? []}
|
||||
rowKey={(item) => item.id}
|
||||
actions={itemColumnActions({ onDelete: handleDelete })}
|
||||
loading={isLoading}
|
||||
onRefresh={refetch}
|
||||
emptyTitle="No items found"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import { localized } 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',
|
||||
};
|
||||
|
||||
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(handlers: {
|
||||
onStatus: (license: IssuedLicense) => void;
|
||||
}): AdvancedTableColumn<IssuedLicense>[] {
|
||||
return [
|
||||
{
|
||||
key: 'certificateNumber',
|
||||
header: 'Certificate №',
|
||||
render: (license) => (
|
||||
<Text size="sm" ff="monospace" fw={600}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Type',
|
||||
render: (license) => (
|
||||
<Text size="sm">{localized(license.licenseType?.name)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'holder',
|
||||
header: 'Holder',
|
||||
render: (license) => <Text size="sm">{license.companyName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'issued',
|
||||
header: 'Issued',
|
||||
render: (license) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{license.issueDate?.slice(0, 10)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'expires',
|
||||
header: 'Expires',
|
||||
render: (license) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{license.expiryDate?.slice(0, 10)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (license) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={LICENSE_STATUS_COLORS[license.status] ?? 'gray'}
|
||||
>
|
||||
{license.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lifecycle',
|
||||
header: '',
|
||||
render: (license) =>
|
||||
actionsFor(license).length > 0 ? (
|
||||
<Tooltip label="Suspend / revoke / reinstate">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={() => handlers.onStatus(license)}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,43 +1,32 @@
|
||||
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 } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useGetLicensesQuery,
|
||||
useReinstateLicenseMutation,
|
||||
useRevokeLicenseMutation,
|
||||
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,
|
||||
@@ -60,19 +49,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,
|
||||
@@ -160,7 +136,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);
|
||||
@@ -186,83 +162,16 @@ 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">
|
||||
{license.issueDate?.slice(0, 10)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{license.expiryDate?.slice(0, 10)}
|
||||
</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
|
||||
columns={licenseRegisterColumns({ onStatus: setTarget })}
|
||||
data={items}
|
||||
rowKey={(license) => license.id}
|
||||
loading={isLoading}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={
|
||||
search ? 'No licences match that search.' : 'No licences issued yet.'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<LifecycleModal license={target} onClose={() => setTarget(null)} />
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Badge, Button, Group, Text, Tooltip } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
type LicenseApplication,
|
||||
} from '@ema-platform/api';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import { computeSla } from '../../sla';
|
||||
|
||||
export function licenseQueueColumns(
|
||||
t: TFunction,
|
||||
locale: string,
|
||||
handlers: {
|
||||
claiming: boolean;
|
||||
onClaim: (app: LicenseApplication) => void;
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
},
|
||||
): AdvancedTableColumn<LicenseApplication>[] {
|
||||
return [
|
||||
{
|
||||
key: 'applicationNumber',
|
||||
header: t('queue.number', 'App #'),
|
||||
sortable: true,
|
||||
render: (app) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'companyName',
|
||||
header: t('queue.company', 'Company'),
|
||||
sortable: true,
|
||||
render: (app) => <Text size="sm">{app.companyName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'tin',
|
||||
header: t('queue.tin', 'TIN'),
|
||||
render: (app) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.tinNumber ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: t('queue.typeCol', 'Type'),
|
||||
render: (app) => <Text size="sm">{app.licenseType?.name?.en ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('queue.statusCol', 'Status'),
|
||||
sortable: true,
|
||||
render: (app) => (
|
||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'submittedAt',
|
||||
header: t('queue.submitted', 'Submitted'),
|
||||
sortable: true,
|
||||
render: (app) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sla',
|
||||
header: t('queue.sla', 'Age / SLA'),
|
||||
render: (app) => {
|
||||
const sla = computeSla(app);
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Claim/Review are text buttons, so they stay a regular column rather
|
||||
// than being redesigned into AdvancedTable icon actions.
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (app) => (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{app.assignedOfficerId === null && app.status === 'SUBMITTED' ? (
|
||||
<Button size="xs" loading={handlers.claiming} onClick={() => handlers.onClaim(app)}>
|
||||
{t('queue.claim', 'Claim')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={() => handlers.onOpen(app)}>
|
||||
{t('queue.review', 'Review')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Container,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Pagination,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
@@ -17,7 +15,6 @@ import {
|
||||
Stack,
|
||||
Kbd,
|
||||
Modal,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -30,14 +27,11 @@ import {
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconSortAscending,
|
||||
IconSortDescending,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
useClaimApplicationMutation,
|
||||
@@ -47,12 +41,11 @@ import {
|
||||
useGetQueueCountsQuery,
|
||||
useGetQueueQuery,
|
||||
useLazyExportApplicationsQuery,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
type QueueFilter,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState } from '@ema-platform/ui';
|
||||
import { computeSla } from '../sla';
|
||||
import { AdvancedTable, EmptyState, ErrorState } from '@ema-platform/ui';
|
||||
import { licenseQueueColumns } from './columns';
|
||||
import {
|
||||
DEFAULT_VIEW,
|
||||
SAVED_VIEWS,
|
||||
@@ -61,11 +54,11 @@ 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';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
@@ -275,10 +268,6 @@ export function LicenseQueuePage() {
|
||||
onHelp: () => setHelpOpen(true),
|
||||
});
|
||||
|
||||
const allSelected = items.length > 0 && selected.length === items.length;
|
||||
const sortIcon =
|
||||
urlFilter.sortDir === 'DESC' ? <IconSortDescending size={13} /> : <IconSortAscending size={13} />;
|
||||
|
||||
const hasFacets = Boolean(
|
||||
urlFilter.status?.length ||
|
||||
urlFilter.licenseTypeId ||
|
||||
@@ -446,95 +435,48 @@ export function LicenseQueuePage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table highlightOnHover verticalSpacing={density === "compact" ? 4 : "sm"}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label={t('queue.selectAll', 'Select all')}
|
||||
checked={allSelected}
|
||||
indeterminate={selected.length > 0 && !allSelected}
|
||||
onChange={() =>
|
||||
setSelected(allSelected ? [] : items.map((a) => a.id))
|
||||
}
|
||||
/>
|
||||
</Table.Th>
|
||||
<SortableTh
|
||||
label={t('queue.number', 'App #')}
|
||||
field="applicationNumber"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableTh
|
||||
label={t('queue.company', 'Company')}
|
||||
field="companyName"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<Table.Th>{t('queue.tin', 'TIN')}</Table.Th>
|
||||
<Table.Th>{t('queue.typeCol', 'Type')}</Table.Th>
|
||||
<SortableTh
|
||||
label={t('queue.statusCol', 'Status')}
|
||||
field="status"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableTh
|
||||
label={t('queue.submitted', 'Submitted')}
|
||||
field="submittedAt"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<Table.Th>{t('queue.sla', 'Age / SLA')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((app, index) => (
|
||||
<QueueRow
|
||||
key={app.id}
|
||||
app={app}
|
||||
focused={index === cursor}
|
||||
selected={selected.includes(app.id)}
|
||||
claiming={claiming}
|
||||
locale={i18n.language}
|
||||
onSelect={(checked) =>
|
||||
setSelected((prev) =>
|
||||
checked ? [...prev, app.id] : prev.filter((id) => id !== app.id),
|
||||
)
|
||||
}
|
||||
onClaim={() => handleClaim(app.id)}
|
||||
onOpen={() => navigate(`/licence-review/${app.id}`)}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
<Group justify="space-between" p="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('queue.showing', {
|
||||
from: (page - 1) * PAGE_SIZE + 1,
|
||||
to: Math.min(page * PAGE_SIZE, total),
|
||||
total,
|
||||
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
||||
})}
|
||||
</Text>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(next) => {
|
||||
<AdvancedTable
|
||||
columns={licenseQueueColumns(t, i18n.language, {
|
||||
claiming,
|
||||
onClaim: (app) => handleClaim(app.id),
|
||||
onOpen: (app) => navigate(`/licence-review/${app.id}`),
|
||||
})}
|
||||
data={items}
|
||||
rowKey={(app) => app.id}
|
||||
selection={{ selected, onChange: setSelected }}
|
||||
sort={{
|
||||
sortBy: urlFilter.sortBy,
|
||||
sortDir: urlFilter.sortDir === 'DESC' ? 'desc' : 'asc',
|
||||
onSort: (field) =>
|
||||
toggleSort(field as NonNullable<QueueFilter['sortBy']>),
|
||||
}}
|
||||
pagination={{
|
||||
page,
|
||||
totalPages: pageCount,
|
||||
onPageChange: (next) => {
|
||||
setPage(next);
|
||||
updateUrl({}, view, next);
|
||||
}}
|
||||
total={pageCount}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
},
|
||||
}}
|
||||
minWidth={1100}
|
||||
verticalSpacing={density === 'compact' ? 4 : 'sm'}
|
||||
// Keyboard cursor. Marked with a left border rather than a
|
||||
// background so it stays distinguishable from row selection and
|
||||
// from hover.
|
||||
rowStyle={(app) =>
|
||||
app.id === cursorRow?.id
|
||||
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" p="sm">
|
||||
{t('queue.showing', {
|
||||
from: (page - 1) * PAGE_SIZE + 1,
|
||||
to: Math.min(page * PAGE_SIZE, total),
|
||||
total,
|
||||
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -601,122 +543,4 @@ export function LicenseQueuePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTh({
|
||||
label,
|
||||
field,
|
||||
current,
|
||||
icon,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
field: NonNullable<QueueFilter['sortBy']>;
|
||||
current?: QueueFilter['sortBy'];
|
||||
icon: React.ReactNode;
|
||||
onSort: (field: NonNullable<QueueFilter['sortBy']>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Table.Th>
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{current === field && icon}
|
||||
</Group>
|
||||
</Table.Th>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueRow({
|
||||
app,
|
||||
selected,
|
||||
focused,
|
||||
claiming,
|
||||
locale,
|
||||
onSelect,
|
||||
onClaim,
|
||||
onOpen,
|
||||
}: {
|
||||
app: LicenseApplication;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
claiming: boolean;
|
||||
locale: string;
|
||||
onSelect: (checked: boolean) => void;
|
||||
onClaim: () => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sla = computeSla(app);
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
// Keyboard cursor. Marked with a left border rather than a background so
|
||||
// it stays distinguishable from row selection and from hover.
|
||||
style={
|
||||
focused
|
||||
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={t('queue.selectRow', { number: app.applicationNumber, defaultValue: 'Select {{number}}' })}
|
||||
checked={selected}
|
||||
onChange={(e) => onSelect(e.currentTarget.checked)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.companyName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.tinNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.licenseType?.name?.en ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{/* 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>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{app.assignedOfficerId === null && app.status === 'SUBMITTED' ? (
|
||||
<Button size="xs" loading={claiming} onClick={onClaim}>
|
||||
{t('queue.claim', 'Claim')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={onOpen}>
|
||||
{t('queue.review', 'Review')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseQueuePage;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Badge, Checkbox, Group, Text, TextInput } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { ApplicationStaff } from '@ema-platform/api';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
|
||||
export function reviewStaffColumns(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
flags: Record<string, { remark: string }>;
|
||||
onToggleFlag: (member: ApplicationStaff) => void;
|
||||
onRemarkChange: (member: ApplicationStaff, remark: string) => void;
|
||||
},
|
||||
): AdvancedTableColumn<ApplicationStaff>[] {
|
||||
const { flags } = handlers;
|
||||
return [
|
||||
{
|
||||
key: 'roleKey',
|
||||
header: t('review.role', 'Role'),
|
||||
render: (member) => <Text size="xs">{member.roleKey}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'fullName',
|
||||
header: t('review.name', 'Name'),
|
||||
render: (member) => <Text size="sm">{member.fullName}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'evidence',
|
||||
header: t('review.evidence', 'Evidence'),
|
||||
render: (member) => (
|
||||
<Group gap={4}>
|
||||
{(member.documents ?? []).map((doc) => (
|
||||
<Badge key={doc.id} size="xs" variant="light">
|
||||
{doc.documentKey}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
// 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.
|
||||
key: 'correction',
|
||||
header: t('review.correction', 'Correction'),
|
||||
width: 260,
|
||||
render: (member) => (
|
||||
<>
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -57,19 +57,20 @@ import {
|
||||
useScheduleInspectionMutation,
|
||||
type RemarkTargetType,
|
||||
} from '@ema-platform/api';
|
||||
import { ErrorState } from '@ema-platform/ui';
|
||||
import { AdvancedTable, ErrorState } from '@ema-platform/ui';
|
||||
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 }>;
|
||||
|
||||
@@ -764,79 +765,20 @@ export function LicenseReviewPage() {
|
||||
|
||||
<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">{member.roleKey}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{member.fullName}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{(member.documents ?? []).map((doc) => (
|
||||
<Badge key={doc.id} size="xs" variant="light">
|
||||
{doc.documentKey}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</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>
|
||||
<AdvancedTable
|
||||
columns={reviewStaffColumns(t, {
|
||||
flags,
|
||||
onToggleFlag: (member) => toggleFlag('STAFF', member.id),
|
||||
onRemarkChange: (member, remark) =>
|
||||
setFlags((p) => ({
|
||||
...p,
|
||||
[member.id]: { ...p[member.id], remark },
|
||||
})),
|
||||
})}
|
||||
data={data.staff}
|
||||
rowKey={(member) => member.id}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { LocationType } from '../../types/location';
|
||||
|
||||
export function locationTypeColumnActions(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onEdit: (type: LocationType) => void;
|
||||
onDelete: (type: LocationType) => void;
|
||||
},
|
||||
): AdvancedTableAction<LocationType>[] {
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('location.edit'),
|
||||
color: 'blue',
|
||||
icon: <IconEdit size={14} />,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('location.delete'),
|
||||
color: 'red',
|
||||
icon: <IconTrash size={14} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { LocationType } from '../../types/location';
|
||||
|
||||
export function locationTypeColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
): AdvancedTableColumn<LocationType>[] {
|
||||
return [
|
||||
{
|
||||
key: 'level',
|
||||
header: t('location.level'),
|
||||
render: (type) => (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{type.level}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
header: t('location.code'),
|
||||
render: (type) => (
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{type.code}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: t('location.name'),
|
||||
render: (type) => type.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 } from '@ema-platform/ui';
|
||||
} from '../../api/location-api';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { locationTypeColumns } from './columns';
|
||||
import { locationTypeColumnActions } from './actions';
|
||||
|
||||
interface LocationTypeFormValues {
|
||||
code: string;
|
||||
@@ -34,7 +31,7 @@ interface LocationTypeFormValues {
|
||||
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: locationTypes, isLoading } = useGetLocationTypesQuery();
|
||||
const { data: locationTypes, isLoading, refetch } = useGetLocationTypesQuery();
|
||||
const [createType] = useCreateLocationTypeMutation();
|
||||
const [updateType] = useUpdateLocationTypeMutation();
|
||||
const [deleteType] = useDeleteLocationTypeMutation();
|
||||
@@ -171,61 +168,18 @@ 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
|
||||
columns={locationTypeColumns(t, locale)}
|
||||
data={sortedTypes}
|
||||
rowKey={(type) => type.id}
|
||||
actions={locationTypeColumnActions(t, {
|
||||
onEdit: handleEdit,
|
||||
onDelete: (type) => handleDelete(type.id),
|
||||
})}
|
||||
loading={isLoading}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('location.noTypes')}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
export const logisticsHeadDashboardColumns: AdvancedTableColumn<LicenseApplication>[] =
|
||||
[
|
||||
{
|
||||
key: 'applicationNumber',
|
||||
header: 'Number',
|
||||
render: (app) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'companyName',
|
||||
header: 'Company',
|
||||
render: (app) => <Text size="sm">{app.companyName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (app) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLORS[app.status as LicenseStatus]}
|
||||
>
|
||||
{STATUS_LABELS[app.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 } 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.
|
||||
@@ -110,50 +111,17 @@ 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
|
||||
columns={logisticsHeadDashboardColumns}
|
||||
data={recent}
|
||||
rowKey={(app) => app.id}
|
||||
onRefresh={() => {
|
||||
queue.refetch();
|
||||
mine.refetch();
|
||||
}}
|
||||
emptyTitle="No licence applications yet."
|
||||
onRowClick={(app) => navigate(`/licence-review/${app.id}`)}
|
||||
/>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconCheck,
|
||||
IconStethoscope,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useGetPendingMedicalQuery,
|
||||
useGetPendingSeaServiceQuery,
|
||||
useVerifyMedicalCertificateMutation,
|
||||
useVerifySeaServiceRecordMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type {
|
||||
MedicalCertificate,
|
||||
SeaServiceRecord,
|
||||
SeafarerProfileSummary,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
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,
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading,
|
||||
}: {
|
||||
title: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (remark: string) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [remark, setRemark] = useState('');
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} centered>
|
||||
<Stack>
|
||||
<Textarea
|
||||
label="What must the seafarer fix?"
|
||||
required
|
||||
minRows={2}
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={remark.trim().length < 3}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onConfirm(remark.trim());
|
||||
setRemark('');
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The record-verification workspace (US-SSM-003/007): everything seafarers
|
||||
* have submitted and no officer has ruled on yet, oldest first. VERIFIED
|
||||
* freezes a record — sea service starts counting toward sea time, a medical
|
||||
* certificate starts satisfying the submission gate.
|
||||
*/
|
||||
export function MedicalVerificationPage() {
|
||||
const { data: pendingMedical, isLoading: loadingMedical } =
|
||||
useGetPendingMedicalQuery();
|
||||
const { data: pendingSeaService, isLoading: loadingSeaService } =
|
||||
useGetPendingSeaServiceQuery();
|
||||
const [verifyMedical, { isLoading: rulingMedical }] =
|
||||
useVerifyMedicalCertificateMutation();
|
||||
const [verifySeaService, { isLoading: rulingSeaService }] =
|
||||
useVerifySeaServiceRecordMutation();
|
||||
const [rejectMedical, setRejectMedical] = useState<MedicalCertificate | null>(
|
||||
null,
|
||||
);
|
||||
const [rejectSeaService, setRejectSeaService] =
|
||||
useState<SeaServiceRecord | null>(null);
|
||||
|
||||
const rule = async (
|
||||
run: () => Promise<unknown>,
|
||||
done: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await run();
|
||||
notify.success(done);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not record the ruling'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
Record verification
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Submitted sea-service records and medical certificates awaiting a
|
||||
ruling. Verified records are frozen; rejections return to the seafarer
|
||||
with your remark.
|
||||
</Text>
|
||||
|
||||
<Tabs defaultValue="medical" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
||||
Medical ({pendingMedical?.length ?? 0})
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
||||
Sea Service ({pendingSeaService?.length ?? 0})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="medical" pt="md">
|
||||
<Card withBorder padding={0}>
|
||||
{loadingMedical ? (
|
||||
<Center h={160}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (pendingMedical ?? []).length === 0 ? (
|
||||
<Center h={120}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing awaiting verification.
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Seafarer</Table.Th>
|
||||
<Table.Th>Issuer</Table.Th>
|
||||
<Table.Th>Validity</Table.Th>
|
||||
<Table.Th>Fitness</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(pendingMedical ?? []).map((certificate) => (
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{ownerName(certificate.profile)}
|
||||
</Text>
|
||||
{certificate.profile?.seafarerNumber && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{certificate.profile.seafarerNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{certificate.issuerName}
|
||||
{certificate.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {certificate.certificateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{certificate.issueDate} → {certificate.expiryDate}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{certificate.fitnessStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={rulingMedical}
|
||||
onClick={() =>
|
||||
rule(
|
||||
() =>
|
||||
verifyMedical({
|
||||
id: certificate.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
'Certificate verified',
|
||||
)
|
||||
}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => setRejectMedical(certificate)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="md">
|
||||
<Card withBorder padding={0}>
|
||||
{loadingSeaService ? (
|
||||
<Center h={160}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (pendingSeaService ?? []).length === 0 ? (
|
||||
<Center h={120}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing awaiting verification.
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Seafarer</Table.Th>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Rank</Table.Th>
|
||||
<Table.Th>Period</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(pendingSeaService ?? []).map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{ownerName(record.profile)}
|
||||
</Text>
|
||||
{record.profile?.seafarerNumber && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{record.profile.seafarerNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>
|
||||
{record.engagementDate} → {record.dischargeDate}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={rulingSeaService}
|
||||
onClick={() =>
|
||||
rule(
|
||||
() =>
|
||||
verifySeaService({
|
||||
id: record.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
'Sea-service record verified',
|
||||
)
|
||||
}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => setRejectSeaService(record)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RejectModal
|
||||
title="Reject medical certificate"
|
||||
opened={Boolean(rejectMedical)}
|
||||
onClose={() => setRejectMedical(null)}
|
||||
loading={rulingMedical}
|
||||
onConfirm={(remark) => {
|
||||
if (!rejectMedical) return;
|
||||
rule(
|
||||
() =>
|
||||
verifyMedical({
|
||||
id: rejectMedical.id,
|
||||
outcome: 'REJECTED',
|
||||
remark,
|
||||
}).unwrap(),
|
||||
'Certificate rejected',
|
||||
);
|
||||
setRejectMedical(null);
|
||||
}}
|
||||
/>
|
||||
<RejectModal
|
||||
title="Reject sea-service record"
|
||||
opened={Boolean(rejectSeaService)}
|
||||
onClose={() => setRejectSeaService(null)}
|
||||
loading={rulingSeaService}
|
||||
onConfirm={(remark) => {
|
||||
if (!rejectSeaService) return;
|
||||
rule(
|
||||
() =>
|
||||
verifySeaService({
|
||||
id: rejectSeaService.id,
|
||||
outcome: 'REJECTED',
|
||||
remark,
|
||||
}).unwrap(),
|
||||
'Sea-service record rejected',
|
||||
);
|
||||
setRejectSeaService(null);
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MedicalVerificationPage;
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Badge, Button, Group, Text } from '@mantine/core';
|
||||
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type {
|
||||
MedicalCertificate,
|
||||
SeaServiceRecord,
|
||||
SeafarerProfileSummary,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
if (!profile) return '—';
|
||||
return (
|
||||
[profile.firstName, profile.middleName, profile.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ') || '—'
|
||||
);
|
||||
}
|
||||
|
||||
function seafarerCell(profile?: SeafarerProfileSummary) {
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" fw={500}>
|
||||
{ownerName(profile)}
|
||||
</Text>
|
||||
{profile?.seafarerNumber && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{profile.seafarerNumber}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Verify/Reject are text buttons by design — kept as a rendered column. */
|
||||
function ruleButtons(
|
||||
ruling: boolean,
|
||||
onVerify: () => void,
|
||||
onReject: () => void,
|
||||
) {
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={ruling}
|
||||
onClick={onVerify}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={onReject}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function medicalColumns(handlers: {
|
||||
ruling: boolean;
|
||||
onVerify: (certificate: MedicalCertificate) => void;
|
||||
onReject: (certificate: MedicalCertificate) => void;
|
||||
}): AdvancedTableColumn<MedicalCertificate>[] {
|
||||
return [
|
||||
{
|
||||
key: 'seafarer',
|
||||
header: 'Seafarer',
|
||||
render: (certificate) => seafarerCell(certificate.profile),
|
||||
},
|
||||
{
|
||||
key: 'issuer',
|
||||
header: 'Issuer',
|
||||
render: (certificate) => (
|
||||
<>
|
||||
{certificate.issuerName}
|
||||
{certificate.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {certificate.certificateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'validity',
|
||||
header: 'Validity',
|
||||
render: (certificate) =>
|
||||
`${certificate.issueDate} → ${certificate.expiryDate}`,
|
||||
},
|
||||
{
|
||||
key: 'fitness',
|
||||
header: 'Fitness',
|
||||
render: (certificate) => (
|
||||
<Badge size="sm" variant="light">
|
||||
{certificate.fitnessStatus}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (certificate) =>
|
||||
ruleButtons(
|
||||
handlers.ruling,
|
||||
() => handlers.onVerify(certificate),
|
||||
() => handlers.onReject(certificate),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function seaServiceColumns(handlers: {
|
||||
ruling: boolean;
|
||||
onVerify: (record: SeaServiceRecord) => void;
|
||||
onReject: (record: SeaServiceRecord) => void;
|
||||
}): AdvancedTableColumn<SeaServiceRecord>[] {
|
||||
return [
|
||||
{
|
||||
key: 'seafarer',
|
||||
header: 'Seafarer',
|
||||
render: (record) => seafarerCell(record.profile),
|
||||
},
|
||||
{
|
||||
key: 'vessel',
|
||||
header: 'Vessel',
|
||||
render: (record) => (
|
||||
<>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'rank', header: 'Rank' },
|
||||
{
|
||||
key: 'period',
|
||||
header: 'Period',
|
||||
render: (record) => `${record.engagementDate} → ${record.dischargeDate}`,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (record) =>
|
||||
ruleButtons(
|
||||
handlers.ruling,
|
||||
() => handlers.onVerify(record),
|
||||
() => handlers.onReject(record),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAnchor, IconStethoscope } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useGetPendingMedicalQuery,
|
||||
useGetPendingSeaServiceQuery,
|
||||
useVerifyMedicalCertificateMutation,
|
||||
useVerifySeaServiceRecordMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||
import { medicalColumns, seaServiceColumns } from './columns';
|
||||
|
||||
/** Reject dialog — the remark is what the seafarer sees and must act on. */
|
||||
function RejectModal({
|
||||
title,
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading,
|
||||
}: {
|
||||
title: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (remark: string) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [remark, setRemark] = useState('');
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} centered>
|
||||
<Stack>
|
||||
<Textarea
|
||||
label="What must the seafarer fix?"
|
||||
required
|
||||
minRows={2}
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={remark.trim().length < 3}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onConfirm(remark.trim());
|
||||
setRemark('');
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The record-verification workspace (US-SSM-003/007): everything seafarers
|
||||
* have submitted and no officer has ruled on yet, oldest first. VERIFIED
|
||||
* freezes a record — sea service starts counting toward sea time, a medical
|
||||
* certificate starts satisfying the submission gate.
|
||||
*/
|
||||
export function MedicalVerificationPage() {
|
||||
const {
|
||||
data: pendingMedical,
|
||||
isLoading: loadingMedical,
|
||||
refetch: refetchMedical,
|
||||
} = useGetPendingMedicalQuery();
|
||||
const {
|
||||
data: pendingSeaService,
|
||||
isLoading: loadingSeaService,
|
||||
refetch: refetchSeaService,
|
||||
} = useGetPendingSeaServiceQuery();
|
||||
const [verifyMedical, { isLoading: rulingMedical }] =
|
||||
useVerifyMedicalCertificateMutation();
|
||||
const [verifySeaService, { isLoading: rulingSeaService }] =
|
||||
useVerifySeaServiceRecordMutation();
|
||||
const [rejectMedical, setRejectMedical] = useState<MedicalCertificate | null>(
|
||||
null,
|
||||
);
|
||||
const [rejectSeaService, setRejectSeaService] =
|
||||
useState<SeaServiceRecord | null>(null);
|
||||
|
||||
const rule = async (
|
||||
run: () => Promise<unknown>,
|
||||
done: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await run();
|
||||
notify.success(done);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not record the ruling'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
Record verification
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Submitted sea-service records and medical certificates awaiting a
|
||||
ruling. Verified records are frozen; rejections return to the seafarer
|
||||
with your remark.
|
||||
</Text>
|
||||
|
||||
<Tabs defaultValue="medical" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
||||
Medical ({pendingMedical?.length ?? 0})
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
||||
Sea Service ({pendingSeaService?.length ?? 0})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="medical" pt="md">
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={medicalColumns({
|
||||
ruling: rulingMedical,
|
||||
onVerify: (certificate) =>
|
||||
rule(
|
||||
() =>
|
||||
verifyMedical({
|
||||
id: certificate.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
'Certificate verified',
|
||||
),
|
||||
onReject: setRejectMedical,
|
||||
})}
|
||||
data={pendingMedical ?? []}
|
||||
rowKey={(certificate) => certificate.id}
|
||||
loading={loadingMedical}
|
||||
onRefresh={refetchMedical}
|
||||
emptyTitle="Nothing awaiting verification."
|
||||
/>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="md">
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={seaServiceColumns({
|
||||
ruling: rulingSeaService,
|
||||
onVerify: (record) =>
|
||||
rule(
|
||||
() =>
|
||||
verifySeaService({
|
||||
id: record.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
'Sea-service record verified',
|
||||
),
|
||||
onReject: setRejectSeaService,
|
||||
})}
|
||||
data={pendingSeaService ?? []}
|
||||
rowKey={(record) => record.id}
|
||||
loading={loadingSeaService}
|
||||
onRefresh={refetchSeaService}
|
||||
emptyTitle="Nothing awaiting verification."
|
||||
/>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RejectModal
|
||||
title="Reject medical certificate"
|
||||
opened={Boolean(rejectMedical)}
|
||||
onClose={() => setRejectMedical(null)}
|
||||
loading={rulingMedical}
|
||||
onConfirm={(remark) => {
|
||||
if (!rejectMedical) return;
|
||||
rule(
|
||||
() =>
|
||||
verifyMedical({
|
||||
id: rejectMedical.id,
|
||||
outcome: 'REJECTED',
|
||||
remark,
|
||||
}).unwrap(),
|
||||
'Certificate rejected',
|
||||
);
|
||||
setRejectMedical(null);
|
||||
}}
|
||||
/>
|
||||
<RejectModal
|
||||
title="Reject sea-service record"
|
||||
opened={Boolean(rejectSeaService)}
|
||||
onClose={() => setRejectSeaService(null)}
|
||||
loading={rulingSeaService}
|
||||
onConfirm={(remark) => {
|
||||
if (!rejectSeaService) return;
|
||||
rule(
|
||||
() =>
|
||||
verifySeaService({
|
||||
id: rejectSeaService.id,
|
||||
outcome: 'REJECTED',
|
||||
remark,
|
||||
}).unwrap(),
|
||||
'Sea-service record rejected',
|
||||
);
|
||||
setRejectSeaService(null);
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MedicalVerificationPage;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import { localized } from '@ema-platform/api';
|
||||
import type { LicenseType } from '@ema-platform/api';
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
/** Edit stays a text button by design — kept as a rendered column. */
|
||||
export function paymentConfigColumns(handlers: {
|
||||
onEdit: (type: LicenseType) => void;
|
||||
}): AdvancedTableColumn<LicenseType>[] {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Licence type',
|
||||
render: (type) => (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(type.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{type.key}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'feeNewApplication',
|
||||
header: 'New application',
|
||||
render: (type) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'feeRenewal',
|
||||
header: 'Renewal',
|
||||
render: (type) =>
|
||||
type.feeNewApplication === null ? (
|
||||
// No charge at all, so "same as new" would be noise.
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : type.feeRenewal === null ? (
|
||||
<Tooltip label="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">
|
||||
(same as new)
|
||||
</Text>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeRenewal, type.feeCurrency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'charged',
|
||||
header: 'Charged?',
|
||||
render: (type) =>
|
||||
type.issuesCertificate ? (
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
On approval
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label="This licence type ends with an EMA decision and never reaches a payment stage">
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
Not charged
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
header: '',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (type) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => handlers.onEdit(type)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
@@ -22,11 +21,10 @@ import {
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCreditCard,
|
||||
IconEdit,
|
||||
IconInfoCircle,
|
||||
IconLock,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
@@ -35,6 +33,7 @@ import {
|
||||
useUpdateLicenseFeesMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { LicenseType } from '@ema-platform/api';
|
||||
import { paymentConfigColumns } from './columns';
|
||||
|
||||
/**
|
||||
* Licence fee configuration.
|
||||
@@ -47,15 +46,8 @@ 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 { data, isLoading, error } = useGetLicenseTypesQuery();
|
||||
const { data, isLoading, error, refetch } = useGetLicenseTypesQuery();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const [editing, setEditing] = useState<LicenseType | null>(null);
|
||||
|
||||
@@ -112,82 +104,14 @@ export function PaymentConfigPage() {
|
||||
</Alert>
|
||||
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
<Table.ScrollContainer minWidth={820}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Licence type</Table.Th>
|
||||
<Table.Th>New application</Table.Th>
|
||||
<Table.Th>Renewal</Table.Th>
|
||||
<Table.Th>Charged?</Table.Th>
|
||||
<Table.Th w={90} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{types.map((type) => (
|
||||
<Table.Tr key={type.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(type.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{type.key}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{type.feeNewApplication === null ? (
|
||||
// No charge at all, so "same as new" would be noise.
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : type.feeRenewal === null ? (
|
||||
<Tooltip label="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">
|
||||
(same as new)
|
||||
</Text>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeRenewal, type.feeCurrency)}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{type.issuesCertificate ? (
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
On approval
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label="This licence type ends with an EMA decision and never reaches a payment stage">
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
Not charged
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => setEditing(type)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<AdvancedTable
|
||||
columns={paymentConfigColumns({ onEdit: setEditing })}
|
||||
data={types}
|
||||
rowKey={(type) => type.id}
|
||||
onRefresh={refetch}
|
||||
minWidth={820}
|
||||
verticalSpacing="sm"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<GatewayPanel bypassEnabled={capabilities?.bypassEnabled} />
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ActionIcon, Badge, Button, Group, Text } from '@mantine/core';
|
||||
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } 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',
|
||||
};
|
||||
|
||||
/** QC actions are text buttons by design — kept as a rendered column. */
|
||||
export function questionColumns(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
locale: 'en' | 'am';
|
||||
getCertName: (id: string) => string;
|
||||
isSubmittingReview: boolean;
|
||||
onSubmitForApproval: (question: Question) => void;
|
||||
onReview: (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => void;
|
||||
onEdit: (question: Question) => void;
|
||||
onDelete: (question: Question) => void;
|
||||
},
|
||||
): AdvancedTableColumn<Question>[] {
|
||||
return [
|
||||
{
|
||||
key: 'title',
|
||||
header: t('question.columns.title'),
|
||||
render: (q) => <Text fz="sm" maw={300} lineClamp={2}>{q.title[handlers.locale]}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'certification',
|
||||
header: t('question.columns.certification'),
|
||||
render: (q) => <Text fz="sm">{handlers.getCertName(q.certificationId)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'form',
|
||||
header: t('question.columns.form'),
|
||||
render: (q) => <Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'points',
|
||||
header: t('question.columns.points'),
|
||||
render: (q) => <Text fz="sm" fw={600}>{q.points}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('question.qc.column'),
|
||||
render: (q) => (
|
||||
<Badge size="sm" variant="light" color={QC_COLOR[q.status] ?? 'gray'} title={q.reviewRemark ?? undefined}>
|
||||
{t(`question.qc.${q.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (q) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -20,17 +18,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 { notify } from '@ema-platform/ui';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } 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,
|
||||
@@ -38,17 +29,9 @@ 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';
|
||||
|
||||
function QuestionForm({
|
||||
editing,
|
||||
@@ -123,7 +106,7 @@ export function QuestionPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError } = useGetQuestionsQuery();
|
||||
const { data, isLoading, isError, refetch } = useGetQuestionsQuery();
|
||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||
const [deleteQ] = useDeleteQuestionMutation();
|
||||
@@ -245,92 +228,21 @@ export function QuestionPage() {
|
||||
<Text fw={600}>{t('question.pool')}</Text>
|
||||
<Select placeholder={t('question.filterByCertification')} data={[{ value: '', label: 'All' }, ...certOptions]} value={certFilter} onChange={(v) => setCertFilter(v ?? null)} size="sm" style={{ width: 280 }} clearable />
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('question.columns.title')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.certification')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.form')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.points')}</Table.Th>
|
||||
<Table.Th>{t('question.qc.column')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((q) => (
|
||||
<Table.Tr key={q.id}>
|
||||
<Table.Td><Text fz="sm" maw={300} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{getCertName(q.certificationId)}</Text></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={QC_COLOR[q.status] ?? 'gray'} title={q.reviewRemark ?? undefined}>
|
||||
{t(`question.qc.${q.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<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>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('question.noQuestions')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<AdvancedTable
|
||||
columns={questionColumns(t, {
|
||||
locale,
|
||||
getCertName,
|
||||
isSubmittingReview,
|
||||
onSubmitForApproval: handleSubmitForApproval,
|
||||
onReview: openReview,
|
||||
onEdit: (q) => { setEditing(q); setShowForm(true); },
|
||||
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
|
||||
})}
|
||||
data={filtered}
|
||||
rowKey={(q) => q.id}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('question.noQuestions')}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NumberInput, Text, TextInput } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } 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;
|
||||
},
|
||||
): AdvancedTableColumn<QuestionBrief>[] {
|
||||
return [
|
||||
{
|
||||
key: 'question',
|
||||
header: t('result.recordModal.question'),
|
||||
render: (q) => <Text fz="sm" maw={250} lineClamp={2}>{q.title[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'maxPoints',
|
||||
header: t('result.recordModal.maxPoints'),
|
||||
render: (q) => <Text fz="sm" fw={600}>{q.points}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
header: t('result.recordModal.score'),
|
||||
render: (q) => (
|
||||
<NumberInput
|
||||
value={handlers.scores[q.id] ?? 0}
|
||||
onChange={(v) => handlers.onScoreChange(q.id, Number(v))}
|
||||
min={0}
|
||||
max={q.points}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'remark',
|
||||
header: t('result.recordModal.remark'),
|
||||
render: (q) => (
|
||||
<TextInput
|
||||
placeholder={t('result.recordModal.remarkOptional')}
|
||||
value={handlers.questionRemarks[q.id] ?? ''}
|
||||
onChange={(e) => handlers.onRemarkChange(q.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, notify } 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 (
|
||||
@@ -166,43 +165,16 @@ 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
|
||||
columns={recordResultColumns(t, locale, {
|
||||
scores,
|
||||
questionRemarks,
|
||||
onScoreChange: handleScoreChange,
|
||||
onRemarkChange: handleQuestionRemarkChange,
|
||||
})}
|
||||
data={questions}
|
||||
rowKey={(q) => q.id}
|
||||
/>
|
||||
|
||||
<Paper withBorder p="sm" radius="md" bg="gray.0">
|
||||
<SimpleGrid cols={3} spacing="sm">
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconGavel } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { ExamAppeal } from '../../types/result';
|
||||
|
||||
export function examAppealsColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
handlers: { onDecide: (appeal: ExamAppeal) => void },
|
||||
): AdvancedTableColumn<ExamAppeal>[] {
|
||||
return [
|
||||
{
|
||||
key: 'number',
|
||||
header: t('result.appeals.number'),
|
||||
render: (appeal) => (
|
||||
<Text fz="sm" ff="monospace" fw={600}>
|
||||
{appeal.appealNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'candidate',
|
||||
header: t('result.appeals.candidate'),
|
||||
render: (appeal) => (
|
||||
<Text fz="sm">
|
||||
{appeal.profile
|
||||
? `${appeal.profile.firstName} ${appeal.profile.lastName}`
|
||||
: appeal.profileId.slice(0, 8)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'exam',
|
||||
header: t('result.appeals.exam'),
|
||||
render: (appeal) => (
|
||||
<>
|
||||
<Text fz="sm">
|
||||
{appeal.result?.exam?.title?.[locale] ?? '—'}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{appeal.result?.status} · {appeal.result?.totalScore}
|
||||
</Badge>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'reason',
|
||||
header: t('result.appeals.reason'),
|
||||
render: (appeal) => (
|
||||
<Text fz="xs" maw={300} lineClamp={3}>
|
||||
{appeal.reason}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lodged',
|
||||
header: t('result.appeals.lodged'),
|
||||
render: (appeal) => <Text fz="xs">{appeal.createdAt?.slice(0, 10)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (appeal) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => handlers.onDecide(appeal)}
|
||||
>
|
||||
{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,19 +10,19 @@ 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 } from '@ema-platform/ui';
|
||||
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).
|
||||
@@ -35,7 +34,7 @@ import type { ExamAppeal } from '../types/result';
|
||||
export function ExamAppealsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: appeals, isLoading, isError } = useGetPendingAppealsQuery();
|
||||
const { data: appeals, isLoading, isError, refetch } = useGetPendingAppealsQuery();
|
||||
const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation();
|
||||
|
||||
const [target, setTarget] = useState<ExamAppeal | null>(null);
|
||||
@@ -83,73 +82,19 @@ 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">{appeal.createdAt?.slice(0, 10)}</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
|
||||
columns={examAppealsColumns(t, locale, {
|
||||
onDecide: (appeal) => {
|
||||
setTarget(appeal);
|
||||
setOutcome('UPHELD');
|
||||
setRemark('');
|
||||
},
|
||||
})}
|
||||
data={appeals ?? []}
|
||||
rowKey={(appeal) => appeal.id}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('result.appeals.none')}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Badge, Box, Button, Group, Text, TextInput } from '@mantine/core';
|
||||
import { IconEye, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { Result, ResultBreakdown, ResultReviewStatus } from '../../types/result';
|
||||
import type { QuestionBrief } from '../../../exam/types/exam';
|
||||
|
||||
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 type QcAction = 'moderate' | 'approve' | 'return';
|
||||
|
||||
export function resultColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
handlers: {
|
||||
getExamTitle: (id: string) => string;
|
||||
onQc: (result: Result, action: QcAction) => void;
|
||||
onViewDetail: (result: Result) => void;
|
||||
onDelete: (result: Result) => void;
|
||||
},
|
||||
): AdvancedTableColumn<Result>[] {
|
||||
return [
|
||||
{
|
||||
key: 'seafarer',
|
||||
header: t('result.columns.seafarer'),
|
||||
render: (r) => (
|
||||
<Text fz="sm" fw={500}>
|
||||
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'exam',
|
||||
header: t('result.columns.exam'),
|
||||
render: (r) => (
|
||||
<Text fz="sm">{r.exam ? r.exam.title[locale] : handlers.getExamTitle(r.examId)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'totalScore',
|
||||
header: t('result.columns.totalScore'),
|
||||
render: (r) => <Text fz="sm" fw={600}>{r.totalScore}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('result.columns.status'),
|
||||
render: (r) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${r.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
header: t('result.review.column'),
|
||||
render: (r) => (
|
||||
<Badge size="sm" variant="light" color={REVIEW_COLOR[r.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${r.reviewStatus}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: t('result.columns.date'),
|
||||
render: (r) => <Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (r) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function resultBreakdownColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
handlers: {
|
||||
breakdowns: ResultBreakdown[];
|
||||
questions?: QuestionBrief[];
|
||||
onChange: (breakdowns: ResultBreakdown[]) => void;
|
||||
},
|
||||
): AdvancedTableColumn<ResultBreakdown>[] {
|
||||
const { breakdowns, questions, onChange } = handlers;
|
||||
const indexOf = (b: ResultBreakdown) =>
|
||||
breakdowns.findIndex((x) => x.questionId === b.questionId);
|
||||
return [
|
||||
{
|
||||
key: 'index',
|
||||
header: '#',
|
||||
render: (b) => <Text fz="xs">{indexOf(b) + 1}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'question',
|
||||
header: t('result.detail.question'),
|
||||
render: (b) => {
|
||||
const q = questions?.find((eq) => eq.id === b.questionId);
|
||||
return (
|
||||
<Text fz="xs" lineClamp={2} maw={200}>
|
||||
{q?.title?.[locale] ?? b.questionId.slice(0, 8)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'max',
|
||||
header: t('result.detail.max'),
|
||||
render: (b) => {
|
||||
const q = questions?.find((eq) => eq.id === b.questionId);
|
||||
return <Text fz="sm" fw={600}>{q?.points ?? '—'}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
header: t('result.detail.score'),
|
||||
render: (b) => (
|
||||
<TextInput
|
||||
size="xs"
|
||||
type="number"
|
||||
style={{ width: 80 }}
|
||||
value={b.score}
|
||||
onChange={(e) => {
|
||||
const i = indexOf(b);
|
||||
const updated = [...breakdowns];
|
||||
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
|
||||
onChange(updated);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'remark',
|
||||
header: t('result.detail.remarkShort'),
|
||||
render: (b) => (
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Optional"
|
||||
value={b.remark ?? ''}
|
||||
onChange={(e) => {
|
||||
const i = indexOf(b);
|
||||
const updated = [...breakdowns];
|
||||
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
|
||||
onChange(updated);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Table,
|
||||
Badge,
|
||||
Modal,
|
||||
Text,
|
||||
@@ -17,14 +16,11 @@ import {
|
||||
Divider,
|
||||
Button,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconEye,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
IconCertificate,
|
||||
IconDeviceFloppy,
|
||||
@@ -36,7 +32,7 @@ import {
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, BilingualInput } from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
@@ -48,26 +44,18 @@ 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 {
|
||||
STATUS_COLOR,
|
||||
REVIEW_COLOR,
|
||||
resultColumns,
|
||||
resultBreakdownColumns,
|
||||
type QcAction,
|
||||
} from './columns';
|
||||
import type { Result, ResultBreakdown } from '../../types/result';
|
||||
import type { Exam } from '../../../exam/types/exam';
|
||||
|
||||
function ResultStat({
|
||||
label,
|
||||
@@ -117,7 +105,7 @@ export function ResultPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: examRes } = useGetExamsQuery();
|
||||
const { data, isLoading, isError } = useGetResultsQuery();
|
||||
const { data, isLoading, isError, refetch } = useGetResultsQuery();
|
||||
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
|
||||
|
||||
const exams = examRes?.items ?? [];
|
||||
@@ -348,112 +336,18 @@ export function ResultPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('result.columns.seafarer')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.exam')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.status')}</Table.Th>
|
||||
<Table.Th>{t('result.review.column')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.date')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>
|
||||
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{r.exam ? r.exam.title[locale] : getExamTitle(r.examId)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{r.totalScore}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${r.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={REVIEW_COLOR[r.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${r.reviewStatus}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<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>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<AdvancedTable
|
||||
columns={resultColumns(t, locale, {
|
||||
getExamTitle,
|
||||
onQc: openQc,
|
||||
onViewDetail: viewDetail,
|
||||
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
|
||||
})}
|
||||
data={filtered}
|
||||
rowKey={(r) => r.id}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('result.noItems')}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
@@ -537,58 +431,15 @@ export function ResultPage() {
|
||||
{detailBreakdowns.length > 0 && (
|
||||
<>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{t('result.detail.scoreBreakdown')}</Text>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>#</Table.Th>
|
||||
<Table.Th>{t('result.detail.question')}</Table.Th>
|
||||
<Table.Th>{t('result.detail.max')}</Table.Th>
|
||||
<Table.Th>{t('result.detail.score')}</Table.Th>
|
||||
<Table.Th>{t('result.detail.remarkShort')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{detailBreakdowns.map((b, i) => {
|
||||
const examDetail = exams.find((e) => e.id === detailResult.examId);
|
||||
const q = examDetail?.questions?.find((eq) => eq.id === b.questionId);
|
||||
return (
|
||||
<Table.Tr key={b.questionId}>
|
||||
<Table.Td><Text fz="xs">{i + 1}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" lineClamp={2} maw={200}>
|
||||
{q?.title?.[locale] ?? b.questionId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{q?.points ?? '—'}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<TextInput
|
||||
size="xs"
|
||||
type="number"
|
||||
style={{ width: 80 }}
|
||||
value={b.score}
|
||||
onChange={(e) => {
|
||||
const updated = [...detailBreakdowns];
|
||||
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
|
||||
setDetailBreakdowns(updated);
|
||||
}}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Optional"
|
||||
value={b.remark ?? ''}
|
||||
onChange={(e) => {
|
||||
const updated = [...detailBreakdowns];
|
||||
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
|
||||
setDetailBreakdowns(updated);
|
||||
}}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<AdvancedTable
|
||||
columns={resultBreakdownColumns(t, locale, {
|
||||
breakdowns: detailBreakdowns,
|
||||
questions: exams.find((e) => e.id === detailResult.examId)?.questions,
|
||||
onChange: setDetailBreakdowns,
|
||||
})}
|
||||
data={detailBreakdowns}
|
||||
rowKey={(b) => b.questionId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconSearch,
|
||||
IconShieldCog,
|
||||
IconStethoscope,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useApiQuery,
|
||||
useGetMedicalForProfileQuery,
|
||||
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',
|
||||
};
|
||||
|
||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: 'blue',
|
||||
VERIFIED: 'green',
|
||||
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,
|
||||
onClose,
|
||||
}: {
|
||||
profile: ProfileRow | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const profileId = profile?.id ?? '';
|
||||
const { data: seaService, isLoading: loadingSea } =
|
||||
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
|
||||
const { data: medical, isLoading: loadingMedical } =
|
||||
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={Boolean(profile)}
|
||||
onClose={onClose}
|
||||
position="right"
|
||||
size="lg"
|
||||
title={
|
||||
profile
|
||||
? [profile.firstName, profile.middleName, profile.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{profile && (
|
||||
<Stack>
|
||||
<Group gap="xl">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Seafarer number
|
||||
</Text>
|
||||
<Text fw={700} ff="monospace">
|
||||
{profile.seafarerNumber ?? '—'}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Department
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{profile.seafarerDepartment
|
||||
? DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
||||
profile.seafarerDepartment
|
||||
: '—'}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Status
|
||||
</Text>
|
||||
<Badge
|
||||
color={
|
||||
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
|
||||
}
|
||||
>
|
||||
{profile.seafarerStatus ?? 'NOT REGISTERED'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
{profile.seafarerStatusReason && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Status reason: {profile.seafarerStatusReason}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
|
||||
Sea Service
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
|
||||
Medical
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="sm">
|
||||
{loadingSea ? (
|
||||
<Loader size="sm" />
|
||||
) : (seaService ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No sea-service records.
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Rank</Table.Th>
|
||||
<Table.Th>Period</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(seaService ?? []).map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>
|
||||
{record.engagementDate} → {record.dischargeDate}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{record.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="medical" pt="sm">
|
||||
{loadingMedical ? (
|
||||
<Loader size="sm" />
|
||||
) : (medical ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No medical certificates.
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Issuer</Table.Th>
|
||||
<Table.Th>Validity</Table.Th>
|
||||
<Table.Th>Fitness</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(medical ?? []).map((certificate) => (
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>{certificate.issuerName}</Table.Td>
|
||||
<Table.Td>
|
||||
{certificate.issueDate} → {certificate.expiryDate}
|
||||
</Table.Td>
|
||||
<Table.Td>{certificate.fitnessStatus}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={RECORD_STATUS_COLORS[certificate.status]}
|
||||
>
|
||||
{certificate.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/** US-SEA-013: suspend / reinstate / close, always with a reason. */
|
||||
function StatusModal({
|
||||
profile,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
profile: ProfileRow | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
|
||||
|
||||
const submit = async () => {
|
||||
if (!profile || !status) return;
|
||||
try {
|
||||
await updateStatus({
|
||||
profileId: profile.id,
|
||||
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
|
||||
reason,
|
||||
}).unwrap();
|
||||
notify.success('Seafarer status updated');
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not update the status'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(profile)}
|
||||
onClose={onClose}
|
||||
title="Change seafarer status"
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{profile?.seafarerNumber} — currently {profile?.seafarerStatus}. The
|
||||
reason is recorded and visible to the seafarer.
|
||||
</Text>
|
||||
<Select
|
||||
label="New status"
|
||||
required
|
||||
data={[
|
||||
{ value: 'SUSPENDED', label: 'Suspend' },
|
||||
{ value: 'INACTIVE', label: 'Close' },
|
||||
{ value: 'ACTIVE', label: 'Reinstate' },
|
||||
].filter((o) => o.value !== profile?.seafarerStatus)}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
required
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={status === 'ACTIVE' ? 'green' : 'orange'}
|
||||
disabled={!status || reason.trim().length < 3}
|
||||
loading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered seafarer profiles, read from the real profiles endpoint.
|
||||
*
|
||||
* Registration review itself happens in the licence queue (the
|
||||
* SEAFARER_REGISTRATION application type); this page is the resulting
|
||||
* register — numbers, departments, statuses, and each seafarer's records.
|
||||
*/
|
||||
export function SeafarerRegistryPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [detail, setDetail] = useState<ProfileRow | null>(null);
|
||||
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
|
||||
const { data, isLoading, refetch } = useApiQuery<{
|
||||
total: number;
|
||||
items: ProfileRow[];
|
||||
}>({
|
||||
url: '/profiles',
|
||||
method: 'GET',
|
||||
params: { q: 'i=profession,address&t=200' },
|
||||
});
|
||||
|
||||
const items = (data?.items ?? []).filter((p) => {
|
||||
if (!search.trim()) return true;
|
||||
const term = search.toLowerCase();
|
||||
return [
|
||||
p.firstName,
|
||||
p.middleName,
|
||||
p.lastName,
|
||||
p.address?.idNumber,
|
||||
p.seafarerNumber,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term));
|
||||
});
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer registry</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} profile{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Name, ID or seafarer number"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
{isLoading ? (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : items.length === 0 ? (
|
||||
<Center h={160}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Seafarer №</Table.Th>
|
||||
<Table.Th>Department</Table.Th>
|
||||
<Table.Th>ID number</Table.Th>
|
||||
<Table.Th>Phone</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((p) => (
|
||||
<Table.Tr
|
||||
key={p.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setDetail(p)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" ff="monospace">
|
||||
{p.seafarerNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{p.seafarerDepartment
|
||||
? DEPARTMENT_LABELS[p.seafarerDepartment] ?? p.seafarerDepartment
|
||||
: '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.idNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.primaryPhoneNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{p.seafarerNumber ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={SEAFARER_STATUS_COLORS[p.seafarerStatus ?? ''] ?? 'gray'}
|
||||
>
|
||||
{p.seafarerStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
|
||||
{p.isComplete ? 'Not registered' : 'Incomplete'}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
{p.seafarerNumber && (
|
||||
<Tooltip label="Suspend / reinstate / close">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={() => setStatusTarget(p)}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
|
||||
<StatusModal
|
||||
profile={statusTarget}
|
||||
onClose={() => setStatusTarget(null)}
|
||||
onDone={refetch}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeafarerRegistryPage;
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: 'blue',
|
||||
VERIFIED: 'green',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
export const DEPARTMENT_LABELS: Record<string, string> = {
|
||||
DECK: 'Deck',
|
||||
ENGINE: 'Engine',
|
||||
CATERING: 'Catering',
|
||||
};
|
||||
|
||||
export function seafarerRegistryColumns(handlers: {
|
||||
onStatus: (profile: ProfileRow) => void;
|
||||
}): AdvancedTableColumn<ProfileRow>[] {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (p) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seafarerNumber',
|
||||
header: 'Seafarer №',
|
||||
render: (p) => (
|
||||
<Text size="sm" ff="monospace">
|
||||
{p.seafarerNumber ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'department',
|
||||
header: 'Department',
|
||||
render: (p) => (
|
||||
<Text size="sm">
|
||||
{p.seafarerDepartment
|
||||
? DEPARTMENT_LABELS[p.seafarerDepartment] ?? p.seafarerDepartment
|
||||
: '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'idNumber',
|
||||
header: 'ID number',
|
||||
render: (p) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.idNumber ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
header: 'Phone',
|
||||
render: (p) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.primaryPhoneNumber ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (p) =>
|
||||
p.seafarerNumber ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={SEAFARER_STATUS_COLORS[p.seafarerStatus ?? ''] ?? 'gray'}
|
||||
>
|
||||
{p.seafarerStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
|
||||
{p.isComplete ? 'Not registered' : 'Incomplete'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (p) =>
|
||||
p.seafarerNumber ? (
|
||||
<Tooltip label="Suspend / reinstate / close">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onStatus(p);
|
||||
}}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const seaServiceColumns: AdvancedTableColumn<SeaServiceRecord>[] = [
|
||||
{
|
||||
key: 'vessel',
|
||||
header: 'Vessel',
|
||||
render: (record) => (
|
||||
<>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'rank', header: 'Rank' },
|
||||
{
|
||||
key: 'period',
|
||||
header: 'Period',
|
||||
render: (record) => (
|
||||
<>
|
||||
{record.engagementDate} → {record.dischargeDate}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (record) => (
|
||||
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{record.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const medicalColumns: AdvancedTableColumn<MedicalCertificate>[] = [
|
||||
{ key: 'issuerName', header: 'Issuer' },
|
||||
{
|
||||
key: 'validity',
|
||||
header: 'Validity',
|
||||
render: (certificate) => (
|
||||
<>
|
||||
{certificate.issueDate} → {certificate.expiryDate}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'fitnessStatus', header: 'Fitness' },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (certificate) => (
|
||||
<Badge size="sm" color={RECORD_STATUS_COLORS[certificate.status]}>
|
||||
{certificate.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAnchor, IconSearch, IconStethoscope } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useApiQuery,
|
||||
useGetMedicalForProfileQuery,
|
||||
useGetSeaServiceForProfileQuery,
|
||||
useUpdateSeafarerStatusMutation,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
DEPARTMENT_LABELS,
|
||||
SEAFARER_STATUS_COLORS,
|
||||
medicalColumns,
|
||||
seaServiceColumns,
|
||||
seafarerRegistryColumns,
|
||||
type ProfileRow,
|
||||
} from './columns';
|
||||
|
||||
/** The registered seafarer's records, read-only (verification is module 06). */
|
||||
function SeafarerDetailDrawer({
|
||||
profile,
|
||||
onClose,
|
||||
}: {
|
||||
profile: ProfileRow | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const profileId = profile?.id ?? '';
|
||||
const { data: seaService, isLoading: loadingSea } =
|
||||
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
|
||||
const { data: medical, isLoading: loadingMedical } =
|
||||
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={Boolean(profile)}
|
||||
onClose={onClose}
|
||||
position="right"
|
||||
size="lg"
|
||||
title={
|
||||
profile
|
||||
? [profile.firstName, profile.middleName, profile.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{profile && (
|
||||
<Stack>
|
||||
<Group gap="xl">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Seafarer number
|
||||
</Text>
|
||||
<Text fw={700} ff="monospace">
|
||||
{profile.seafarerNumber ?? '—'}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Department
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{profile.seafarerDepartment
|
||||
? DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
||||
profile.seafarerDepartment
|
||||
: '—'}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Status
|
||||
</Text>
|
||||
<Badge
|
||||
color={
|
||||
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
|
||||
}
|
||||
>
|
||||
{profile.seafarerStatus ?? 'NOT REGISTERED'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
{profile.seafarerStatusReason && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Status reason: {profile.seafarerStatusReason}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
|
||||
Sea Service
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
|
||||
Medical
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="sm">
|
||||
<AdvancedTable
|
||||
columns={seaServiceColumns}
|
||||
data={seaService ?? []}
|
||||
rowKey={(record) => record.id}
|
||||
loading={loadingSea}
|
||||
emptyTitle="No sea-service records."
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="medical" pt="sm">
|
||||
<AdvancedTable
|
||||
columns={medicalColumns}
|
||||
data={medical ?? []}
|
||||
rowKey={(certificate) => certificate.id}
|
||||
loading={loadingMedical}
|
||||
emptyTitle="No medical certificates."
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/** US-SEA-013: suspend / reinstate / close, always with a reason. */
|
||||
function StatusModal({
|
||||
profile,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
profile: ProfileRow | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
|
||||
|
||||
const submit = async () => {
|
||||
if (!profile || !status) return;
|
||||
try {
|
||||
await updateStatus({
|
||||
profileId: profile.id,
|
||||
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
|
||||
reason,
|
||||
}).unwrap();
|
||||
notify.success('Seafarer status updated');
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not update the status'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(profile)}
|
||||
onClose={onClose}
|
||||
title="Change seafarer status"
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{profile?.seafarerNumber} — currently {profile?.seafarerStatus}. The
|
||||
reason is recorded and visible to the seafarer.
|
||||
</Text>
|
||||
<Select
|
||||
label="New status"
|
||||
required
|
||||
data={[
|
||||
{ value: 'SUSPENDED', label: 'Suspend' },
|
||||
{ value: 'INACTIVE', label: 'Close' },
|
||||
{ value: 'ACTIVE', label: 'Reinstate' },
|
||||
].filter((o) => o.value !== profile?.seafarerStatus)}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
required
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={status === 'ACTIVE' ? 'green' : 'orange'}
|
||||
disabled={!status || reason.trim().length < 3}
|
||||
loading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered seafarer profiles, read from the real profiles endpoint.
|
||||
*
|
||||
* Registration review itself happens in the licence queue (the
|
||||
* SEAFARER_REGISTRATION application type); this page is the resulting
|
||||
* register — numbers, departments, statuses, and each seafarer's records.
|
||||
*/
|
||||
export function SeafarerRegistryPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [detail, setDetail] = useState<ProfileRow | null>(null);
|
||||
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
|
||||
const { data, isLoading, refetch } = useApiQuery<{
|
||||
total: number;
|
||||
items: ProfileRow[];
|
||||
}>({
|
||||
url: '/profiles',
|
||||
method: 'GET',
|
||||
params: { q: 'i=profession,address&t=200' },
|
||||
});
|
||||
|
||||
const items = (data?.items ?? []).filter((p) => {
|
||||
if (!search.trim()) return true;
|
||||
const term = search.toLowerCase();
|
||||
return [
|
||||
p.firstName,
|
||||
p.middleName,
|
||||
p.lastName,
|
||||
p.address?.idNumber,
|
||||
p.seafarerNumber,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term));
|
||||
});
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer registry</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} profile{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Name, ID or seafarer number"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={seafarerRegistryColumns({ onStatus: setStatusTarget })}
|
||||
data={items}
|
||||
rowKey={(p) => p.id}
|
||||
loading={isLoading}
|
||||
onRefresh={refetch}
|
||||
onRowClick={(p) => setDetail(p)}
|
||||
emptyTitle={
|
||||
search ? 'No profiles match that search.' : 'No seafarers registered yet.'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
|
||||
<StatusModal
|
||||
profile={statusTarget}
|
||||
onClose={() => setStatusTarget(null)}
|
||||
onDone={refetch}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeafarerRegistryPage;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } 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(handlers: {
|
||||
onStatus: (vessel: Vessel) => void;
|
||||
}): AdvancedTableColumn<Vessel>[] {
|
||||
return [
|
||||
{
|
||||
key: 'registrationNumber',
|
||||
header: 'Registration №',
|
||||
render: (vessel) => (
|
||||
<Text size="sm" ff="monospace" fw={600}>
|
||||
{vessel.registrationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Vessel',
|
||||
render: (vessel) => (
|
||||
<>
|
||||
<Text size="sm" fw={500}>
|
||||
{vessel.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{vessel.vesselType ?? '—'}
|
||||
{vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
header: 'Category',
|
||||
render: (vessel) => CATEGORY_LABELS[vessel.category] ?? vessel.category,
|
||||
},
|
||||
{
|
||||
key: 'ownerName',
|
||||
header: 'Owner',
|
||||
render: (vessel) => <Text size="sm">{vessel.ownerName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'registeredAt',
|
||||
header: 'Registered',
|
||||
render: (vessel) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{vessel.registeredAt?.slice(0, 10)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (vessel) => (
|
||||
<Badge size="sm" variant="light" color={VESSEL_STATUS_COLORS[vessel.status]}>
|
||||
{vessel.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (vessel) => (
|
||||
<Tooltip label="Suspend / deregister / reinstate">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onStatus(vessel);
|
||||
}}
|
||||
>
|
||||
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 } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useGetVesselIncidentsQuery,
|
||||
@@ -34,17 +31,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({
|
||||
@@ -233,7 +223,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);
|
||||
@@ -265,89 +255,17 @@ 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">
|
||||
{vessel.registeredAt?.slice(0, 10)}
|
||||
</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>
|
||||
)}
|
||||
<AdvancedTable
|
||||
columns={vesselRegistrationQueueColumns({ onStatus: setStatusTarget })}
|
||||
data={items}
|
||||
rowKey={(vessel) => vessel.id}
|
||||
loading={isLoading}
|
||||
onRefresh={refetch}
|
||||
onRowClick={(vessel) => setDetail(vessel)}
|
||||
emptyTitle={
|
||||
search ? 'No vessels match that search.' : 'No vessels registered yet.'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<VesselDetailDrawer vessel={detail} onClose={() => setDetail(null)} />
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconCertificate } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { IssuedLicense } from '@ema-platform/api';
|
||||
|
||||
export function certificateColumns(handlers: {
|
||||
onDownload: (license: IssuedLicense) => void;
|
||||
}): AdvancedTableColumn<IssuedLicense>[] {
|
||||
return [
|
||||
{
|
||||
key: 'certificateNumber',
|
||||
header: 'Certificate №',
|
||||
render: (license) => (
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Type',
|
||||
render: (license) => license.licenseType?.name?.en,
|
||||
},
|
||||
{
|
||||
key: 'issued',
|
||||
header: 'Issued',
|
||||
render: (license) => license.issueDate?.slice(0, 10),
|
||||
},
|
||||
{
|
||||
key: 'expires',
|
||||
header: 'Expires',
|
||||
render: (license) => license.expiryDate?.slice(0, 10),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (license) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
||||
>
|
||||
{license.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
header: '',
|
||||
render: (license) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconCertificate size={14} />}
|
||||
onClick={() => handlers.onDownload(license)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -7,14 +7,12 @@ import {
|
||||
List,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconInfoCircle,
|
||||
@@ -32,7 +30,8 @@ import {
|
||||
useGetMySeaTimeQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { certificateColumns } from './columns';
|
||||
|
||||
const CERTIFICATE_TYPE_KEYS = [
|
||||
'CERTIFICATE_OF_COMPETENCY',
|
||||
@@ -70,7 +69,7 @@ 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 registered =
|
||||
@@ -216,61 +215,15 @@ 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>{license.licenseType?.name?.en}</Table.Td>
|
||||
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
|
||||
<Table.Td>{license.expiryDate?.slice(0, 10)}</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
|
||||
columns={certificateColumns({
|
||||
onDownload: (license) => download(license.id),
|
||||
})}
|
||||
data={issued}
|
||||
rowKey={(license) => license.id}
|
||||
onRefresh={refetchLicenses}
|
||||
emptyTitle="No certificates issued yet."
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Badge, Progress, Text } from '@mantine/core';
|
||||
import type { AdvancedTableColumn } 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: AdvancedTableColumn<LicenseApplication>[] =
|
||||
[
|
||||
{
|
||||
key: 'application',
|
||||
header: 'Application',
|
||||
render: (app) => (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{app.companyName ?? '—'}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'licence',
|
||||
header: 'Licence',
|
||||
render: (app) => (
|
||||
<Text size="sm">{localized(app.licenseType?.name) || '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (app) => (
|
||||
<Badge variant="light" color={STATUS_COLORS[app.status]}>
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
header: 'Progress',
|
||||
width: 180,
|
||||
render: (app) => (
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[app.status]}
|
||||
color={STATUS_COLORS[app.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -15,10 +15,8 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
@@ -35,12 +33,10 @@ import {
|
||||
IconRefresh,
|
||||
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,
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
@@ -49,9 +45,10 @@ import {
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
||||
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
|
||||
import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue';
|
||||
import { dashboardApplicationColumns } from './columns';
|
||||
|
||||
/**
|
||||
* The applicant's home screen.
|
||||
@@ -97,7 +94,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();
|
||||
@@ -244,6 +241,7 @@ export function DashboardPage() {
|
||||
<ApplicationTable
|
||||
applications={items.slice(0, 6)}
|
||||
navigate={navigate}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
@@ -485,66 +483,28 @@ function Section({
|
||||
function ApplicationTable({
|
||||
applications,
|
||||
navigate,
|
||||
onRefresh,
|
||||
}: {
|
||||
applications: LicenseApplication[];
|
||||
navigate: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
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>
|
||||
<AdvancedTable
|
||||
columns={dashboardApplicationColumns}
|
||||
data={applications}
|
||||
rowKey={(app) => app.id}
|
||||
verticalSpacing="sm"
|
||||
onRefresh={onRefresh}
|
||||
onRowClick={(app) =>
|
||||
navigate(
|
||||
app.licenseType?.key
|
||||
? `/licensing/${app.licenseType.key}/applications/${app.id}`
|
||||
: '/licensing/applications',
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
152
apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx
Normal file
152
apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconFileText, IconGavel } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
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(handlers: {
|
||||
onDownloadSlip: (registration: MyRegistration) => void;
|
||||
}): AdvancedTableColumn<MyRegistration>[] {
|
||||
return [
|
||||
{
|
||||
key: 'admissionNumber',
|
||||
header: 'Admission №',
|
||||
render: (registration) => (
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{registration.admissionNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'examination',
|
||||
header: 'Examination',
|
||||
render: (registration) => registration.exam?.title?.en ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: 'Date',
|
||||
render: (registration) => registration.exam?.date?.slice(0, 10),
|
||||
},
|
||||
{
|
||||
key: 'venue',
|
||||
header: 'Venue',
|
||||
render: (registration) => registration.exam?.venue ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'attempt',
|
||||
header: 'Attempt',
|
||||
render: (registration) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
>
|
||||
{registration.kind === 'RETAKE'
|
||||
? `Retake · ${registration.attemptNumber}`
|
||||
: 'First sitting'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attendance',
|
||||
header: 'Attendance',
|
||||
render: (registration) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'}
|
||||
>
|
||||
{registration.attendanceStatus}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'slip',
|
||||
header: 'Slip',
|
||||
render: (registration) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconFileText size={13} />}
|
||||
onClick={() => handlers.onDownloadSlip(registration)}
|
||||
>
|
||||
Slip
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function resultColumns(handlers: {
|
||||
appeals: MyAppeal[];
|
||||
onAppeal: (result: MyResult) => void;
|
||||
}): AdvancedTableColumn<MyResult>[] {
|
||||
return [
|
||||
{
|
||||
key: 'examination',
|
||||
header: 'Examination',
|
||||
render: (result) => result.exam?.title?.en ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'published',
|
||||
header: 'Published',
|
||||
render: (result) => result.publishedAt?.slice(0, 10) ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
header: 'Score',
|
||||
render: (result) => (
|
||||
<Text fw={600} size="sm">
|
||||
{result.totalScore}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'outcome',
|
||||
header: 'Outcome',
|
||||
render: (result) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={result.status === 'PASSED' ? 'teal' : 'red'}
|
||||
>
|
||||
{result.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'appeal',
|
||||
header: 'Appeal',
|
||||
render: (result) => {
|
||||
const appeal = handlers.appeals.find((a) => a.resultId === result.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={() => handlers.onAppeal(result)}
|
||||
>
|
||||
Appeal
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -7,21 +7,21 @@ 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 } from '@ema-platform/ui';
|
||||
import {
|
||||
useApiQuery,
|
||||
useApiMutation,
|
||||
extractErrorMessage,
|
||||
openAuthedDocument,
|
||||
} from '@ema-platform/api';
|
||||
import { registrationColumns, resultColumns } from './columns';
|
||||
|
||||
interface OpenExam {
|
||||
export interface OpenExam {
|
||||
id: string;
|
||||
title: { en?: string; am?: string };
|
||||
date: string;
|
||||
@@ -30,7 +30,7 @@ interface OpenExam {
|
||||
certification?: { name?: { en?: string } };
|
||||
}
|
||||
|
||||
type AttendanceStatus =
|
||||
export type AttendanceStatus =
|
||||
| 'REGISTERED'
|
||||
| 'PRESENT'
|
||||
| 'ABSENT'
|
||||
@@ -38,7 +38,7 @@ type AttendanceStatus =
|
||||
| 'WITHDRAWN'
|
||||
| 'DISQUALIFIED';
|
||||
|
||||
interface MyRegistration {
|
||||
export interface MyRegistration {
|
||||
id: string;
|
||||
admissionNumber: string;
|
||||
createdAt: string;
|
||||
@@ -48,7 +48,7 @@ interface MyRegistration {
|
||||
exam?: OpenExam;
|
||||
}
|
||||
|
||||
interface MyResult {
|
||||
export interface MyResult {
|
||||
id: string;
|
||||
totalScore: number;
|
||||
status: 'PASSED' | 'FAILED';
|
||||
@@ -57,7 +57,7 @@ interface MyResult {
|
||||
exam?: OpenExam;
|
||||
}
|
||||
|
||||
interface MyAppeal {
|
||||
export interface MyAppeal {
|
||||
id: string;
|
||||
appealNumber: string;
|
||||
status: 'SUBMITTED' | 'UNDER_REVIEW' | 'UPHELD' | 'REJECTED';
|
||||
@@ -66,15 +66,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
|
||||
@@ -96,7 +87,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',
|
||||
});
|
||||
@@ -224,145 +219,30 @@ 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>{registration.exam?.title?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{registration.exam?.date?.slice(0, 10)}</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
|
||||
columns={registrationColumns({ onDownloadSlip: downloadSlip })}
|
||||
data={mine ?? []}
|
||||
rowKey={(registration) => registration.id}
|
||||
minWidth={720}
|
||||
onRefresh={refetch}
|
||||
emptyTitle="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>{result.exam?.title?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{result.publishedAt?.slice(0, 10) ?? '—'}</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
|
||||
columns={resultColumns({
|
||||
appeals: appeals ?? [],
|
||||
onAppeal: setAppealFor,
|
||||
})}
|
||||
data={results ?? []}
|
||||
rowKey={(result) => result.id}
|
||||
minWidth={720}
|
||||
onRefresh={refetchResults}
|
||||
emptyTitle="No results have been published yet."
|
||||
emptyDescription="Marks appear here once the authority approves and publishes them."
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
@@ -1,322 +0,0 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload } from '@tabler/icons-react';
|
||||
import { LicenseCatalogue } from '../components/LicenseCatalogue';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useBypassPaymentMutation,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* The applicant's landing page: which licences they can apply for, and the
|
||||
* state of anything already filed.
|
||||
*
|
||||
* The licence types come from the backend, so a newly configured type appears
|
||||
* here without a code change — and each one carries its own document
|
||||
* requirements into the wizard.
|
||||
*/
|
||||
export function MyApplicationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useGetMyApplicationsQuery();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const { data: licences } = useGetMyLicensesQuery();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
|
||||
async function handleBypass(applicationId: string) {
|
||||
try {
|
||||
const result = await bypassPayment(applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Payment bypassed',
|
||||
message: result.certificateIssued
|
||||
? 'The licence has been issued — see My licences above.'
|
||||
: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
||||
});
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Bypass failed',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the certificate belonging to an application.
|
||||
*
|
||||
* The applicant knows their application number, not the licence id, so the
|
||||
* licence is looked up from the list already loaded rather than making them
|
||||
* find it in a separate table.
|
||||
*/
|
||||
async function openCertificateForApplication(applicationId: string) {
|
||||
const licence = (licences?.items ?? []).find(
|
||||
(l) => l.applicationId === applicationId,
|
||||
);
|
||||
if (!licence) {
|
||||
notifications.show({
|
||||
color: 'yellow',
|
||||
title: 'Certificate not ready',
|
||||
message:
|
||||
'The licence for this application has not been issued yet. It will appear under My licences.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
await downloadCertificate(licence.id);
|
||||
}
|
||||
|
||||
async function downloadCertificate(licenseId: string) {
|
||||
try {
|
||||
const { url } = await getCertificateUrl(licenseId).unwrap();
|
||||
window.open(url, '_blank', 'noopener');
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Could not open the certificate',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Licence applications
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Your licences and applications, and the catalogue to file a new one.
|
||||
</Text>
|
||||
|
||||
{(licences?.items ?? []).length > 0 && (
|
||||
<>
|
||||
<Title order={4} mb="sm">
|
||||
My licences
|
||||
</Title>
|
||||
<Card withBorder padding={0} mb="xl">
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Certificate</Table.Th>
|
||||
<Table.Th>Licence</Table.Th>
|
||||
<Table.Th>Valid until</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(licences?.items ?? []).map((licence) => (
|
||||
<Table.Tr key={licence.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500} ff="monospace">
|
||||
{licence.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{localized(licence.licenseType?.name) || '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{new Date(licence.expiryDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={licence.status === 'ACTIVE' ? 'green' : 'gray'}
|
||||
>
|
||||
{licence.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={() => downloadCertificate(licence.id)}
|
||||
>
|
||||
Certificate
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Title order={4} mb="sm">
|
||||
My applications
|
||||
</Title>
|
||||
|
||||
{items.some((a) => a.status === 'PAYMENT_CONFIRMED' || a.status === 'PAID') && (
|
||||
<Card withBorder padding="sm" radius="md" mb="sm"
|
||||
style={{ borderLeft: '3px solid var(--mantine-color-teal-5)' }}>
|
||||
<Text size="sm">
|
||||
Your payment has been received. The certificate is being prepared
|
||||
and will appear under My licences above once it is issued.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Card withBorder padding="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<Text c="dimmed">You have not filed any applications yet.</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Pick a licence below to get started.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder padding={0}>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Number</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((app) => (
|
||||
<Table.Tr key={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 color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt
|
||||
? new Date(app.submittedAt).toLocaleDateString()
|
||||
: '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<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"
|
||||
>
|
||||
Bypass payment
|
||||
</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)}
|
||||
>
|
||||
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'
|
||||
? 'Continue'
|
||||
: app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'Fix & resubmit'
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
? `Pay ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`
|
||||
: app.status === 'CERTIFICATE_ISSUED'
|
||||
? 'Application'
|
||||
: 'View'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Last, for the same reason as on the dashboard: someone opening this
|
||||
page came to check on what they already filed, not to browse. */}
|
||||
<Title order={4} mt="xl" mb="sm">
|
||||
Apply for a licence
|
||||
</Title>
|
||||
<LicenseCatalogue />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApplicationsPage;
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Badge, Button, Group, Text } from '@mantine/core';
|
||||
import { IconDownload } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
localized,
|
||||
type IssuedLicense,
|
||||
type LicenseApplication,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
export function licenceColumns(handlers: {
|
||||
onDownloadCertificate: (licence: IssuedLicense) => void;
|
||||
}): AdvancedTableColumn<IssuedLicense>[] {
|
||||
return [
|
||||
{
|
||||
key: 'certificateNumber',
|
||||
header: 'Certificate',
|
||||
render: (licence) => (
|
||||
<Text size="sm" fw={500} ff="monospace">
|
||||
{licence.certificateNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'licenseType',
|
||||
header: 'Licence',
|
||||
render: (licence) => (
|
||||
<Text size="sm">{localized(licence.licenseType?.name) || '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'expiryDate',
|
||||
header: 'Valid until',
|
||||
render: (licence) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{new Date(licence.expiryDate).toLocaleDateString()}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (licence) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={licence.status === 'ACTIVE' ? 'green' : 'gray'}
|
||||
>
|
||||
{licence.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
render: (licence) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={() => handlers.onDownloadCertificate(licence)}
|
||||
>
|
||||
Certificate
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function applicationColumns(handlers: {
|
||||
bypassEnabled: boolean;
|
||||
bypassing: boolean;
|
||||
isPaying: boolean;
|
||||
onBypass: (app: LicenseApplication) => void;
|
||||
onCertificate: (app: LicenseApplication) => void;
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
}): AdvancedTableColumn<LicenseApplication>[] {
|
||||
return [
|
||||
{
|
||||
key: 'applicationNumber',
|
||||
header: 'Number',
|
||||
render: (app) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'companyName',
|
||||
header: 'Company',
|
||||
render: (app) => <Text size="sm">{app.companyName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (app) => (
|
||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'submittedAt',
|
||||
header: 'Submitted',
|
||||
render: (app) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt
|
||||
? new Date(app.submittedAt).toLocaleDateString()
|
||||
: '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
render: (app) => (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{handlers.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
loading={handlers.bypassing}
|
||||
onClick={() => handlers.onBypass(app)}
|
||||
title="Testing only — marks the fee paid and issues the licence"
|
||||
>
|
||||
Bypass payment
|
||||
</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={() => handlers.onCertificate(app)}
|
||||
>
|
||||
Certificate
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
loading={handlers.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={() => handlers.onOpen(app)}
|
||||
>
|
||||
{app.status === 'DRAFT'
|
||||
? 'Continue'
|
||||
: app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'Fix & resubmit'
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
? `Pay ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`
|
||||
: app.status === 'CERTIFICATE_ISSUED'
|
||||
? 'Application'
|
||||
: 'View'}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { AdvancedTable } from '@ema-platform/ui';
|
||||
import { LicenseCatalogue } from '../../components/LicenseCatalogue';
|
||||
import { useApplicationPayment } from '../../../payments/hooks/useApplicationPayment';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useBypassPaymentMutation,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { applicationColumns, licenceColumns } from './columns';
|
||||
|
||||
/**
|
||||
* The applicant's landing page: which licences they can apply for, and the
|
||||
* state of anything already filed.
|
||||
*
|
||||
* The licence types come from the backend, so a newly configured type appears
|
||||
* here without a code change — and each one carries its own document
|
||||
* requirements into the wizard.
|
||||
*/
|
||||
export function MyApplicationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, refetch } = useGetMyApplicationsQuery();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const { data: licences, refetch: refetchLicences } = useGetMyLicensesQuery();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
|
||||
async function handleBypass(applicationId: string) {
|
||||
try {
|
||||
const result = await bypassPayment(applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Payment bypassed',
|
||||
message: result.certificateIssued
|
||||
? 'The licence has been issued — see My licences above.'
|
||||
: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
||||
});
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Bypass failed',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the certificate belonging to an application.
|
||||
*
|
||||
* The applicant knows their application number, not the licence id, so the
|
||||
* licence is looked up from the list already loaded rather than making them
|
||||
* find it in a separate table.
|
||||
*/
|
||||
async function openCertificateForApplication(applicationId: string) {
|
||||
const licence = (licences?.items ?? []).find(
|
||||
(l) => l.applicationId === applicationId,
|
||||
);
|
||||
if (!licence) {
|
||||
notifications.show({
|
||||
color: 'yellow',
|
||||
title: 'Certificate not ready',
|
||||
message:
|
||||
'The licence for this application has not been issued yet. It will appear under My licences.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
await downloadCertificate(licence.id);
|
||||
}
|
||||
|
||||
async function downloadCertificate(licenseId: string) {
|
||||
try {
|
||||
const { url } = await getCertificateUrl(licenseId).unwrap();
|
||||
window.open(url, '_blank', 'noopener');
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Could not open the certificate',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Licence applications
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Your licences and applications, and the catalogue to file a new one.
|
||||
</Text>
|
||||
|
||||
{(licences?.items ?? []).length > 0 && (
|
||||
<>
|
||||
<Title order={4} mb="sm">
|
||||
My licences
|
||||
</Title>
|
||||
<Card withBorder padding={0} mb="xl">
|
||||
<AdvancedTable
|
||||
columns={licenceColumns({
|
||||
onDownloadCertificate: (licence) =>
|
||||
downloadCertificate(licence.id),
|
||||
})}
|
||||
data={licences?.items ?? []}
|
||||
rowKey={(licence) => licence.id}
|
||||
onRefresh={refetchLicences}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Title order={4} mb="sm">
|
||||
My applications
|
||||
</Title>
|
||||
|
||||
{items.some((a) => a.status === 'PAYMENT_CONFIRMED' || a.status === 'PAID') && (
|
||||
<Card withBorder padding="sm" radius="md" mb="sm"
|
||||
style={{ borderLeft: '3px solid var(--mantine-color-teal-5)' }}>
|
||||
<Text size="sm">
|
||||
Your payment has been received. The certificate is being prepared
|
||||
and will appear under My licences above once it is issued.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Card withBorder padding="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<Text c="dimmed">You have not filed any applications yet.</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Pick a licence below to get started.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={applicationColumns({
|
||||
bypassEnabled: Boolean(capabilities?.bypassEnabled),
|
||||
bypassing,
|
||||
isPaying,
|
||||
onBypass: (app) => handleBypass(app.id),
|
||||
onCertificate: (app) => openCertificateForApplication(app.id),
|
||||
onOpen: (app) =>
|
||||
// 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}`,
|
||||
),
|
||||
})}
|
||||
data={items}
|
||||
rowKey={(app) => app.id}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Last, for the same reason as on the dashboard: someone opening this
|
||||
page came to check on what they already filed, not to browse. */}
|
||||
<Title order={4} mt="xl" mb="sm">
|
||||
Apply for a licence
|
||||
</Title>
|
||||
<LicenseCatalogue />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApplicationsPage;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||
|
||||
/** Verified/rejected rows are frozen — only SUBMITTED ones can change. */
|
||||
const locked = (row: { status: string }) => row.status !== 'SUBMITTED';
|
||||
|
||||
export function seaServiceColumnActions(handlers: {
|
||||
onEvidence: (record: SeaServiceRecord) => void;
|
||||
onEdit: (record: SeaServiceRecord) => void;
|
||||
onDelete: (record: SeaServiceRecord) => void;
|
||||
}): AdvancedTableAction<SeaServiceRecord>[] {
|
||||
return [
|
||||
{
|
||||
key: 'evidence',
|
||||
label: 'Evidence',
|
||||
icon: <IconPaperclip size={16} />,
|
||||
onClick: handlers.onEvidence,
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <IconEdit size={16} />,
|
||||
disabled: locked,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
color: 'red',
|
||||
icon: <IconTrash size={16} />,
|
||||
disabled: locked,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function medicalColumnActions(handlers: {
|
||||
onEvidence: (certificate: MedicalCertificate) => void;
|
||||
onEdit: (certificate: MedicalCertificate) => void;
|
||||
onDelete: (certificate: MedicalCertificate) => void;
|
||||
}): AdvancedTableAction<MedicalCertificate>[] {
|
||||
return [
|
||||
{
|
||||
key: 'evidence',
|
||||
label: 'Scan / evidence',
|
||||
icon: <IconPaperclip size={16} />,
|
||||
onClick: handlers.onEvidence,
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <IconEdit size={16} />,
|
||||
disabled: locked,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
color: 'red',
|
||||
icon: <IconTrash size={16} />,
|
||||
disabled: locked,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Badge, Group, Text, Tooltip } from '@mantine/core';
|
||||
import type { AdvancedTableColumn } 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 const seaServiceColumns: AdvancedTableColumn<SeaServiceRecord>[] = [
|
||||
{
|
||||
key: 'vesselName',
|
||||
header: 'Vessel',
|
||||
render: (record) => (
|
||||
<>
|
||||
<Text fw={600} size="sm">
|
||||
{record.vesselName}
|
||||
</Text>
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'rank', header: 'Rank' },
|
||||
{ key: 'engagementDate', header: 'From' },
|
||||
{ key: 'dischargeDate', header: 'To' },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (record) => (
|
||||
<Tooltip
|
||||
label={record.verificationRemark ?? ''}
|
||||
disabled={!record.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{record.status}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const medicalColumns: AdvancedTableColumn<MedicalCertificate>[] = [
|
||||
{
|
||||
key: 'issuerName',
|
||||
header: 'Issuer',
|
||||
render: (certificate) => (
|
||||
<>
|
||||
<Text fw={600} size="sm">
|
||||
{certificate.issuerName}
|
||||
</Text>
|
||||
{certificate.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {certificate.certificateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'issueDate', header: 'Issued' },
|
||||
{
|
||||
key: 'expiryDate',
|
||||
header: 'Expires',
|
||||
render: (certificate) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{certificate.expiryDate}
|
||||
{certificate.expiryDate < new Date().toISOString().slice(0, 10) && (
|
||||
<Badge color="red">Expired</Badge>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fitnessStatus',
|
||||
header: 'Fitness',
|
||||
render: (certificate) =>
|
||||
FITNESS_OPTIONS.find((o) => o.value === certificate.fitnessStatus)
|
||||
?.label ?? certificate.fitnessStatus,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (certificate) => (
|
||||
<Tooltip
|
||||
label={certificate.verificationRemark ?? ''}
|
||||
disabled={!certificate.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[certificate.status]}>
|
||||
{certificate.status}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
@@ -9,29 +8,24 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
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 { notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
uploadDocument,
|
||||
@@ -47,18 +41,15 @@ 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 {
|
||||
FITNESS_OPTIONS,
|
||||
medicalColumns,
|
||||
seaServiceColumns,
|
||||
} from './columns';
|
||||
import {
|
||||
medicalColumnActions,
|
||||
seaServiceColumnActions,
|
||||
} from './actions';
|
||||
|
||||
/**
|
||||
* Evidence viewer/uploader shared by both record kinds.
|
||||
@@ -156,7 +147,7 @@ const EMPTY_SEA_SERVICE = {
|
||||
};
|
||||
|
||||
function SeaServiceTab() {
|
||||
const { data: records, isLoading } = useGetMySeaServiceRecordsQuery();
|
||||
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
|
||||
const { data: seaTime } = useGetMySeaTimeQuery();
|
||||
const [createRecord, { isLoading: creating }] =
|
||||
useCreateSeaServiceRecordMutation();
|
||||
@@ -257,95 +248,19 @@ function SeaServiceTab() {
|
||||
Add sea service
|
||||
</Button>
|
||||
</Group>
|
||||
{(records ?? []).length === 0 ? (
|
||||
<Paper withBorder p="xl" radius="md">
|
||||
<Text c="dimmed" ta="center">
|
||||
No sea-service records yet.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Rank</Table.Th>
|
||||
<Table.Th>From</Table.Th>
|
||||
<Table.Th>To</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(records ?? []).map((record) => {
|
||||
const locked = record.status !== 'SUBMITTED';
|
||||
return (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{record.vesselName}
|
||||
</Text>
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>{record.engagementDate}</Table.Td>
|
||||
<Table.Td>{record.dischargeDate}</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
label={record.verificationRemark ?? ''}
|
||||
disabled={!record.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{record.status}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<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>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<AdvancedTable
|
||||
columns={seaServiceColumns}
|
||||
data={records ?? []}
|
||||
rowKey={(record) => record.id}
|
||||
actions={seaServiceColumnActions({
|
||||
onEvidence: (record) => setEvidenceFor(record.id),
|
||||
onEdit: openEdit,
|
||||
onDelete: remove,
|
||||
})}
|
||||
minWidth={720}
|
||||
onRefresh={refetch}
|
||||
emptyTitle="No sea-service records yet."
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
@@ -455,7 +370,8 @@ const EMPTY_MEDICAL = {
|
||||
};
|
||||
|
||||
function MedicalTab() {
|
||||
const { data: certificates, isLoading } = useGetMyMedicalCertificatesQuery();
|
||||
const { data: certificates, isLoading, refetch } =
|
||||
useGetMyMedicalCertificatesQuery();
|
||||
const [createCertificate, { isLoading: creating }] =
|
||||
useCreateMedicalCertificateMutation();
|
||||
const [updateCertificate, { isLoading: updating }] =
|
||||
@@ -528,8 +444,6 @@ function MedicalTab() {
|
||||
form.expiryDate &&
|
||||
form.issueDate < form.expiryDate;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
return (
|
||||
@@ -543,111 +457,19 @@ function MedicalTab() {
|
||||
Add certificate
|
||||
</Button>
|
||||
</Group>
|
||||
{(certificates ?? []).length === 0 ? (
|
||||
<Paper withBorder p="xl" radius="md">
|
||||
<Text c="dimmed" ta="center">
|
||||
No medical certificates yet.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Issuer</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Expires</Table.Th>
|
||||
<Table.Th>Fitness</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(certificates ?? []).map((certificate) => {
|
||||
const locked = certificate.status !== 'SUBMITTED';
|
||||
const expired = certificate.expiryDate < today;
|
||||
return (
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{certificate.issuerName}
|
||||
</Text>
|
||||
{certificate.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {certificate.certificateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{certificate.issueDate}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{certificate.expiryDate}
|
||||
{expired && <Badge color="red">Expired</Badge>}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{FITNESS_OPTIONS.find(
|
||||
(o) => o.value === certificate.fitnessStatus,
|
||||
)?.label ?? certificate.fitnessStatus}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
label={certificate.verificationRemark ?? ''}
|
||||
disabled={!certificate.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[certificate.status]}>
|
||||
{certificate.status}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<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>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<AdvancedTable
|
||||
columns={medicalColumns}
|
||||
data={certificates ?? []}
|
||||
rowKey={(certificate) => certificate.id}
|
||||
actions={medicalColumnActions({
|
||||
onEvidence: (certificate) => setEvidenceFor(certificate.id),
|
||||
onEdit: openEdit,
|
||||
onDelete: remove,
|
||||
})}
|
||||
minWidth={720}
|
||||
onRefresh={refetch}
|
||||
emptyTitle="No medical certificates yet."
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Badge, Button, Group, Text, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCertificate,
|
||||
IconRefresh,
|
||||
} from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } 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;
|
||||
}): AdvancedTableColumn<Vessel>[] {
|
||||
return [
|
||||
{
|
||||
key: 'registrationNumber',
|
||||
header: 'Registration №',
|
||||
render: (vessel) => (
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{vessel.registrationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vessel',
|
||||
header: 'Vessel',
|
||||
render: (vessel) => (
|
||||
<>
|
||||
<Text size="sm" fw={500}>
|
||||
{vessel.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{vessel.vesselType ?? '—'}
|
||||
{vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
header: 'Category',
|
||||
render: (vessel) => CATEGORY_LABELS[vessel.category] ?? vessel.category,
|
||||
},
|
||||
{
|
||||
key: 'certificate',
|
||||
header: 'Certificate',
|
||||
render: (vessel) => {
|
||||
const license = handlers.licenseById.get(vessel.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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (vessel) => (
|
||||
<Badge size="sm" color={VESSEL_STATUS_COLORS[vessel.status]}>
|
||||
{vessel.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (vessel) => {
|
||||
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 } 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,7 +127,7 @@ 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();
|
||||
@@ -286,120 +271,18 @@ 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
|
||||
columns={vesselColumns({
|
||||
licenseById,
|
||||
onDownloadCertificate: downloadCertificate,
|
||||
onRenew: renew,
|
||||
onReportIncident: setIncidentFor,
|
||||
})}
|
||||
data={vessels ?? []}
|
||||
rowKey={(vessel) => vessel.id}
|
||||
onRefresh={refetch}
|
||||
minWidth={760}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconFileText } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { IssuedLicense } from '@ema-platform/api';
|
||||
|
||||
export function waiverLetterColumns(handlers: {
|
||||
onDownload: (license: IssuedLicense) => void;
|
||||
}): AdvancedTableColumn<IssuedLicense>[] {
|
||||
return [
|
||||
{
|
||||
key: 'reference',
|
||||
header: 'Reference',
|
||||
render: (license) => (
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kind',
|
||||
header: 'Kind',
|
||||
render: (license) => license.licenseType?.name?.en ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'issued',
|
||||
header: 'Issued',
|
||||
render: (license) => license.issueDate?.slice(0, 10),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (license) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
||||
>
|
||||
{license.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'letter',
|
||||
header: '',
|
||||
render: (license) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconFileText size={13} />}
|
||||
onClick={() => handlers.onDownload(license)}
|
||||
>
|
||||
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 {
|
||||
STATUS_COLORS,
|
||||
@@ -26,7 +21,8 @@ import {
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { waiverLetterColumns } from './columns';
|
||||
|
||||
const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
|
||||
|
||||
@@ -41,7 +37,7 @@ const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
|
||||
export function WaiverPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: applications, isLoading } = useGetMyApplicationsQuery();
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const { data: licenses, refetch } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
|
||||
const waiverApplications = (applications?.items ?? []).filter((app) =>
|
||||
@@ -156,59 +152,15 @@ export function WaiverPage() {
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Issued waiver letters</Title>
|
||||
{letters.length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No waiver letters issued yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Kind</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>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>{license.licenseType?.name?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{license.issueDate?.slice(0, 10)}</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="light"
|
||||
leftSection={<IconFileText size={13} />}
|
||||
onClick={() => download(license.id)}
|
||||
>
|
||||
Letter
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<AdvancedTable
|
||||
columns={waiverLetterColumns({
|
||||
onDownload: (license) => download(license.id),
|
||||
})}
|
||||
data={letters}
|
||||
rowKey={(license) => license.id}
|
||||
onRefresh={refetch}
|
||||
emptyTitle="No waiver letters issued yet."
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
@@ -13,3 +13,4 @@ export * from './lib/layout/BrandAvatar';
|
||||
export * from './lib/layout/ColorSchemeToggle';
|
||||
export * from './lib/layout/LanguageSwitcher';
|
||||
export * from './lib/layout/PageHeader';
|
||||
export * from './lib/table/AdvancedTable';
|
||||
|
||||
261
libs/ui/src/lib/table/AdvancedTable.tsx
Normal file
261
libs/ui/src/lib/table/AdvancedTable.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Checkbox,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Pagination,
|
||||
Table,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
type MantineSpacing,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconRefresh,
|
||||
IconSelector,
|
||||
} from '@tabler/icons-react';
|
||||
import { EmptyState } from '../feedback/EmptyState';
|
||||
|
||||
export interface AdvancedTableColumn<T> {
|
||||
/** Unique column id; doubles as the sort field sent to `sort.onSort`. */
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
/** Cell renderer. Defaults to reading `row[key]`. */
|
||||
render?: (row: T) => ReactNode;
|
||||
sortable?: boolean;
|
||||
width?: number | string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export interface AdvancedTableAction<T> {
|
||||
key: string;
|
||||
/** Shown as tooltip and aria-label. */
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
color?: string;
|
||||
hidden?: (row: T) => boolean;
|
||||
disabled?: (row: T) => boolean;
|
||||
onClick: (row: T) => void;
|
||||
}
|
||||
|
||||
export interface AdvancedTableSort {
|
||||
sortBy?: string;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
onSort: (field: string) => void;
|
||||
}
|
||||
|
||||
export interface AdvancedTablePagination {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export interface AdvancedTableSelection {
|
||||
selected: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export interface AdvancedTableProps<T> {
|
||||
columns: AdvancedTableColumn<T>[];
|
||||
data: T[];
|
||||
rowKey: (row: T) => string;
|
||||
actions?: AdvancedTableAction<T>[];
|
||||
sort?: AdvancedTableSort;
|
||||
pagination?: AdvancedTablePagination;
|
||||
/** Controlled row selection (checkbox column); ids come from `rowKey`. */
|
||||
selection?: AdvancedTableSelection;
|
||||
loading?: boolean;
|
||||
/** Min table width before horizontal scroll kicks in. */
|
||||
minWidth?: number;
|
||||
/** Row density, e.g. 4 (compact) or 'sm' (comfortable). */
|
||||
verticalSpacing?: MantineSpacing;
|
||||
/** Per-row style override (e.g. focused-row highlight). */
|
||||
rowStyle?: (row: T) => CSSProperties | undefined;
|
||||
/** Rendered above the table, left-aligned (filters, search, tabs…). */
|
||||
toolbar?: ReactNode;
|
||||
/** Shows a refresh button above the table, right-aligned. */
|
||||
onRefresh?: () => void;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
column,
|
||||
sort,
|
||||
}: {
|
||||
column: AdvancedTableColumn<never>;
|
||||
sort: AdvancedTableSort;
|
||||
}) {
|
||||
const active = sort.sortBy === column.key;
|
||||
const Icon = active
|
||||
? sort.sortDir === 'desc'
|
||||
? IconChevronDown
|
||||
: IconChevronUp
|
||||
: IconSelector;
|
||||
return (
|
||||
<UnstyledButton onClick={() => sort.onSort(column.key)} fz="sm" fw={700}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{column.header}
|
||||
<Icon size={14} stroke={1.5} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic data table: column definitions and row actions come from the
|
||||
* consumer as config (typically `*Columns.tsx` + `*ColumnActions.tsx` files);
|
||||
* filters, search and refresh live in the parent component above the table.
|
||||
* Sorting and pagination are controlled — the parent owns the state (URL,
|
||||
* query params) and refetches.
|
||||
*/
|
||||
export function AdvancedTable<T>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
actions,
|
||||
sort,
|
||||
pagination,
|
||||
selection,
|
||||
loading = false,
|
||||
minWidth = 640,
|
||||
verticalSpacing,
|
||||
rowStyle,
|
||||
toolbar,
|
||||
onRefresh,
|
||||
emptyTitle = 'Nothing here yet',
|
||||
emptyDescription,
|
||||
onRowClick,
|
||||
}: AdvancedTableProps<T>) {
|
||||
const allIds = data.map(rowKey);
|
||||
const allSelected = selection
|
||||
? allIds.length > 0 && allIds.every((id) => selection.selected.includes(id))
|
||||
: false;
|
||||
|
||||
const toolbarRow = (toolbar || onRefresh) && (
|
||||
<Group justify="space-between" mb="sm" wrap="nowrap" align="flex-end">
|
||||
<Box style={{ flex: 1 }}>{toolbar}</Box>
|
||||
{onRefresh && (
|
||||
<Tooltip label="Refresh">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={onRefresh} aria-label="Refresh">
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (!loading && data.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
{toolbarRow}
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
{toolbarRow}
|
||||
<LoadingOverlay visible={loading} zIndex={10} />
|
||||
<Table.ScrollContainer minWidth={minWidth}>
|
||||
<Table striped highlightOnHover verticalSpacing={verticalSpacing}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{selection && (
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={selection.selected.length > 0 && !allSelected}
|
||||
onChange={() => selection.onChange(allSelected ? [] : allIds)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</Table.Th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<Table.Th key={col.key} w={col.width} ta={col.align}>
|
||||
{col.sortable && sort ? (
|
||||
<SortableHeader column={col as AdvancedTableColumn<never>} sort={sort} />
|
||||
) : (
|
||||
col.header
|
||||
)}
|
||||
</Table.Th>
|
||||
))}
|
||||
{actions && <Table.Th w={1} />}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.map((row) => (
|
||||
<Table.Tr
|
||||
key={rowKey(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
style={{
|
||||
...(onRowClick ? { cursor: 'pointer' } : undefined),
|
||||
...rowStyle?.(row),
|
||||
}}
|
||||
>
|
||||
{selection && (
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selection.selected.includes(rowKey(row))}
|
||||
onChange={(e) => {
|
||||
const id = rowKey(row);
|
||||
selection.onChange(
|
||||
e.currentTarget.checked
|
||||
? [...selection.selected, id]
|
||||
: selection.selected.filter((s) => s !== id),
|
||||
);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</Table.Td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<Table.Td key={col.key} ta={col.align}>
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: ((row as Record<string, unknown>)[col.key] as ReactNode)}
|
||||
</Table.Td>
|
||||
))}
|
||||
{actions && (
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
{actions
|
||||
.filter((a) => !a.hidden?.(row))
|
||||
.map((a) => (
|
||||
<Tooltip key={a.key} label={a.label}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={a.color}
|
||||
disabled={a.disabled?.(row)}
|
||||
aria-label={a.label}
|
||||
onClick={() => a.onClick(row)}
|
||||
>
|
||||
{a.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Pagination
|
||||
value={pagination.page}
|
||||
total={pagination.totalPages}
|
||||
onChange={pagination.onPageChange}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -3885,18 +3885,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/x-date-pickers/node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/x-date-pickers/node_modules/react-is": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||
@@ -11968,7 +11956,7 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
@@ -13772,7 +13760,7 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -17986,7 +17974,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
|
||||
Reference in New Issue
Block a user