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:
Nati
2026-08-13 09:11:50 +00:00
parent a7e74cb7bd
commit 2af9e60800
67 changed files with 4209 additions and 3446 deletions

View File

@@ -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,
},
];
}

View File

@@ -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>
),
},
];
}

View File

@@ -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">

View File

@@ -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,
},
];
}

View File

@@ -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),
},
];
}

View File

@@ -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">

View File

@@ -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>
),
},
];

View File

@@ -1,24 +1,18 @@
import { useNavigate } from 'react-router-dom';
import {
Badge,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Table,
Text,
Title,
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import {
STATUS_COLORS,
STATUS_LABELS,
useGetAssignedToMeQuery,
useGetQueueQuery,
type LicenseStatus,
} from '@ema-platform/api';
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
import { AdvancedTable } 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>
);

View File

@@ -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>
),
},
];
}

View File

@@ -2,35 +2,25 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Button,
Group,
Modal,
Paper,
Select,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconInfoCircle, IconUserCheck } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { IconInfoCircle } from '@tabler/icons-react';
import { AdvancedTable, notify } 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

View File

@@ -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>
),
},
];
}

View File

@@ -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

View File

@@ -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,
},
];
}

View File

@@ -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>
),
},
];
}

View File

@@ -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 */}

View File

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

View File

@@ -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,
},
];
}

View File

@@ -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(),
},
];

View File

@@ -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"
/>
);
}

View File

@@ -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,
},
];
}

View File

@@ -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)} />

View File

@@ -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>
),
},
];
}

View File

@@ -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;

View File

@@ -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);
}}
/>
)}
</>
),
},
];
}

View File

@@ -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>

View File

@@ -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,
},
];
}

View File

@@ -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],
},
];
}

View File

@@ -6,23 +6,20 @@ import {
Group,
Stack,
NumberInput,
Table,
ActionIcon,
Badge,
Text,
Tooltip,
Divider,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconEdit, IconTrash, IconPlus } from '@tabler/icons-react';
import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
useGetLocationTypesQuery,
useCreateLocationTypeMutation,
useUpdateLocationTypeMutation,
useDeleteLocationTypeMutation,
} from '../api/location-api';
import { notify } 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>
);
}

View File

@@ -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>
),
},
];

View File

@@ -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>

View File

@@ -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;

View File

@@ -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),
),
},
];
}

View File

@@ -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;

View File

@@ -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>
),
},
];
}

View File

@@ -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} />

View File

@@ -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>
),
},
];
}

View File

@@ -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

View File

@@ -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 }}
/>
),
},
];
}

View File

@@ -5,10 +5,8 @@ import {
Stack,
Select,
Divider,
Table,
Text,
Badge,
NumberInput,
Paper,
SimpleGrid,
Group,
@@ -17,11 +15,12 @@ import {
Alert,
} from '@mantine/core';
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { AdvancedTable, 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">

View File

@@ -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>
),
},
];
}

View File

@@ -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

View File

@@ -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);
}}
/>
),
},
];
}

View File

@@ -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}
/>
</>
)}

View File

@@ -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;

View File

@@ -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>
),
},
];

View File

@@ -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;

View File

@@ -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>
),
},
];
}

View File

@@ -5,7 +5,6 @@ import {
Badge,
Button,
Card,
Center,
Container,
Drawer,
Group,
@@ -18,15 +17,13 @@ import {
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
IconInfoCircle,
IconSearch,
IconShieldCog,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { AdvancedTable, notify } 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)} />

View File

@@ -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>
),
},
];
}

View File

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

View File

@@ -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"
/>
),
},
];

View File

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

View 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>
);
},
},
];
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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>
),
},
];
}

View File

@@ -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;

View File

@@ -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,
},
];
}

View File

@@ -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>
),
},
];

View File

@@ -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}

View File

@@ -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>
);
},
},
];
}

View File

@@ -11,24 +11,19 @@ import {
Paper,
Progress,
Stack,
Table,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
IconAnchor,
IconArrowRight,
IconCertificate,
IconInfoCircle,
IconPlus,
IconRefresh,
IconShip,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { AdvancedTable, notify } 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>

View File

@@ -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>
),
},
];
}

View File

@@ -7,15 +7,10 @@ import {
Loader,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconFileText,
IconInfoCircle,
} from '@tabler/icons-react';
import { IconArrowRight, IconInfoCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import {
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>
);