Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
4
.gitignore
vendored
@@ -30,3 +30,7 @@ apps/backoffice/public/_um/
|
||||
apps/backoffice/public/tinymce/
|
||||
local-packages/iamui-extracted/
|
||||
|
||||
|
||||
# Playwright visual-regression artifacts (baselines under apps/e2e/visual are tracked)
|
||||
test-results/
|
||||
dist/visual-report/
|
||||
|
||||
@@ -167,7 +167,7 @@ export function DocumentRequirementEditorDrawer({
|
||||
<>
|
||||
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
|
||||
<ConditionBuilder
|
||||
value={(draft.conditionExpression ?? null) as ConditionValue | null}
|
||||
value={(draft.conditionExpression ?? { field: '', equals: '' }) as ConditionValue}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))}
|
||||
targets={conditionTargets}
|
||||
palette={palette}
|
||||
|
||||
@@ -44,6 +44,7 @@ export function CertificateRequirementsPage() {
|
||||
'certReq.subtitle',
|
||||
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
|
||||
)}
|
||||
noMargin
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
|
||||
@@ -16,6 +16,15 @@ export function certificationColumns(
|
||||
header: t('certification.columns.description'),
|
||||
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.rank', 'Rank'),
|
||||
cell: ({ row }) =>
|
||||
row.original.rankKey ? (
|
||||
<Badge size="sm" variant="outline" color="violet">{row.original.rankKey}</Badge>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed">—</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Card,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
||||
import {IconPlus} from '@tabler/icons-react';
|
||||
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
useUpdateCertificationMutation,
|
||||
useDeleteCertificationMutation,
|
||||
} from '../../api/certification-api';
|
||||
import type { Certification } from '../../types/certification';
|
||||
import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
|
||||
import { certificationColumns } from './columns';
|
||||
import { certificationActionsColumn } from './actions';
|
||||
|
||||
@@ -33,7 +22,7 @@ function CertificationForm({
|
||||
}: {
|
||||
editing: Certification | null;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => void;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -41,6 +30,7 @@ function CertificationForm({
|
||||
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
|
||||
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
||||
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
||||
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -48,7 +38,7 @@ function CertificationForm({
|
||||
notify.error('Name fields are required');
|
||||
return;
|
||||
}
|
||||
onSubmit({ nameEn, nameAm, descEn, descAm }, !!editing);
|
||||
onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -59,6 +49,17 @@ function CertificationForm({
|
||||
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
|
||||
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Select
|
||||
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
|
||||
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
|
||||
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
|
||||
data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]}
|
||||
value={rankKey}
|
||||
onChange={setRankKey}
|
||||
size="sm"
|
||||
clearable
|
||||
searchable
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||
@@ -91,15 +92,17 @@ export function CertificationPage() {
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => {
|
||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateCert({ id: editing.id, name, description }).unwrap();
|
||||
// null clears a previously-set rank; undefined would leave it
|
||||
// untouched server-side, so the two are not interchangeable here.
|
||||
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap();
|
||||
notify.success(t('certification.updated'));
|
||||
} else {
|
||||
await createCert({ name, description }).unwrap();
|
||||
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
|
||||
notify.success(t('certification.created'));
|
||||
}
|
||||
resetForm();
|
||||
@@ -120,7 +123,8 @@ export function CertificationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
||||
if (isError)
|
||||
return <ErrorState title={t('certification.loadError')} onRetry={refetch} />;
|
||||
|
||||
const columns = [
|
||||
...certificationColumns(t, locale),
|
||||
@@ -134,17 +138,18 @@ export function CertificationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t('certification.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<PageHeader
|
||||
title={t('certification.title')}
|
||||
subtitle={t('certification.subtitle')}
|
||||
noMargin
|
||||
action={
|
||||
!showForm && (
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('certification.add')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<CertificationForm
|
||||
|
||||
@@ -3,11 +3,30 @@ export interface LocalePair {
|
||||
am: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* STCW rank an exam certification is for — the join that lets the
|
||||
* schedule-exam picker offer only sittings valid for an application's rank.
|
||||
* Matches `LicenseApplication.formData.certificate.rank` / `rankEngine` /
|
||||
* `proficiency` on the backend. Not every certification is on the examined
|
||||
* ladder, so this stays a plain optional string rather than a required enum.
|
||||
*/
|
||||
export const RANK_KEY_OPTIONS = [
|
||||
{ value: 'OOW_DECK', label: 'Officer of the Watch (Deck)' },
|
||||
{ value: 'CHIEF_MATE', label: 'Chief Mate' },
|
||||
{ value: 'MASTER', label: 'Master' },
|
||||
{ value: 'OOW_ENGINE', label: 'Officer of the Watch (Engine)' },
|
||||
{ value: 'SECOND_ENGINEER', label: 'Second Engineer' },
|
||||
{ value: 'CHIEF_ENGINEER', label: 'Chief Engineer' },
|
||||
{ value: 'ABLE_SEAFARER_DECK', label: 'Able Seafarer Deck' },
|
||||
{ value: 'ABLE_SEAFARER_ENGINE', label: 'Able Seafarer Engine' },
|
||||
] as const;
|
||||
|
||||
export interface Certification {
|
||||
id: string;
|
||||
name: LocalePair;
|
||||
description: LocalePair;
|
||||
isActive: boolean;
|
||||
rankKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -20,6 +39,7 @@ export interface ListResponse<T> {
|
||||
export interface CreateCertificationPayload {
|
||||
name: LocalePair;
|
||||
description: LocalePair;
|
||||
rankKey?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCertificationPayload {
|
||||
@@ -27,4 +47,6 @@ export interface UpdateCertificationPayload {
|
||||
name?: LocalePair;
|
||||
description?: LocalePair;
|
||||
isActive?: boolean;
|
||||
/** Omit to leave unchanged, null to clear a previously-set rank. */
|
||||
rankKey?: string | null;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import type { Profession } from "../../types/configuration";
|
||||
import { professionColumns } from "./columns";
|
||||
import { professionActionsColumn } from "./actions";
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
interface ProfFormValues {
|
||||
nameEn: string;
|
||||
@@ -378,7 +379,7 @@ export function ConfigurationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>{t("configuration.title")}</Title>
|
||||
<PageHeader title={t("configuration.title")} noMargin />
|
||||
|
||||
<Tabs defaultValue="professions">
|
||||
<Tabs.List>
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
|
||||
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
IconAlertTriangle,
|
||||
IconCreditCard,
|
||||
IconFileText,
|
||||
IconInbox,
|
||||
IconUserCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
useGetAssignedToMeQuery,
|
||||
useGetQueueQuery,
|
||||
useListSeafarerDocumentsQuery,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
type LicenseApplication,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
AdvancedTable,
|
||||
PageHeader,
|
||||
PageLoader,
|
||||
StatTile,
|
||||
WaitingFor,
|
||||
useServerTable,
|
||||
} from '@ema-platform/ui';
|
||||
import { dashboardQueueColumns } from './columns';
|
||||
|
||||
/**
|
||||
* Backoffice home.
|
||||
*
|
||||
* Shows the licence pipeline, which is the part of the platform that has real
|
||||
* data behind it. The previous version charted invented registration volumes
|
||||
* and a fictional breakdown of staff roles.
|
||||
* Every figure here is counted from a queue the officer can open, and each
|
||||
* tile navigates to the list it counted — a dashboard that cannot be drilled
|
||||
* into is a poster. Nothing is charted: the platform exposes queues, not time
|
||||
* series, and an earlier version of this page invented both a registration
|
||||
* trend and a staff-role breakdown rather than admit that.
|
||||
*
|
||||
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
|
||||
* queues are permission-gated and an officer without them simply gets no
|
||||
* count — never a broken page — so the tiles read `—` rather than `0`, which
|
||||
* would be a lie.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -27,6 +44,13 @@ export function DashboardPage() {
|
||||
const mine = useGetAssignedToMeQuery();
|
||||
const table = useServerTable();
|
||||
|
||||
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
|
||||
const seamanBooks = useListSeafarerDocumentsQuery({
|
||||
kind: 'SEAMAN_BOOK',
|
||||
status: 'PAYMENT_PENDING',
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
||||
}
|
||||
@@ -34,60 +58,88 @@ export function DashboardPage() {
|
||||
const unclaimed = queue.data?.items ?? [];
|
||||
const inProgress = mine.data?.items ?? [];
|
||||
const all = [...unclaimed, ...inProgress];
|
||||
const paged = table.paginate(unclaimed.slice(0, 8));
|
||||
|
||||
const stats = [
|
||||
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
|
||||
{ label: 'Assigned to me', value: inProgress.length, color: 'indigo' },
|
||||
{
|
||||
label: 'Needs applicant action',
|
||||
value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
label: 'Awaiting payment',
|
||||
value: all.filter((a) => a.status === 'PAYMENT_PENDING').length,
|
||||
color: 'yellow',
|
||||
},
|
||||
];
|
||||
const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
|
||||
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
|
||||
|
||||
/** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
|
||||
const byAge = [...unclaimed].sort((a, b) =>
|
||||
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
|
||||
);
|
||||
const paged = table.paginate(byAge.slice(0, 8));
|
||||
|
||||
/** `undefined` while loading or forbidden — rendered as "—", never as 0. */
|
||||
const countOf = (q: { data?: { total: number }; isError: boolean }) =>
|
||||
q.isError ? undefined : q.data?.total;
|
||||
|
||||
const show = (n: number | undefined) => (n === undefined ? '—' : n);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Dashboard
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="lg">
|
||||
Licence applications currently in the system.
|
||||
</Text>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Work waiting across the Authority's review queues."
|
||||
noMargin
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||
{stats.map((stat) => (
|
||||
<Card withBorder key={stat.label} padding="md" radius="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
|
||||
{stat.value}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<StatTile
|
||||
label="Awaiting claim"
|
||||
value={unclaimed.length}
|
||||
hint="Licence applications nobody has picked up"
|
||||
icon={IconInbox}
|
||||
tone="info"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Assigned to me"
|
||||
value={inProgress.length}
|
||||
hint="Your open licence reviews"
|
||||
icon={IconFileText}
|
||||
tone="neutral"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Needs applicant action"
|
||||
value={needsApplicant}
|
||||
hint="Returned for corrections"
|
||||
icon={IconAlertTriangle}
|
||||
tone="pending"
|
||||
/>
|
||||
<StatTile
|
||||
label="Awaiting payment"
|
||||
value={awaitingPayment}
|
||||
hint="Approved, fee not yet settled"
|
||||
icon={IconCreditCard}
|
||||
tone="warning"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder padding={0} radius="md">
|
||||
<Group justify="space-between" p="md" pb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Awaiting claim
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
|
||||
</Text>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
{/* Same 4-column track as the row above, so a two-tile row lines up with
|
||||
it instead of stretching each tile to half the page. */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<StatTile
|
||||
label="Seafarer registrations"
|
||||
value={show(countOf(registrations))}
|
||||
hint="Submitted, awaiting review"
|
||||
icon={IconUserCheck}
|
||||
tone="info"
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Seaman books"
|
||||
value={show(countOf(seamanBooks))}
|
||||
hint="Released, awaiting payment"
|
||||
icon={IconCreditCard}
|
||||
tone="pending"
|
||||
onClick={() => navigate('/seaman-book-queue')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid gutter="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<AdvancedTable<LicenseApplication>
|
||||
title="Awaiting claim — oldest first"
|
||||
tableName="Awaiting claim"
|
||||
columns={dashboardQueueColumns}
|
||||
data={paged.rows}
|
||||
@@ -95,12 +147,54 @@ export function DashboardPage() {
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
onRowClick={() => navigate('/licence-review')}
|
||||
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
|
||||
refresh={queue.refetch}
|
||||
isLoading={queue.isFetching}
|
||||
emptyText="Nothing waiting to be claimed."
|
||||
toolbar={
|
||||
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
|
||||
Open queue
|
||||
</Anchor>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Container>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Paper withBorder radius="lg" p="lg" h="100%">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
Longest waiting
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Unclaimed applications, by how long they have sat.
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{byAge.slice(0, 5).map((app) => (
|
||||
<Group key={app.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Anchor
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||
>
|
||||
{app.applicationNumber}
|
||||
</Anchor>
|
||||
<WaitingFor
|
||||
since={app.submittedAt ?? app.createdAt}
|
||||
slaDays={
|
||||
app.licenseType?.slaHours ? app.licenseType.slaHours / 24 : undefined
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
{byAge.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing waiting.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ExamIncident,
|
||||
CreateIncidentPayload,
|
||||
ResolveIncidentPayload,
|
||||
RegradeOutcome,
|
||||
} from '../types/exam';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
@@ -20,7 +21,9 @@ const examApi = baseApi.injectEndpoints({
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
getExam: builder.query<Exam, string>({
|
||||
query: (id) => `/exams/${id}?i=questions`,
|
||||
// Nested relation so CHOICE questions carry their options here too —
|
||||
// needed to print real answer choices instead of blank A/B/C/D lines.
|
||||
query: (id) => `/exams/${id}?i=questions,questions.options`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createExam: builder.mutation<Exam, CreateExamPayload>({
|
||||
@@ -90,6 +93,14 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Staff-triggered re-run of auto-grading for one finalized attempt. */
|
||||
regradeAttempt: builder.mutation<RegradeOutcome, string>({
|
||||
query: (attemptId) => ({
|
||||
url: `/exam-attempts/${attemptId}/regrade`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -107,4 +118,5 @@ export const {
|
||||
useGetExamIncidentsQuery,
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
useRegradeAttemptMutation,
|
||||
} = examApi;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconUserCheck } from '@tabler/icons-react';
|
||||
import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
|
||||
import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) =>
|
||||
|
||||
export function examCandidateColumns(
|
||||
t: TFunction,
|
||||
handlers: { onRecord: (registration: ExamRegistration) => void },
|
||||
handlers: {
|
||||
onRecord: (registration: ExamRegistration) => void;
|
||||
onRegrade: (registration: ExamRegistration) => void;
|
||||
regrading?: string | null;
|
||||
},
|
||||
): AdvancedColumn<ExamRegistration>[] {
|
||||
return [
|
||||
{
|
||||
@@ -78,21 +82,51 @@ export function examCandidateColumns(
|
||||
header: '',
|
||||
label: t('exam.candidates.record'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
cell: ({ row }) => {
|
||||
const attemptStatus = row.original.attempt?.status;
|
||||
const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED';
|
||||
return (
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
loading={handlers.regrading === row.original.attempt?.id}
|
||||
>
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
|
||||
hideOnly
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconUserCheck size={12} />}
|
||||
<Menu.Item
|
||||
leftSection={<IconUserCheck size={14} />}
|
||||
onClick={() => handlers.onRecord(row.original)}
|
||||
>
|
||||
{t('exam.candidates.record')}
|
||||
</Button>
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
),
|
||||
{canRegrade && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]}
|
||||
hideOnly
|
||||
>
|
||||
<Menu.Item
|
||||
color="grape"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => handlers.onRegrade(row.original)}
|
||||
>
|
||||
{t('exam.candidates.regrade')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordAttendanceMutation,
|
||||
useRegradeAttemptMutation,
|
||||
} from '../../api/exam-api';
|
||||
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
|
||||
import { candidateName, examCandidateColumns } from './columns';
|
||||
@@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
|
||||
const [regradeAttempt] = useRegradeAttemptMutation();
|
||||
const [regrading, setRegrading] = useState<string | null>(null);
|
||||
const [target, setTarget] = useState<ExamRegistration | null>(null);
|
||||
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
|
||||
const [remark, setRemark] = useState('');
|
||||
const table = useServerTable();
|
||||
|
||||
const regrade = async (registration: ExamRegistration) => {
|
||||
const attemptId = registration.attempt?.id;
|
||||
if (!attemptId) return;
|
||||
setRegrading(attemptId);
|
||||
try {
|
||||
const outcome = await regradeAttempt(attemptId).unwrap();
|
||||
if (outcome.graded) {
|
||||
notify.success(t('exam.candidates.regraded'));
|
||||
} else {
|
||||
notify.error(t('exam.candidates.regradeNotEligible', { reason: outcome.reason }));
|
||||
}
|
||||
refetch();
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.candidates.regradeError')));
|
||||
} finally {
|
||||
setRegrading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = (registration: ExamRegistration) => {
|
||||
setTarget(registration);
|
||||
setStatus(
|
||||
@@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
) : (
|
||||
<AdvancedTable
|
||||
tableName={t('exam.candidates.section')}
|
||||
columns={examCandidateColumns(t, { onRecord: startRecording })}
|
||||
columns={examCandidateColumns(t, {
|
||||
onRecord: startRecording,
|
||||
onRegrade: regrade,
|
||||
regrading,
|
||||
})}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
OPEN: 'red',
|
||||
UNDER_REVIEW: 'yellow',
|
||||
RESOLVED: 'teal',
|
||||
DISMISSED: 'gray',
|
||||
const STATUS_TONE: Record<ExamIncidentStatus, StatusTone> = {
|
||||
OPEN: 'danger',
|
||||
UNDER_REVIEW: 'warning',
|
||||
RESOLVED: 'success',
|
||||
DISMISSED: 'neutral',
|
||||
};
|
||||
|
||||
export function examIncidentColumns(
|
||||
@@ -56,13 +58,12 @@ export function examIncidentColumns(
|
||||
{
|
||||
header: t('exam.incidents.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status] ?? 'neutral'}
|
||||
label={t(`exam.incidentStatus.${row.original.status}`)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[row.original.status] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.incidentStatus.${row.original.status}`)}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import {
|
||||
@@ -57,13 +58,13 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
||||
import { PageLoader } from '@ema-platform/ui';
|
||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
ACTIVE: "blue",
|
||||
COMPLETED: "teal",
|
||||
CANCELLED: "red",
|
||||
POSTPONED: "orange",
|
||||
PUBLISHED: "green",
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
PENDING: 'neutral',
|
||||
ACTIVE: 'info',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
POSTPONED: 'pending',
|
||||
PUBLISHED: 'success',
|
||||
};
|
||||
|
||||
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
|
||||
@@ -171,6 +172,11 @@ export function ExamDetailPage() {
|
||||
notify.error(
|
||||
key.startsWith('insufficient_approved_questions')
|
||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
max: key.split(':')[1]?.split('/')[0] ?? '',
|
||||
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
|
||||
})
|
||||
: key,
|
||||
);
|
||||
}
|
||||
@@ -187,18 +193,35 @@ export function ExamDetailPage() {
|
||||
notify.error(
|
||||
key.startsWith('question_not_approved')
|
||||
? t('question.qc.onlyApprovedUsable')
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
max: key.split(':')[1]?.split('/')[0] ?? '',
|
||||
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
|
||||
})
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
const handlePrint = async () => {
|
||||
const total = (exam.questions ?? []).reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
if (total < Number(exam.cuttingPoint)) {
|
||||
// The reachable max depends on the evaluation method, not the raw point
|
||||
// sum — mirrors RecordResultModal's grading math so "can this paper pass"
|
||||
// means the same thing here as it does at marking time. Cutting point can
|
||||
// be raised after the paper was assembled (edit modal, no re-check on
|
||||
// save), so this still needs to run even though assignment now enforces
|
||||
// it too.
|
||||
const questions = exam.questions ?? [];
|
||||
const total = questions.reduce((s, q) => s + Number(q.points), 0);
|
||||
const reachableMax =
|
||||
exam.evaluationMethod === 'AVERAGE'
|
||||
? questions.length
|
||||
? total / questions.length
|
||||
: 0
|
||||
: exam.evaluationMethod === 'PERCENTAGE'
|
||||
? 100
|
||||
: total;
|
||||
if (reachableMax < Number(exam.cuttingPoint)) {
|
||||
notify.error(
|
||||
`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
|
||||
`This paper cannot reach the passing mark under its ${EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} evaluation (max ${reachableMax}, pass mark ${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -232,7 +255,23 @@ export function ExamDetailPage() {
|
||||
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
|
||||
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ""}
|
||||
${q.form === "ESSAY" ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ""}
|
||||
${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("") : ""}
|
||||
${
|
||||
q.form === "CHOICE"
|
||||
? q.options && q.options.length
|
||||
? q.options
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(
|
||||
(o, oi) =>
|
||||
`<p style="margin: 4px 0; font-size: 13px;">${String.fromCharCode(65 + oi)}. ${o.text[locale] || o.text.en}</p>`,
|
||||
)
|
||||
.join("")
|
||||
// No options on record (legacy question, or options relation
|
||||
// wasn't loaded) — fall back to blank lines rather than
|
||||
// printing nothing.
|
||||
: ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("")
|
||||
: ""
|
||||
}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -247,7 +286,20 @@ export function ExamDetailPage() {
|
||||
.header p { margin: 2px 0; font-size: 13px; color: #555; }
|
||||
.directions { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; }
|
||||
.directions strong { display: block; margin-bottom: 4px; }
|
||||
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
|
||||
.footer { margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center; }
|
||||
/* Pinned to the bottom of every printed page (not just after the
|
||||
last question) — @page's bottom margin leaves room for it so it
|
||||
never overlaps question text on the last page. */
|
||||
@media print {
|
||||
@page { margin: 20mm 20mm 28mm 20mm; }
|
||||
/* @page's margin already insets content from the physical page
|
||||
edge — body's own 40px padding (needed on-screen, for the
|
||||
preview tab before printing) would double up with it here,
|
||||
wasting real page height on every side and fitting noticeably
|
||||
fewer questions per page than the paper actually has room for. */
|
||||
body { -webkit-print-color-adjust: exact; padding: 0; max-width: none; }
|
||||
.footer { position: fixed; bottom: 0; left: 0; right: 0; margin-top: 0; }
|
||||
}
|
||||
</style></head><body>
|
||||
<div class="header">
|
||||
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
|
||||
@@ -258,7 +310,7 @@ export function ExamDetailPage() {
|
||||
</div>
|
||||
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""}
|
||||
${qHtml}
|
||||
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
|
||||
<div class="footer">
|
||||
Generated by EMA — Ethiopian Maritime Authority
|
||||
</div>
|
||||
</body></html>
|
||||
@@ -290,7 +342,7 @@ export function ExamDetailPage() {
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Title order={3}>{exam.title[locale]}</Title>
|
||||
<Title order={2}>{exam.title[locale]}</Title>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
@@ -313,14 +365,13 @@ export function ExamDetailPage() {
|
||||
</Group>
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[exam.status]}
|
||||
label={t(`exam.status.${exam.status}`)}
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[exam.status]}
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
{t(`exam.status.${exam.status}`)}
|
||||
</Badge>
|
||||
/>
|
||||
|
||||
{/* Exam Info */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ActionIcon, Group } from "@mantine/core";
|
||||
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconDetails,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconToggleRight,
|
||||
} from "@tabler/icons-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
@@ -11,40 +17,55 @@ export function examActionsColumn(
|
||||
onEdit: (exam: Exam) => void;
|
||||
onDelete: (exam: Exam) => void;
|
||||
onDetails: (exam: Exam) => void;
|
||||
onOpenStatusChange: (exam: Exam) => void;
|
||||
changingStatusId?: string | null;
|
||||
},
|
||||
): AdvancedColumn<Exam> {
|
||||
return {
|
||||
header: t("exam.columns.actions"),
|
||||
header: t("exam.columns.actions", "Actions"),
|
||||
align: "right",
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
loading={handlers.changingStatusId === row.original.id}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</RequirePermission>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconDetails size={14} />}
|
||||
onClick={() => handlers.onDetails(row.original)}
|
||||
>
|
||||
<IconDetails size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{t("exam.action.details", "Details")}
|
||||
</Menu.Item>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
>
|
||||
{t("exam.action.edit", "Edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconToggleRight size={14} />}
|
||||
onClick={() => handlers.onOpenStatusChange(row.original)}
|
||||
>
|
||||
{t("exam.form.status")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
>
|
||||
{t("exam.action.delete", "Delete")}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Badge, Text } from "@mantine/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { StatusBadge } 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",
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
PENDING: 'neutral',
|
||||
ACTIVE: 'info',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
POSTPONED: 'pending',
|
||||
PUBLISHED: 'success',
|
||||
};
|
||||
|
||||
export function examColumns(
|
||||
@@ -72,9 +74,12 @@ export function examColumns(
|
||||
{
|
||||
header: t("exam.columns.status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{t(`exam.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status]}
|
||||
label={t(`exam.status.${row.original.status}`)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Modal,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
TextInput,
|
||||
Textarea,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
@@ -35,6 +33,7 @@ import {
|
||||
import type { Exam } from "../../types/exam";
|
||||
import { examColumns } from "./columns";
|
||||
import { examActionsColumn } from "./actions";
|
||||
import { ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
|
||||
function ExamForm({
|
||||
editing,
|
||||
@@ -77,21 +76,23 @@ function ExamForm({
|
||||
editing?.cuttingPoint ?? 0,
|
||||
);
|
||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("basic");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!certificationId ||
|
||||
!titleEn ||
|
||||
!titleAm ||
|
||||
!date ||
|
||||
!type ||
|
||||
!form ||
|
||||
!venue ||
|
||||
!adminMethod ||
|
||||
!evalMethod
|
||||
) {
|
||||
notify.error("Please fill all required fields");
|
||||
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.fillRequiredBasic"));
|
||||
return;
|
||||
}
|
||||
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.directionBothLanguages"));
|
||||
return;
|
||||
}
|
||||
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
|
||||
setActiveTab("settings");
|
||||
notify.error(t("exam.form.fillRequiredSettings"));
|
||||
return;
|
||||
}
|
||||
onSubmit(
|
||||
@@ -121,7 +122,7 @@ function ExamForm({
|
||||
return (
|
||||
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs value={activeTab} onChange={setActiveTab} variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
||||
{t("exam.form.basicInfo")}
|
||||
@@ -252,6 +253,12 @@ function ExamForm({
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
disabled={adminMethod === "ONLINE"}
|
||||
description={
|
||||
adminMethod === "ONLINE"
|
||||
? t("exam.form.onlineChoiceOnlyHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.administration")}
|
||||
@@ -261,7 +268,14 @@ function ExamForm({
|
||||
{ value: "ONLINE", label: t("exam.form.online") },
|
||||
]}
|
||||
value={adminMethod}
|
||||
onChange={setAdminMethod}
|
||||
onChange={(value) => {
|
||||
setAdminMethod(value);
|
||||
// Online exams are graded automatically, and that only
|
||||
// has an answer model for CHOICE — matches the backend
|
||||
// rule (online_exam_requires_choice_form), not just a
|
||||
// UI nicety.
|
||||
if (value === "ONLINE") setForm("CHOICE");
|
||||
}}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
@@ -291,12 +305,22 @@ function ExamForm({
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.cuttingPoint")}
|
||||
placeholder={t("exam.form.cuttingPointPlaceholder")}
|
||||
placeholder={
|
||||
evalMethod === "PERCENTAGE"
|
||||
? t("exam.form.cuttingPointPercentagePlaceholder")
|
||||
: t("exam.form.cuttingPointPlaceholder")
|
||||
}
|
||||
value={cuttingPoint}
|
||||
onChange={(v) => setCuttingPoint(Number(v))}
|
||||
min={0}
|
||||
max={evalMethod === "PERCENTAGE" ? 100 : undefined}
|
||||
size="sm"
|
||||
required
|
||||
withAsterisk
|
||||
description={
|
||||
evalMethod === "PERCENTAGE"
|
||||
? t("exam.form.cuttingPointPercentageHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
@@ -353,6 +377,11 @@ export function ExamPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
const [changingStatusId, setChangingStatusId] = useState<string | null>(null);
|
||||
const [statusTarget, setStatusTarget] = useState<Exam | null>(null);
|
||||
const [pendingStatus, setPendingStatus] = useState<Exam["status"] | null>(null);
|
||||
const [statusOpened, { open: openStatus, close: closeStatus }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const certOptions = certifications
|
||||
.filter((c) => c.isActive)
|
||||
@@ -403,6 +432,21 @@ export function ExamPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeStatus = async () => {
|
||||
if (!statusTarget || !pendingStatus) return;
|
||||
setChangingStatusId(statusTarget.id);
|
||||
try {
|
||||
await updateExam({ id: statusTarget.id, status: pendingStatus }).unwrap();
|
||||
notify.success(t("exam.updated"));
|
||||
closeStatus();
|
||||
setStatusTarget(null);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setChangingStatusId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
@@ -416,13 +460,7 @@ export function ExamPage() {
|
||||
};
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
color="red"
|
||||
title={t("exam.loadError")}
|
||||
/>
|
||||
);
|
||||
return <ErrorState title={t("exam.loadError")} onRetry={refetch} />;
|
||||
|
||||
const columns = [
|
||||
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
|
||||
@@ -436,6 +474,12 @@ export function ExamPage() {
|
||||
openDelete();
|
||||
},
|
||||
onDetails: (exam) => navigate(`/exams/${exam.id}`),
|
||||
onOpenStatusChange: (exam) => {
|
||||
setStatusTarget(exam);
|
||||
setPendingStatus(exam.status);
|
||||
openStatus();
|
||||
},
|
||||
changingStatusId,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -443,14 +487,12 @@ export function ExamPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t("exam.title")}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("exam.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<PageHeader
|
||||
title={t("exam.title")}
|
||||
subtitle={t("exam.subtitle")}
|
||||
noMargin
|
||||
action={
|
||||
!showForm && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -461,8 +503,9 @@ export function ExamPage() {
|
||||
{t("exam.add")}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<ExamForm
|
||||
@@ -511,6 +554,47 @@ export function ExamPage() {
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
{/* Quick status change — not the full edit form */}
|
||||
<Modal
|
||||
opened={statusOpened}
|
||||
onClose={closeStatus}
|
||||
title={t("exam.form.status")}
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{statusTarget?.title?.[locale]}
|
||||
</Text>
|
||||
<Select
|
||||
label={t("exam.form.status")}
|
||||
data={[
|
||||
{ value: "PENDING", label: t("exam.form.pending") },
|
||||
{ value: "ACTIVE", label: t("exam.form.active") },
|
||||
{ value: "COMPLETED", label: t("exam.form.completed") },
|
||||
{ value: "CANCELLED", label: t("exam.form.cancelled") },
|
||||
{ value: "POSTPONED", label: t("exam.form.postponed") },
|
||||
{ value: "PUBLISHED", label: t("exam.form.published") },
|
||||
]}
|
||||
value={pendingStatus}
|
||||
onChange={(value) => setPendingStatus(value as Exam["status"])}
|
||||
size="sm"
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeStatus} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleChangeStatus}
|
||||
size="sm"
|
||||
loading={changingStatusId === statusTarget?.id}
|
||||
disabled={pendingStatus === statusTarget?.status}
|
||||
>
|
||||
{t("exam.update")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,19 @@ export type ExamStatus =
|
||||
| "POSTPONED"
|
||||
| "PUBLISHED";
|
||||
|
||||
/** Only populated when the exam is fetched with `?i=questions,questions.options`. */
|
||||
export interface QuestionOptionBrief {
|
||||
id: string;
|
||||
text: LocalePair;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface QuestionBrief {
|
||||
id: string;
|
||||
title: LocalePair;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
options?: QuestionOptionBrief[];
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
@@ -118,8 +126,14 @@ export interface ExamRegistration {
|
||||
lastName: string | null;
|
||||
seafarerNumber: string | null;
|
||||
};
|
||||
/** The candidate's online sitting, when one has been started. */
|
||||
attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
||||
}
|
||||
|
||||
export type RegradeOutcome =
|
||||
| { graded: true; resultId: string }
|
||||
| { graded: false; reason: string };
|
||||
|
||||
export interface RecordAttendancePayload {
|
||||
registrationId: string;
|
||||
status: AttendanceStatus;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Item } from '../../api/item-api';
|
||||
|
||||
const STATUS_COLORS: Record<Item['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
ACTIVE: 'green',
|
||||
ARCHIVED: 'orange',
|
||||
const STATUS_TONES: Record<Item['status'], StatusTone> = {
|
||||
DRAFT: 'neutral',
|
||||
ACTIVE: 'success',
|
||||
ARCHIVED: 'pending',
|
||||
};
|
||||
|
||||
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
|
||||
@@ -14,7 +15,7 @@ export function itemColumns(showDate: (date: string) => string): AdvancedColumn<
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={STATUS_COLORS[row.original.status]}>{row.original.status}</Badge>
|
||||
<StatusBadge tone={STATUS_TONES[row.original.status]} label={row.original.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Stack, Title, Paper } from '@mantine/core';
|
||||
import {Stack, Paper} from '@mantine/core';
|
||||
import { ItemTable } from '../components/ItemTable';
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
export function ItemPage() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>Items</Title>
|
||||
<PageHeader title="Items" noMargin />
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<ItemTable />
|
||||
</Paper>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const LICENSE_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'green',
|
||||
EXPIRED: 'yellow',
|
||||
SUSPENDED: 'orange',
|
||||
CANCELLED: 'red',
|
||||
SUPERSEDED: 'gray',
|
||||
const LICENSE_STATUS_TONES: Record<string, StatusTone> = {
|
||||
ACTIVE: 'success',
|
||||
EXPIRED: 'warning',
|
||||
SUSPENDED: 'pending',
|
||||
CANCELLED: 'danger',
|
||||
SUPERSEDED: 'neutral',
|
||||
};
|
||||
|
||||
export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
|
||||
@@ -70,13 +72,12 @@ export function licenseRegisterColumns(
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={LICENSE_STATUS_TONES[row.original.status] ?? 'neutral'}
|
||||
label={row.original.status}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={LICENSE_STATUS_COLORS[row.original.status] ?? 'gray'}
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Textarea} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -151,13 +139,10 @@ export function LicenseRegisterPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Licence register</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Licence register"
|
||||
subtitle={`${data?.total ?? 0} issued licence${(data?.total ?? 0) === 1 ? '' : 's'}`}
|
||||
action={
|
||||
<TextInput
|
||||
placeholder="Certificate № or company"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
@@ -165,7 +150,8 @@ export function LicenseRegisterPage() {
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable<IssuedLicense>
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/c
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
import { useGetEligibleExamsQuery } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
applicationId: string;
|
||||
applicantName: string;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
@@ -22,27 +23,30 @@ interface Props {
|
||||
*
|
||||
* Sessions are picked from the exam calendar rather than typed, because the
|
||||
* candidate joins a scheduled sitting — this is an assignment, not the creation
|
||||
* of a per-candidate appointment.
|
||||
* of a per-candidate appointment. Scoped to sittings whose certification
|
||||
* matches this application's rank, so a Chief Mate candidate cannot be seated
|
||||
* into an OOW Deck sitting by accident.
|
||||
*/
|
||||
export function ScheduleExamModal({
|
||||
opened,
|
||||
applicationId,
|
||||
applicantName,
|
||||
loading,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: exams, isLoading } = useGetExamsQuery(undefined, { skip: !opened });
|
||||
const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
|
||||
const [examId, setExamId] = useState<string | null>(null);
|
||||
const [admissionNumber, setAdmissionNumber] = useState('');
|
||||
|
||||
const options = (exams?.items ?? []).map((exam) => ({
|
||||
const options = (exams ?? []).map((exam) => ({
|
||||
value: exam.id,
|
||||
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
}));
|
||||
const selected = exams?.items?.find((exam) => exam.id === examId);
|
||||
const selected = exams?.find((exam) => exam.id === examId);
|
||||
|
||||
function confirm() {
|
||||
if (!examId) return;
|
||||
@@ -72,7 +76,7 @@ export function ScheduleExamModal({
|
||||
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
|
||||
{t(
|
||||
'review.scheduleExam.noSessions',
|
||||
'No exam sessions exist yet. Create one in the Exams area first.',
|
||||
'No exam sessions for this rank exist yet. Create one in the Exams area first.',
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
@@ -73,6 +72,7 @@ import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
|
||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
|
||||
import { licenseQueueColumns } from "./columns";
|
||||
import { licenseQueueActionsColumn } from "./actions";
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
@@ -503,15 +503,10 @@ export function LicenseQueuePage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{queueTitle}</Title>
|
||||
{typeCode && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<PageHeader
|
||||
title={queueTitle}
|
||||
subtitle={typeCode ? t(`nav.type${typeCode}`, { defaultValue: typeCode }) : undefined}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
@@ -537,7 +532,8 @@ export function LicenseQueuePage() {
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Saved views, counted. */}
|
||||
<Tabs
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
useLocalized,
|
||||
useApproveDocumentsMutation,
|
||||
useAssignApplicationMutation,
|
||||
useClaimApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
@@ -198,6 +199,7 @@ export function LicenseReviewPage() {
|
||||
[requirements],
|
||||
);
|
||||
|
||||
const [claimApplication] = useClaimApplicationMutation();
|
||||
const [completeReview] = useCompleteReviewMutation();
|
||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||
const [approveDocuments] = useApproveDocumentsMutation();
|
||||
@@ -531,8 +533,12 @@ export function LicenseReviewPage() {
|
||||
try {
|
||||
switch (action.id) {
|
||||
case "claim":
|
||||
// Claim is fired from the queue in practice; kept here for the case
|
||||
// where an officer opens an unclaimed application directly.
|
||||
// Usually fired from the queue, but an officer can also open an
|
||||
// unclaimed application directly and claim it from here.
|
||||
await run(
|
||||
() => claimApplication(id).unwrap(),
|
||||
t("review.done.claim", "Application claimed"),
|
||||
);
|
||||
break;
|
||||
case "complete-review":
|
||||
await run(
|
||||
@@ -724,7 +730,7 @@ export function LicenseReviewPage() {
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{headerName}</Title>
|
||||
<Title order={2}>{headerName}</Title>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.applicationNumber}
|
||||
@@ -1156,6 +1162,7 @@ export function LicenseReviewPage() {
|
||||
|
||||
<ScheduleExamModal
|
||||
opened={scheduleExamOpen}
|
||||
applicationId={id}
|
||||
applicantName={
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Paper,
|
||||
Text,
|
||||
Grid,
|
||||
Modal,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Group, Button, Paper, Text, Grid, Modal, ActionIcon, Tooltip, Loader, Center, Alert} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui';
|
||||
import { ModalFooter, notify, PageHeader, PageLoader, useErrorHandler } from '@ema-platform/ui';
|
||||
import { LocationTree } from '../components/LocationTree';
|
||||
import { LocationDetail } from '../components/LocationDetail';
|
||||
import { LocationForm } from '../components/LocationForm';
|
||||
@@ -108,8 +94,10 @@ export function LocationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>{t('location.title')}</Title>
|
||||
<PageHeader
|
||||
title={t('location.title')}
|
||||
noMargin
|
||||
action={
|
||||
<Group gap="sm">
|
||||
{locationTypes.length > 0 && (
|
||||
<Button
|
||||
@@ -136,7 +124,8 @@ export function LocationPage() {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{locationTypes.length === 0 && (
|
||||
<Alert
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Badge, Card, Center, Container, Grid, Group, Loader, SimpleGrid, Stack, Text} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -74,12 +62,10 @@ export function LogisticsHeadDashboardPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Logistics overview
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="lg">
|
||||
Licence applications currently in the department.
|
||||
</Text>
|
||||
<PageHeader
|
||||
title="Logistics overview"
|
||||
subtitle="Licence applications currently in the department."
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||
{stats.map((stat) => (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import {
|
||||
seaServiceDays,
|
||||
type MedicalCertificate,
|
||||
@@ -18,10 +20,10 @@ export function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
|
||||
SUBMITTED: 'yellow',
|
||||
VERIFIED: 'teal',
|
||||
REJECTED: 'red',
|
||||
const STATUS_TONE: Record<SeafarerRecordStatus, StatusTone> = {
|
||||
SUBMITTED: 'warning',
|
||||
VERIFIED: 'success',
|
||||
REJECTED: 'danger',
|
||||
};
|
||||
|
||||
/** Only meaningful now the queue can show ruled records too. */
|
||||
@@ -33,9 +35,12 @@ function statusColumn<T extends { status: SeafarerRecordStatus }>(
|
||||
label: t('recordVerification.columns.status', 'Status'),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{t(`recordVerification.status.${row.original.status}`, row.original.status)}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status]}
|
||||
label={t(`recordVerification.status.${row.original.status}`, row.original.status)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Badge, Button, Center, Container, Group, Loader, Modal, Paper, SegmentedControl, Stack, Text, Textarea} from '@mantine/core';
|
||||
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
|
||||
import {
|
||||
AdvancedTable,
|
||||
notify,
|
||||
PdfPreviewModal,
|
||||
type AdvancedColumn,
|
||||
} from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, PdfPreviewModal, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -375,13 +356,14 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
{isMedical
|
||||
<PageHeader
|
||||
title={
|
||||
isMedical
|
||||
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
||||
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{isMedical
|
||||
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')
|
||||
}
|
||||
subtitle={
|
||||
isMedical
|
||||
? t(
|
||||
'recordVerification.medicalSubtitle',
|
||||
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
|
||||
@@ -389,8 +371,9 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
||||
: t(
|
||||
'recordVerification.seaServiceSubtitle',
|
||||
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{statusFilter}
|
||||
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Center, Group, Loader, Modal, NumberInput, Paper, Stack, Switch, Text, TextInput, ThemeIcon, Tooltip} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCreditCard,
|
||||
IconInfoCircle,
|
||||
IconLock,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
notify,
|
||||
ModalFooter,
|
||||
AdvancedTable,
|
||||
useServerTable,
|
||||
type AdvancedColumn,
|
||||
PageLoader,
|
||||
} from '@ema-platform/ui';
|
||||
import { AdvancedTable, ModalFooter, notify, PageHeader, PageLoader, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
@@ -92,20 +68,19 @@ export function PaymentConfigPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{t(
|
||||
<PageHeader
|
||||
title={t('paymentConfig.title', 'Payment configuration')}
|
||||
subtitle={t(
|
||||
'paymentConfig.subtitle',
|
||||
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
noMargin
|
||||
action={
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconCreditCard size={22} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
variant="light"
|
||||
|
||||
@@ -249,7 +249,7 @@ export function ProfilePage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={900}>
|
||||
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
|
||||
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} noMargin />
|
||||
|
||||
{/* Profile summary */}
|
||||
<Paper p="lg" shadow="sm" radius="lg" withBorder>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import type {
|
||||
Question,
|
||||
QuestionOption,
|
||||
ListResponse,
|
||||
CreateQuestionPayload,
|
||||
UpdateQuestionPayload,
|
||||
ReviewQuestionPayload,
|
||||
SetQuestionOptionsPayload,
|
||||
} from '../types/question';
|
||||
|
||||
const questionApi = baseApi.injectEndpoints({
|
||||
@@ -17,6 +19,11 @@ const questionApi = baseApi.injectEndpoints({
|
||||
query: (id) => `/questions/${id}`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
/** Same question, with `options` populated — the MCQ authoring editor. */
|
||||
getQuestionWithOptions: builder.query<Question, string>({
|
||||
query: (id) => `/questions/${id}?i=options`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createQuestion: builder.mutation<Question, CreateQuestionPayload>({
|
||||
query: (body) => ({ url: '/questions', method: 'POST', body }),
|
||||
invalidatesTags: ['Api'],
|
||||
@@ -47,6 +54,15 @@ const questionApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Full replace of a CHOICE question's options + correct-answer set (Phase 2). */
|
||||
setQuestionOptions: builder.mutation<QuestionOption[], SetQuestionOptionsPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/questions/${id}/options`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -54,9 +70,11 @@ const questionApi = baseApi.injectEndpoints({
|
||||
export const {
|
||||
useGetQuestionsQuery,
|
||||
useGetQuestionQuery,
|
||||
useGetQuestionWithOptionsQuery,
|
||||
useCreateQuestionMutation,
|
||||
useUpdateQuestionMutation,
|
||||
useDeleteQuestionMutation,
|
||||
useSubmitQuestionMutation,
|
||||
useReviewQuestionMutation,
|
||||
useSetQuestionOptionsMutation,
|
||||
} = questionApi;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetQuestionWithOptionsQuery,
|
||||
useSetQuestionOptionsMutation,
|
||||
} from '../api/question-api';
|
||||
|
||||
interface DraftOption {
|
||||
text: BilingualValue;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCQ options + correct-answer editor for a CHOICE-form question (Phase 2).
|
||||
*
|
||||
* Only reachable while editing an already-created question — options attach
|
||||
* to a question id, matching the backend's `PUT /questions/:id/options`
|
||||
* full-replace endpoint. Nothing here is ever shown to a candidate; this is
|
||||
* the authoring side only.
|
||||
*/
|
||||
export function QuestionOptionsEditor({ questionId }: { questionId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: question, isFetching } = useGetQuestionWithOptionsQuery(questionId);
|
||||
const [setOptions, { isLoading: isSaving }] = useSetQuestionOptionsMutation();
|
||||
const [draft, setDraft] = useState<DraftOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!question) return;
|
||||
const existing = question.options ?? [];
|
||||
setDraft(
|
||||
existing.length
|
||||
? existing
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((o) => ({ text: o.text, isCorrect: false }))
|
||||
: [
|
||||
{ text: { en: '', am: '' }, isCorrect: false },
|
||||
{ text: { en: '', am: '' }, isCorrect: false },
|
||||
],
|
||||
);
|
||||
// isCorrect never comes back from the API by design — an examiner
|
||||
// re-editing options re-marks the correct one(s) rather than us
|
||||
// pretending to know what they were.
|
||||
}, [question]);
|
||||
|
||||
const updateField = (index: number, lang: keyof BilingualValue, value: string) => {
|
||||
setDraft((prev) =>
|
||||
prev.map((o, i) => (i === index ? { ...o, text: { ...o.text, [lang]: value } } : o)),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCorrect = (index: number) => {
|
||||
setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, isCorrect: !o.isCorrect } : o)));
|
||||
};
|
||||
|
||||
const addOption = () => {
|
||||
setDraft((prev) => [...prev, { text: { en: '', am: '' }, isCorrect: false }]);
|
||||
};
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
setDraft((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (draft.length < 2) {
|
||||
notify.error(t('question.options.needAtLeastTwo'));
|
||||
return;
|
||||
}
|
||||
if (!draft.some((o) => o.isCorrect)) {
|
||||
notify.error(t('question.options.needOneCorrect'));
|
||||
return;
|
||||
}
|
||||
if (draft.some((o) => !o.text.en.trim() || !o.text.am.trim())) {
|
||||
notify.error(t('question.options.textRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setOptions({ id: questionId, options: draft }).unwrap();
|
||||
notify.success(t('question.options.saved'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching) return <Loader size="sm" />;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Alert icon={<IconInfoCircle size={15} />} color="blue" variant="light">
|
||||
{t('question.options.hint')}
|
||||
</Alert>
|
||||
{draft.map((option, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="center">
|
||||
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
|
||||
<Stack gap={6} style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
label={t('question.options.optionEn', { number: index + 1 })}
|
||||
value={option.text.en}
|
||||
onChange={(e) => updateField(index, 'en', e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t('question.options.optionAm', { number: index + 1 })}
|
||||
value={option.text.am}
|
||||
onChange={(e) => updateField(index, 'am', e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
</Stack>
|
||||
<Checkbox
|
||||
label={t('question.options.correct')}
|
||||
checked={option.isCorrect}
|
||||
onChange={() => toggleCorrect(index)}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
disabled={draft.length <= 2}
|
||||
onClick={() => removeOption(index)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconPlus size={14} />} onClick={addOption}>
|
||||
{t('question.options.addOption')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isSaving} onClick={handleSave}>
|
||||
{t('question.options.save')}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{t('question.options.replaceNotice')}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ActionIcon, Button, Group } from '@mantine/core';
|
||||
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconGavel,
|
||||
IconSend,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -21,52 +27,64 @@ export function questionActionsColumn(
|
||||
cell: ({ row }) => {
|
||||
const q = row.original;
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
loading={handlers.isSubmittingReview}
|
||||
>
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconSend size={12} />}
|
||||
loading={handlers.isSubmittingReview}
|
||||
<Menu.Item
|
||||
leftSection={<IconSend size={14} />}
|
||||
onClick={() => handlers.onSubmitForApproval(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Button>
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
||||
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
|
||||
<Menu.Item 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')}>
|
||||
</Menu.Item>
|
||||
<Menu.Item color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
|
||||
{t('question.qc.reject')}
|
||||
</Button>
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
<Menu.Item
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
leftSection={<IconGavel size={14} />}
|
||||
onClick={() => handlers.onReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Button>
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||
<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>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => handlers.onEdit(q)}>
|
||||
{t('question.action.edit', 'Edit')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(q)}
|
||||
>
|
||||
{t('question.action.delete', 'Delete')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
@@ -13,14 +13,30 @@ import {
|
||||
Select,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
||||
Checkbox,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconTrash,
|
||||
IconGripVertical,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
AdvancedColumn,
|
||||
AdvancedTable,
|
||||
ErrorState,
|
||||
ModalFooter,
|
||||
notify,
|
||||
PageHeader,
|
||||
useErrorHandler,
|
||||
useServerTable,
|
||||
} from "@ema-platform/ui";
|
||||
import { extractErrorMessage } from "@ema-platform/api";
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||
import {
|
||||
useGetQuestionsQuery,
|
||||
useCreateQuestionMutation,
|
||||
@@ -28,10 +44,92 @@ import {
|
||||
useDeleteQuestionMutation,
|
||||
useSubmitQuestionMutation,
|
||||
useReviewQuestionMutation,
|
||||
} from '../../api/question-api';
|
||||
import type { Question, QuestionForm } from '../../types/question';
|
||||
import { questionColumns } from './columns';
|
||||
import { questionActionsColumn } from './actions';
|
||||
useSetQuestionOptionsMutation,
|
||||
} from "../../api/question-api";
|
||||
import type {
|
||||
Question,
|
||||
QuestionForm,
|
||||
QuestionOptionInput,
|
||||
} from "../../types/question";
|
||||
import { QuestionOptionsEditor } from "../../components/QuestionOptionsEditor";
|
||||
import { questionColumns } from "./columns";
|
||||
import { questionActionsColumn } from "./actions";
|
||||
|
||||
type DraftOption = { textEn: string; textAm: string; isCorrect: boolean };
|
||||
|
||||
const BLANK_DRAFT_OPTIONS: DraftOption[] = [
|
||||
{ textEn: "", textAm: "", isCorrect: false },
|
||||
{ textEn: "", textAm: "", isCorrect: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Options for a brand-new CHOICE question, entered inline in the same
|
||||
* modal — no question id exists yet, so this is pure local state, only
|
||||
* turned into a real setOptions() call once the question itself is
|
||||
* created (see QuestionPage.handleSubmit).
|
||||
*/
|
||||
function InlineOptionsEditor({
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
options: DraftOption[];
|
||||
onChange: (options: DraftOption[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const update = (index: number, patch: Partial<DraftOption>) =>
|
||||
onChange(options.map((o, i) => (i === index ? { ...o, ...patch } : o)));
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{options.map((option, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="center">
|
||||
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
|
||||
<Stack gap={6} style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
label={t("question.options.optionEn", { number: index + 1 })}
|
||||
value={option.textEn}
|
||||
onChange={(e) => update(index, { textEn: e.currentTarget.value })}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("question.options.optionAm", { number: index + 1 })}
|
||||
value={option.textAm}
|
||||
onChange={(e) => update(index, { textAm: e.currentTarget.value })}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
</Stack>
|
||||
<Checkbox
|
||||
label={t("question.options.correct")}
|
||||
checked={option.isCorrect}
|
||||
onChange={() => update(index, { isCorrect: !option.isCorrect })}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
disabled={options.length <= 2}
|
||||
onClick={() => onChange(options.filter((_, i) => i !== index))}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
onChange([...options, { textEn: "", textAm: "", isCorrect: false }])
|
||||
}
|
||||
>
|
||||
{t("question.options.addOption")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionForm({
|
||||
editing,
|
||||
@@ -43,7 +141,8 @@ function QuestionForm({
|
||||
editing: Question | null;
|
||||
certOptions: { value: string; label: string }[];
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: {
|
||||
onSubmit: (
|
||||
values: {
|
||||
certificationId: string;
|
||||
titleEn: string;
|
||||
titleAm: string;
|
||||
@@ -52,49 +151,171 @@ function QuestionForm({
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
}, isEdit: boolean) => void;
|
||||
draftOptions: DraftOption[];
|
||||
},
|
||||
isEdit: boolean,
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
|
||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
|
||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
|
||||
const [certificationId, setCertificationId] = useState<string | null>(
|
||||
editing?.certificationId ?? null,
|
||||
);
|
||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
|
||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
|
||||
|
||||
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
||||
const [points, setPoints] = useState<number>(editing?.points ?? 0);
|
||||
const [days, setDays] = useState(editing?.time?.days ?? 0);
|
||||
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
|
||||
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
|
||||
const [draftOptions, setDraftOptions] =
|
||||
useState<DraftOption[]>(BLANK_DRAFT_OPTIONS);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!certificationId || !titleEn || !titleAm || !form) {
|
||||
notify.error('Please fill all required fields');
|
||||
notify.error("Please fill all required fields");
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
certificationId, titleEn, titleAm, form, points, days, hours, minutes
|
||||
}, !!editing);
|
||||
if (!editing && form === "CHOICE") {
|
||||
if (draftOptions.length < 2) {
|
||||
notify.error(t("question.options.needAtLeastTwo"));
|
||||
return;
|
||||
}
|
||||
if (!draftOptions.some((o) => o.isCorrect)) {
|
||||
notify.error(t("question.options.needOneCorrect"));
|
||||
return;
|
||||
}
|
||||
if (draftOptions.some((o) => !o.textEn.trim() || !o.textAm.trim())) {
|
||||
notify.error(t("question.options.textRequired"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSubmit(
|
||||
{
|
||||
certificationId,
|
||||
titleEn,
|
||||
titleAm,
|
||||
form,
|
||||
points,
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
draftOptions: !editing && form === "CHOICE" ? draftOptions : [],
|
||||
},
|
||||
!!editing,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened onClose={onCancel} title={editing ? t('question.update') : t('question.addQuestion')} size="lg">
|
||||
<Modal
|
||||
opened
|
||||
onClose={onCancel}
|
||||
title={editing ? t("question.update") : t("question.addQuestion")}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
||||
<TextInput label={t('question.form.titleEn')} placeholder={t('question.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
|
||||
<TextInput label={t('question.form.titleAm')} placeholder={t('question.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
|
||||
<Select label={t('question.form.form')} placeholder={t('question.form.selectForm')} data={[{ value: 'ESSAY', label: t('question.form.essay') }, { value: 'CHOICE', label: t('question.form.choice') }]} value={form} onChange={setForm} size="sm" required />
|
||||
<NumberInput label={t('question.form.points')} placeholder={t('question.form.pointsPlaceholder')} value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required />
|
||||
<Text fz="sm" fw={500}>{t('question.form.timeAllowed')}</Text>
|
||||
<Select
|
||||
label={t("question.form.certification")}
|
||||
placeholder={t("question.form.selectCertification")}
|
||||
data={certOptions}
|
||||
value={certificationId}
|
||||
onChange={setCertificationId}
|
||||
size="sm"
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("question.form.titleEn")}
|
||||
placeholder={t("question.form.titleEnPlaceholder")}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("question.form.titleAm")}
|
||||
placeholder={t("question.form.titleAmPlaceholder")}
|
||||
value={titleAm}
|
||||
onChange={(e) => setTitleAm(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("question.form.form")}
|
||||
placeholder={t("question.form.selectForm")}
|
||||
data={[
|
||||
{ value: "ESSAY", label: t("question.form.essay") },
|
||||
{ value: "CHOICE", label: t("question.form.choice") },
|
||||
]}
|
||||
value={form}
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("question.form.points")}
|
||||
placeholder={t("question.form.pointsPlaceholder")}
|
||||
value={points}
|
||||
onChange={(v) => setPoints(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("question.form.timeAllowed")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput label={t('question.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
||||
<NumberInput
|
||||
label={t("question.form.days")}
|
||||
value={days}
|
||||
onChange={(v) => setDays(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("question.form.hours")}
|
||||
value={hours}
|
||||
onChange={(v) => setHours(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("question.form.minutes")}
|
||||
value={minutes}
|
||||
onChange={(v) => setMinutes(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
{editing && form === "CHOICE" && (
|
||||
<>
|
||||
<Text fz="sm" fw={500} mt="sm">
|
||||
{t("question.options.title")}
|
||||
</Text>
|
||||
<QuestionOptionsEditor questionId={editing.id} />
|
||||
</>
|
||||
)}
|
||||
{!editing && form === "CHOICE" && (
|
||||
<>
|
||||
<Text fz="sm" fw={500} mt="sm">
|
||||
{t("question.options.title")}
|
||||
</Text>
|
||||
<InlineOptionsEditor
|
||||
options={draftOptions}
|
||||
onChange={setDraftOptions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t("question.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("question.update") : t("question.create")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
@@ -104,7 +325,7 @@ function QuestionForm({
|
||||
|
||||
export function QuestionPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
|
||||
@@ -112,7 +333,10 @@ export function QuestionPage() {
|
||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||
const [deleteQ] = useDeleteQuestionMutation();
|
||||
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
|
||||
const [setOptions, { isLoading: isSavingOptions }] =
|
||||
useSetQuestionOptionsMutation();
|
||||
const [submitQ, { isLoading: isSubmittingReview }] =
|
||||
useSubmitQuestionMutation();
|
||||
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
|
||||
|
||||
const certifications = certRes?.items ?? [];
|
||||
@@ -121,60 +345,115 @@ export function QuestionPage() {
|
||||
const [editing, setEditing] = useState<Question | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
const [certFilter, setCertFilter] = useState<string | null>(null);
|
||||
const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
|
||||
const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED');
|
||||
const [reviewRemark, setReviewRemark] = useState('');
|
||||
const [reviewOutcome, setReviewOutcome] = useState<
|
||||
"APPROVED" | "REJECTED" | "RETIRED"
|
||||
>("APPROVED");
|
||||
const [reviewRemark, setReviewRemark] = useState("");
|
||||
|
||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
const certOptions = certifications
|
||||
.filter((c) => c.isActive)
|
||||
.map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
|
||||
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
|
||||
const filtered = questions.filter(
|
||||
(q) => !certFilter || q.certificationId === certFilter,
|
||||
);
|
||||
const page = paginate(filtered);
|
||||
|
||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
||||
const getCertName = (id: string) =>
|
||||
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
||||
|
||||
const resetForm = () => { setEditing(null); setShowForm(false); };
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: {
|
||||
certificationId: string; titleEn: string; titleAm: string;
|
||||
form: string; points: number; days: number; hours: number; minutes: number;
|
||||
}, isEdit: boolean) => {
|
||||
const handleSubmit = async (
|
||||
values: {
|
||||
certificationId: string;
|
||||
titleEn: string;
|
||||
titleAm: string;
|
||||
form: string;
|
||||
points: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
draftOptions: DraftOption[];
|
||||
},
|
||||
isEdit: boolean,
|
||||
) => {
|
||||
const title = { en: values.titleEn, am: values.titleAm };
|
||||
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
|
||||
const time = {
|
||||
days: values.days,
|
||||
hours: values.hours,
|
||||
minutes: values.minutes,
|
||||
};
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
|
||||
notify.success(t('question.updated'));
|
||||
await updateQ({
|
||||
id: editing.id,
|
||||
certificationId: values.certificationId,
|
||||
title,
|
||||
form: values.form as QuestionForm,
|
||||
points: values.points,
|
||||
time,
|
||||
}).unwrap();
|
||||
notify.success(t("question.updated"));
|
||||
} else {
|
||||
await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap();
|
||||
notify.success(t('question.created'));
|
||||
const created = await createQ({
|
||||
certificationId: values.certificationId,
|
||||
title,
|
||||
description: { en: "", am: "" },
|
||||
form: values.form as QuestionForm,
|
||||
points: values.points,
|
||||
time,
|
||||
}).unwrap();
|
||||
// The question needs an id to attach options to — this is the second
|
||||
// half of one "create" action from the user's point of view, not a
|
||||
// separate edit step, so it happens right here rather than waiting
|
||||
// for them to reopen the question later.
|
||||
if (values.form === "CHOICE" && values.draftOptions.length) {
|
||||
const options: QuestionOptionInput[] = values.draftOptions.map(
|
||||
(o) => ({
|
||||
text: { en: o.textEn, am: o.textAm },
|
||||
isCorrect: o.isCorrect,
|
||||
}),
|
||||
);
|
||||
await setOptions({ id: created.id, options }).unwrap();
|
||||
}
|
||||
notify.success(t("question.created"));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('question.error'));
|
||||
notify.error(t("question.error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitForApproval = async (question: Question) => {
|
||||
try {
|
||||
await submitQ(question.id).unwrap();
|
||||
notify.success(t('question.qc.submitted'));
|
||||
notify.success(t("question.qc.submitted"));
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
||||
notify.error(extractErrorMessage(error, t("question.qc.error")));
|
||||
}
|
||||
};
|
||||
|
||||
const openReview = (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => {
|
||||
const openReview = (
|
||||
question: Question,
|
||||
outcome: "APPROVED" | "REJECTED" | "RETIRED",
|
||||
) => {
|
||||
setReviewTarget(question);
|
||||
setReviewOutcome(outcome);
|
||||
setReviewRemark('');
|
||||
setReviewRemark("");
|
||||
};
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!reviewTarget) return;
|
||||
if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) {
|
||||
notify.error(t('question.qc.remarkRequired'));
|
||||
if (reviewOutcome !== "APPROVED" && !reviewRemark.trim()) {
|
||||
notify.error(t("question.qc.remarkRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -183,10 +462,10 @@ export function QuestionPage() {
|
||||
outcome: reviewOutcome,
|
||||
remark: reviewRemark.trim() || undefined,
|
||||
}).unwrap();
|
||||
notify.success(t('question.qc.reviewed'));
|
||||
notify.success(t("question.qc.reviewed"));
|
||||
setReviewTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
||||
notify.error(extractErrorMessage(error, t("question.qc.error")));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,7 +473,7 @@ export function QuestionPage() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteQ(deleteTarget.id).unwrap();
|
||||
notify.success(t('question.deleted'));
|
||||
notify.success(t("question.deleted"));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch (e) {
|
||||
@@ -202,7 +481,8 @@ export function QuestionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
|
||||
if (isError)
|
||||
return <ErrorState title={t("question.loadError")} onRetry={refetch} />;
|
||||
|
||||
const columns: AdvancedColumn<Question>[] = [
|
||||
...questionColumns(t, { locale, getCertName }),
|
||||
@@ -210,51 +490,70 @@ export function QuestionPage() {
|
||||
isSubmittingReview,
|
||||
onSubmitForApproval: handleSubmitForApproval,
|
||||
onReview: openReview,
|
||||
onEdit: (q) => { setEditing(q); setShowForm(true); },
|
||||
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
|
||||
onEdit: (q) => {
|
||||
setEditing(q);
|
||||
setShowForm(true);
|
||||
},
|
||||
onDelete: (q) => {
|
||||
setDeleteTarget(q);
|
||||
openDelete();
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Title order={2}>{t('question.title')}</Title>
|
||||
{!showForm && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('question.addQuestion')}
|
||||
<PageHeader
|
||||
title={t("question.title")}
|
||||
noMargin
|
||||
action={
|
||||
!showForm && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]}
|
||||
hideOnly
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("question.addQuestion")}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<QuestionForm
|
||||
editing={editing}
|
||||
certOptions={certOptions}
|
||||
isSubmitting={isCreating || isUpdating}
|
||||
isSubmitting={isCreating || isUpdating || isSavingOptions}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={resetForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>{t('question.pool')}</Text>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
title={t("question.pool")}
|
||||
tableName={t("question.title")}
|
||||
toolbar={
|
||||
<Select
|
||||
placeholder={t('question.filterByCertification')}
|
||||
data={[{ value: '', label: 'All' }, ...certOptions]}
|
||||
placeholder={t("question.filterByCertification")}
|
||||
data={[{ value: "", label: "All" }, ...certOptions]}
|
||||
value={certFilter}
|
||||
onChange={(v) => { setCertFilter(v ?? null); setPageIndex(0); }}
|
||||
onChange={(v) => {
|
||||
setCertFilter(v ?? null);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
size="sm"
|
||||
style={{ width: 280 }}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('question.title')}
|
||||
}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
@@ -262,47 +561,75 @@ export function QuestionPage() {
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('question.noQuestions')}
|
||||
emptyText={t("question.noQuestions")}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(reviewTarget)}
|
||||
onClose={() => setReviewTarget(null)}
|
||||
title={t('question.qc.reviewTitle')}
|
||||
title={t("question.qc.reviewTitle")}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text>
|
||||
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text>
|
||||
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content">
|
||||
<Text fz="sm" fw={500}>
|
||||
{reviewTarget?.title?.[locale]}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t("question.qc.onlyApprovedUsable")}
|
||||
</Text>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={
|
||||
reviewOutcome === "APPROVED"
|
||||
? "teal"
|
||||
: reviewOutcome === "REJECTED"
|
||||
? "red"
|
||||
: "dark"
|
||||
}
|
||||
w="fit-content"
|
||||
>
|
||||
{t(`question.qc.${reviewOutcome}`)}
|
||||
</Badge>
|
||||
<Textarea
|
||||
label={t('question.qc.remark')}
|
||||
label={t("question.qc.remark")}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={reviewRemark}
|
||||
onChange={(e) => setReviewRemark(e.currentTarget.value)}
|
||||
required={reviewOutcome !== 'APPROVED'}
|
||||
required={reviewOutcome !== "APPROVED"}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setReviewTarget(null)}>
|
||||
{t('question.cancel')}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setReviewTarget(null)}
|
||||
>
|
||||
{t("question.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" loading={isReviewing} onClick={handleReview}>
|
||||
{t(`question.qc.${reviewOutcome === 'APPROVED' ? 'approve' : reviewOutcome === 'REJECTED' ? 'reject' : 'retire'}`)}
|
||||
{t(
|
||||
`question.qc.${reviewOutcome === "APPROVED" ? "approve" : reviewOutcome === "REJECTED" ? "reject" : "retire"}`,
|
||||
)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||
<Modal
|
||||
opened={deleteOpened}
|
||||
onClose={closeDelete}
|
||||
title={t("question.confirmDelete")}
|
||||
size="sm"
|
||||
>
|
||||
<Text mb="md">{t("question.deleteConfirmText")}</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">
|
||||
{t("question.cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">
|
||||
{t("question.delete")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
|
||||
@@ -16,6 +16,17 @@ export type QuestionStatus =
|
||||
| 'REJECTED'
|
||||
| 'RETIRED';
|
||||
|
||||
/**
|
||||
* A CHOICE option, as returned by the authoring/QC endpoints. Never carries
|
||||
* a correctness flag — the API's own answer-key table is never joined into
|
||||
* this response either, so there's nothing to accidentally serialize here.
|
||||
*/
|
||||
export interface QuestionOption {
|
||||
id: string;
|
||||
text: LocalePair;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
certificationId: string;
|
||||
@@ -32,6 +43,8 @@ export interface Question {
|
||||
submittedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Only populated when explicitly requested (`?i=options`). */
|
||||
options?: QuestionOption[];
|
||||
}
|
||||
|
||||
export interface ReviewQuestionPayload {
|
||||
@@ -64,3 +77,13 @@ export interface UpdateQuestionPayload {
|
||||
points?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface QuestionOptionInput {
|
||||
text: LocalePair;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
export interface SetQuestionOptionsPayload {
|
||||
id: string;
|
||||
options: QuestionOptionInput[];
|
||||
}
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Button, Center, Group, Loader, Modal, Paper, Select, Stack, Text, Textarea} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
@@ -75,12 +62,11 @@ export function ExamAppealsPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2}>{t('result.appeals.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('result.appeals.subtitle')}
|
||||
</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={t('result.appeals.title')}
|
||||
subtitle={t('result.appeals.subtitle')}
|
||||
noMargin
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<AdvancedTable<ExamAppeal>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { IconEye, IconTrash } from '@tabler/icons-react';
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -13,6 +13,7 @@ export function resultActionsColumn(
|
||||
onQc: (result: Result, action: QcAction) => void;
|
||||
onViewDetail: (result: Result) => void;
|
||||
onDelete: (result: Result) => void;
|
||||
onPublish: (result: Result) => void;
|
||||
},
|
||||
): AdvancedColumn<Result> {
|
||||
return {
|
||||
@@ -21,18 +22,27 @@ export function resultActionsColumn(
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => handlers.onViewDetail(r)}>
|
||||
{t('result.action.viewEdit')}
|
||||
</Menu.Item>
|
||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||
<>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
|
||||
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
||||
<Menu.Item color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
||||
{t('result.review.moderate')}
|
||||
</Button>
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
||||
<Menu.Item color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
||||
{t('result.review.approve')}
|
||||
</Button>
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
@@ -44,26 +54,34 @@ export function resultActionsColumn(
|
||||
]}
|
||||
hideOnly
|
||||
>
|
||||
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
|
||||
<Menu.Item color="orange" onClick={() => handlers.onQc(r, 'return')}>
|
||||
{t('result.review.return')}
|
||||
</Button>
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{r.reviewStatus === 'APPROVED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Item
|
||||
color="teal"
|
||||
leftSection={<IconSend size={14} />}
|
||||
onClick={() => handlers.onPublish(r)}
|
||||
>
|
||||
{t('result.review.publish')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
|
||||
{t('result.action.viewEdit')}
|
||||
</Button>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(r)}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Button>
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
|
||||
import { Badge, Box, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Result, ResultReviewStatus } from '../../types/result';
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
PASSED: 'teal',
|
||||
FAILED: 'red',
|
||||
export const STATUS_TONE: Record<string, StatusTone> = {
|
||||
PASSED: 'success',
|
||||
FAILED: 'danger',
|
||||
};
|
||||
|
||||
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
|
||||
@@ -46,20 +48,22 @@ export function resultColumns(
|
||||
{
|
||||
header: t('result.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status]}
|
||||
label={t(`result.status.${row.original.status}`)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[row.original.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${STATUS_TONE_COLOR[STATUS_TONE[row.original.status]]}-6)`,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
import { useState, useCallback, type ElementType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Table,
|
||||
Badge,
|
||||
Modal,
|
||||
Text,
|
||||
Paper,
|
||||
Card,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Button,
|
||||
ThemeIcon,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconUser,
|
||||
IconCertificate,
|
||||
IconDeviceFloppy,
|
||||
IconPlus,
|
||||
IconClipboardList,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconChartBar,
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
||||
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
|
||||
import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
||||
@@ -53,7 +22,7 @@ import { useGetExamsQuery } from '../../../exam/api/exam-api';
|
||||
import { RecordResultModal } from '../../components/RecordResultModal';
|
||||
import type { Result, ResultBreakdown } from '../../types/result';
|
||||
import type { Exam } from '../../../exam/types/exam';
|
||||
import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns';
|
||||
import { resultColumns, STATUS_TONE, REVIEW_COLOR } from './columns';
|
||||
import { resultActionsColumn, type QcAction } from './actions';
|
||||
|
||||
function ResultStat({
|
||||
@@ -126,6 +95,8 @@ export function ResultPage() {
|
||||
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [publishTarget, setPublishTarget] = useState<Result | null>(null);
|
||||
const [publishOpened, { open: openPublish, close: closePublish }] = useDisclosure(false);
|
||||
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
|
||||
const [detailSaving, setDetailSaving] = useState(false);
|
||||
@@ -262,6 +233,19 @@ export function ResultPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */
|
||||
const handleConfirmPublish = async () => {
|
||||
if (!publishTarget) return;
|
||||
try {
|
||||
const outcome = await publishResults(publishTarget.examId).unwrap();
|
||||
notify.success(t('result.review.publishedCount', outcome));
|
||||
closePublish();
|
||||
setPublishTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.review.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetailClose = () => {
|
||||
closeDetail();
|
||||
setDetailBreakdowns([]);
|
||||
@@ -280,7 +264,7 @@ export function ResultPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
|
||||
if (isError) return <ErrorState title={t('result.loadError')} onRetry={refetch} />;
|
||||
|
||||
const columns = [
|
||||
...resultColumns(t, locale, showDate, getExamTitle),
|
||||
@@ -288,6 +272,7 @@ export function ResultPage() {
|
||||
onQc: openQc,
|
||||
onViewDetail: viewDetail,
|
||||
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
|
||||
onPublish: (r) => { setPublishTarget(r); openPublish(); },
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -295,11 +280,11 @@ export function ResultPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t('result.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={t('result.title')}
|
||||
subtitle={t('result.subtitle')}
|
||||
noMargin
|
||||
action={
|
||||
<Group gap="sm">
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
|
||||
<Button
|
||||
@@ -314,11 +299,14 @@ export function ResultPage() {
|
||||
{t('result.review.publish')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||
{t('result.record')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
|
||||
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
|
||||
@@ -327,10 +315,13 @@ export function ResultPage() {
|
||||
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>{t('result.section')}</Text>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
title={t('result.section')}
|
||||
tableName={t('result.title')}
|
||||
toolbar={
|
||||
<>
|
||||
<TextInput
|
||||
placeholder={t('result.search.seafarer')}
|
||||
leftSection={<IconSearch size={15} />}
|
||||
@@ -348,13 +339,8 @@ export function ResultPage() {
|
||||
style={{ width: 280 }}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('result.title')}
|
||||
</>
|
||||
}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
@@ -364,7 +350,6 @@ export function ResultPage() {
|
||||
isLoading={isFetching}
|
||||
emptyText={t('result.noItems')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={detailOpened}
|
||||
@@ -421,9 +406,10 @@ export function ResultPage() {
|
||||
{t('result.review.derivedStatus')}
|
||||
</Text>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Badge variant="light" color={STATUS_COLOR[detailResult.status]}>
|
||||
{t(`result.status.${detailResult.status}`)}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[detailResult.status]}
|
||||
label={t(`result.status.${detailResult.status}`)}
|
||||
/>
|
||||
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${detailResult.reviewStatus}`)}
|
||||
</Badge>
|
||||
@@ -504,6 +490,13 @@ export function ResultPage() {
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
|
||||
<RequirePermission
|
||||
anyOf={[
|
||||
LICENSE_PERMISSIONS.RECORD_EXAM_RESULT,
|
||||
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
|
||||
]}
|
||||
hideOnly
|
||||
>
|
||||
<Button
|
||||
onClick={handleDetailSave}
|
||||
size="sm"
|
||||
@@ -512,6 +505,7 @@ export function ResultPage() {
|
||||
>
|
||||
{t('result.save')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
) : (
|
||||
@@ -574,6 +568,20 @@ export function ResultPage() {
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={publishOpened} onClose={closePublish} title={t('result.review.publish')} size="sm">
|
||||
<Text mb="md">
|
||||
{t('result.review.publishConfirmText', {
|
||||
exam: publishTarget ? getExamTitle(publishTarget.examId) : '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closePublish} size="sm">{t('result.cancel')}</Button>
|
||||
<Button color="teal" loading={isPublishing} onClick={handleConfirmPublish} size="sm">
|
||||
{t('result.review.publish')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
{/* Choose exam, then record */}
|
||||
<Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg">
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
|
||||
import {Badge, Container, Select, Text, TextInput} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type SeafarerDocumentRow,
|
||||
type SeafarerDocumentStatus,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageHeader, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -91,6 +91,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Waiting',
|
||||
accessorKey: 'submittedAt',
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<WaitingFor
|
||||
since={row.original.submittedAt}
|
||||
done={row.original.status === 'ISSUED' || row.original.status === 'REJECTED'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
@@ -106,14 +117,16 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
{SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Requests released by an approved seafarer registration: confirm payment, schedule the
|
||||
collection date, then issue.
|
||||
</Text>
|
||||
<Group mb="md" gap="sm">
|
||||
<PageHeader
|
||||
title={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue`}
|
||||
subtitle="Requests released by an approved seafarer registration: confirm payment, schedule the collection date, then issue."
|
||||
/>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
|
||||
toolbar={
|
||||
<>
|
||||
<TextInput
|
||||
placeholder="Search number, name or seafarer №…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
@@ -122,7 +135,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
setSearch(e.currentTarget.value);
|
||||
setPage(0);
|
||||
}}
|
||||
w={280}
|
||||
w={260}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
@@ -133,13 +146,10 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
setPage(0);
|
||||
}}
|
||||
clearable
|
||||
w={220}
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
|
||||
</>
|
||||
}
|
||||
itemCount={data?.total ?? 0}
|
||||
pageIndex={page}
|
||||
onPageChange={setPage}
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||
import {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, SimpleGrid, Stack, Text, Textarea, ThemeIcon, rem} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
@@ -29,26 +14,41 @@ import {
|
||||
useRejectSeafarerDocumentMutation,
|
||||
useScheduleSeafarerDocumentMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, notify } from '@ema-platform/ui';
|
||||
import { AmharicDatePicker, notify, PageHeader } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const;
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" component="div">
|
||||
{value ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||
<Text fz="sm" fw={600} component="div">{value ?? '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
icon,
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<ThemeIcon variant="light" color={color} size={26} radius="md">{icon}</ThemeIcon>
|
||||
<Text fw={700} fz="sm">{title}</Text>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,12 +123,10 @@ export function SeafarerDocumentReviewPage() {
|
||||
>
|
||||
Back to queue
|
||||
</Button>
|
||||
<Group justify="space-between" align="flex-start" mb="md">
|
||||
<div>
|
||||
<Title order={3}>
|
||||
{kindLabel} — {applicant?.name ?? '—'}
|
||||
</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<PageHeader
|
||||
title={`${kindLabel} — ${applicant?.name ?? '—'}`}
|
||||
meta={
|
||||
<>
|
||||
<Text size="sm" c="dimmed" ff="monospace">
|
||||
{document.requestNumber}
|
||||
</Text>
|
||||
@@ -140,8 +138,9 @@ export function SeafarerDocumentReviewPage() {
|
||||
{document.documentNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||
@@ -175,7 +174,8 @@ export function SeafarerDocumentReviewPage() {
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{document.status === 'REJECTED' && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
|
||||
@@ -183,61 +183,49 @@ export function SeafarerDocumentReviewPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="lg" mb="md">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>
|
||||
{(applicant?.name ?? '??').split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Seafarer
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
<Row label="Name" value={applicant?.name} />
|
||||
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
||||
<Row
|
||||
label="Registration"
|
||||
value={
|
||||
applicant?.registrationId ? (
|
||||
<Text fw={700} fz="lg" lh={1.2}>{applicant?.name ?? '—'}</Text>
|
||||
<Group gap={6} mt={4}>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{applicant?.seafarerNumber ?? '—'}</Text>
|
||||
{applicant?.registrationId && (
|
||||
<>
|
||||
<Text fz="xs" c="dimmed">·</Text>
|
||||
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
|
||||
{applicant.registrationNumber}
|
||||
<Text fz="xs" c="blue.6">{applicant.registrationNumber}</Text>
|
||||
</Link>
|
||||
) : (
|
||||
applicant?.registrationNumber
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Payment
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||
<Row label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
||||
<Row label="Provider" value={payment?.provider} />
|
||||
<Row label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Issuance
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||
<Row label="Document №" value={document.documentNumber} />
|
||||
<Row label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
||||
<Row label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<SectionCard title="Payment" icon={<IconCash size={14} />} color="teal">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Stat label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||
<Stat label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||
<Stat label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
||||
<Stat label="Provider" value={payment?.provider} />
|
||||
<Stat label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Issuance" icon={<IconFileCertificate size={14} />} color="violet">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Stat label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||
<Stat label="Document №" value={document.documentNumber} />
|
||||
<Stat label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
||||
<Stat label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
</SimpleGrid>
|
||||
|
||||
<Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
|
||||
<Stack>
|
||||
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
|
||||
import {Container, Select, Text, TextInput} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_STATUS_COLORS,
|
||||
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
displaySeafarerAnswer,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
type SeafarerRegistration,
|
||||
type SeafarerRegistrationStatus,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageHeader, StatusBadge, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -78,13 +78,25 @@ export function SeafarerRegistrationQueuePage() {
|
||||
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Waiting',
|
||||
accessorKey: 'submittedAt',
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<WaitingFor
|
||||
since={row.original.submittedAt}
|
||||
done={row.original.status === 'APPROVED' || row.original.status === 'REJECTED'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[row.original.status]}>
|
||||
{SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={SEAFARER_REGISTRATION_STATUS_TONES[row.original.status]}
|
||||
label={SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -93,14 +105,16 @@ export function SeafarerRegistrationQueuePage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
Seafarer Registration Queue
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and
|
||||
BTC applications.
|
||||
</Text>
|
||||
<Group mb="md" gap="sm">
|
||||
<PageHeader
|
||||
title="Seafarer Registration Queue"
|
||||
subtitle="Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and BTC applications."
|
||||
/>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
tableName="Seafarer registrations"
|
||||
toolbar={
|
||||
<>
|
||||
<TextInput
|
||||
placeholder="Search number, name or ID…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
@@ -109,7 +123,7 @@ export function SeafarerRegistrationQueuePage() {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPage(0);
|
||||
}}
|
||||
w={280}
|
||||
w={260}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
@@ -120,13 +134,10 @@ export function SeafarerRegistrationQueuePage() {
|
||||
setPage(0);
|
||||
}}
|
||||
clearable
|
||||
w={220}
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
tableName="Seafarer registrations"
|
||||
</>
|
||||
}
|
||||
itemCount={data?.total ?? 0}
|
||||
pageIndex={page}
|
||||
onPageChange={setPage}
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||
SEAFARER_REGISTRATION_SECTIONS,
|
||||
SEAFARER_REGISTRATION_STATUS_COLORS,
|
||||
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
displaySeafarerAnswer,
|
||||
extractErrorMessage,
|
||||
@@ -31,7 +15,7 @@ import {
|
||||
useRejectSeafarerRegistrationMutation,
|
||||
useRequestSeafarerRegistrationChangesMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import { applicantName } from './SeafarerRegistrationQueuePage';
|
||||
|
||||
@@ -109,23 +93,25 @@ export function SeafarerRegistrationReviewPage() {
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/seafarer-registrations')} mb="xs">
|
||||
Back to queue
|
||||
</Button>
|
||||
<Group justify="space-between" align="flex-start" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{applicantName(registration)}</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<PageHeader
|
||||
title={applicantName(registration)}
|
||||
meta={
|
||||
<>
|
||||
<Text size="sm" c="dimmed" ff="monospace">
|
||||
{registration.registrationNumber}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
|
||||
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
|
||||
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
||||
/>
|
||||
{registration.seafarerNumber && (
|
||||
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
{registration.seafarerNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
{canDecide && (
|
||||
<>
|
||||
@@ -147,7 +133,8 @@ export function SeafarerRegistrationReviewPage() {
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{registration.status === 'RESUBMIT_REQUIRED' && (
|
||||
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
|
||||
@@ -167,7 +154,7 @@ export function SeafarerRegistrationReviewPage() {
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
{section.title}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
{section.fields
|
||||
.filter((f) => f !== 'passportExpiry' || registration.passportNumber)
|
||||
@@ -192,7 +179,7 @@ export function SeafarerRegistrationReviewPage() {
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Documents
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
{slots.map((slot) => {
|
||||
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
import { PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useState } from 'react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {ActionIcon, Avatar, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
@@ -32,6 +15,7 @@ import {
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -59,72 +43,78 @@ const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
|
||||
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
|
||||
|
||||
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
|
||||
const STATUS_COLOR: Record<string, string> = { Active: 'teal', Inactive: 'gray', Suspended: 'red' };
|
||||
const STATUS_TONE: Record<string, StatusTone> = { Active: 'success', Inactive: 'neutral', Suspended: 'danger' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||
<Text fz="sm" fw={600}>{value ?? '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
|
||||
if (!sf) return null;
|
||||
const initials = sf.name.split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase();
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} — {sf.id}</Text></Group>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Medical Expiry</Text>
|
||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
|
||||
<Modal opened={opened} onClose={onClose} size="xl" radius="lg" padding={0} withCloseButton={false}>
|
||||
<Stack gap={0}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" wrap="nowrap" p="lg" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>{initials}</Avatar>
|
||||
<div>
|
||||
<Text fw={700} fz="lg" lh={1.2}>{sf.name}</Text>
|
||||
<Group gap={6} mt={4}>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{sf.id}</Text>
|
||||
<Text fz="xs" c="dimmed">·</Text>
|
||||
<Text fz="xs" c="dimmed">{sf.rank}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<StatusBadge tone={STATUS_TONE[sf.status]} label={sf.status} variant="light" />
|
||||
<ActionIcon variant="subtle" color="gray" onClick={onClose}><IconX size={16} /></ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Stack gap="lg" p="lg">
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="lg">
|
||||
<Stat label="Nationality" value={sf.nationality} />
|
||||
<Stat label="Date of Birth" value={sf.dob} />
|
||||
<Stat label="Seaman Book №" value={sf.seamanBookNo} />
|
||||
<Stat label="SB Expiry" value={sf.seamanBookExpiry} />
|
||||
<Stat label="BTC №" value={sf.btcNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
|
||||
<Stat label="BSID №" value={sf.bsidNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
|
||||
<Stat label="Medical" value={<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="sm">{sf.medicalStatus}</Badge>} />
|
||||
<Stat label="Medical Expiry" value={sf.medicalExpiry} />
|
||||
</SimpleGrid>
|
||||
|
||||
{sf.cocCerts.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
|
||||
<Table fz="xs" verticalSpacing="xs">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="sm" tt="uppercase">CoC / CoP Certificates</Text>
|
||||
<Stack gap="xs">
|
||||
{sf.cocCerts.map((c) => (
|
||||
<Table.Tr key={c.no}>
|
||||
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
<Group key={c.no} justify="space-between" wrap="nowrap" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="violet" size={30} radius="md"><IconCertificate size={15} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{c.type}</Text>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{c.no}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">Expires {c.expiry}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -169,10 +159,11 @@ export function SeafarerRegistryPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registry</Title>
|
||||
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Seafarer Registry"
|
||||
subtitle="Search and view all registered seafarers, their documents, and certificate status"
|
||||
noMargin
|
||||
/>
|
||||
|
||||
{/* KPIs */}
|
||||
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
|
||||
@@ -264,7 +255,12 @@ export function SeafarerRegistryPage() {
|
||||
: <Text fz="xs" c="dimmed">—</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[sf.status]}
|
||||
label={sf.status}
|
||||
variant="light"
|
||||
size="xs"
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>
|
||||
|
||||
@@ -336,7 +336,7 @@ export function VesselRegistrationFormBuilderPage() {
|
||||
<IconSettings size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Vessel Registration Form Builder</Title>
|
||||
<Title order={2}>Vessel Registration Form Builder</Title>
|
||||
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Vessel } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
||||
REGISTERED: 'green',
|
||||
SUSPENDED: 'orange',
|
||||
DEREGISTERED: 'gray',
|
||||
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
|
||||
REGISTERED: 'success',
|
||||
SUSPENDED: 'pending',
|
||||
DEREGISTERED: 'neutral',
|
||||
};
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -63,13 +65,12 @@ export function vesselRegistrationQueueColumns(
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={VESSEL_STATUS_TONES[row.original.status]}
|
||||
label={row.original.status}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={VESSEL_STATUS_COLORS[row.original.status]}
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Card, Container, Drawer, Group, Loader, Modal, Select, Stack, Table, Text, TextInput, Textarea} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -238,18 +221,19 @@ export function VesselRegistrationQueuePage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Vessel register</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
<PageHeader
|
||||
title="Vessel register"
|
||||
subtitle={
|
||||
<>
|
||||
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'} —
|
||||
pending registrations are reviewed in the{' '}
|
||||
<Text component={Link} to="/licence-review" inherit c="blue">
|
||||
licence queue
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<TextInput
|
||||
placeholder="Name, registration № or IMO"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
@@ -257,7 +241,8 @@ export function VesselRegistrationQueuePage() {
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<AdvancedTable
|
||||
tableName="Vessel register"
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Alert, Container, Group, Text, Title } from '@mantine/core';
|
||||
import {Alert, Container, Group, Text} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
|
||||
import {
|
||||
ApiErrorAlert,
|
||||
EmptyState,
|
||||
PageLoader,
|
||||
notify,
|
||||
} from '@ema-platform/ui';
|
||||
import { ApiErrorAlert, EmptyState, notify, PageHeader, PageLoader } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
downloadAuthedFile,
|
||||
@@ -85,16 +80,14 @@ export function VesselRegistrationReportPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Vessel registration report</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{report
|
||||
<PageHeader
|
||||
title="Vessel registration report"
|
||||
subtitle={
|
||||
report
|
||||
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
||||
: 'The national vessel register at a glance.'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
: 'The national vessel register at a glance.'
|
||||
}
|
||||
/>
|
||||
|
||||
<ReportFilters
|
||||
query={query}
|
||||
|
||||
@@ -11,6 +11,10 @@ export const am: Translations = {
|
||||
tagline: "የቁጥጥር ማዕከል",
|
||||
},
|
||||
|
||||
a11y: {
|
||||
skipToContent: "ወደ ዋናው ይዘት ዝለል",
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
|
||||
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
|
||||
@@ -255,6 +259,7 @@ export const am: Translations = {
|
||||
choice: "ምርጫ",
|
||||
offline: "ከመስመር ውጪ",
|
||||
online: "በመስመር",
|
||||
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
|
||||
sum: "ድምር",
|
||||
average: "አማካይ",
|
||||
percentage: "መቶኛ",
|
||||
@@ -262,6 +267,11 @@ export const am: Translations = {
|
||||
random: "በዘፈቀደ",
|
||||
cuttingPoint: "የማለፊያ ነጥብ",
|
||||
cuttingPointPlaceholder: "ለማለፍ ዝቅተኛ ነጥብ",
|
||||
cuttingPointPercentagePlaceholder: "ለማለፍ ዝቅተኛ መቶኛ (0-100)",
|
||||
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
|
||||
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
|
||||
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
|
||||
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
|
||||
status: "ሁኔታ",
|
||||
statusPlaceholder: "የፈተና ሁኔታ",
|
||||
pending: "በመጠባበቅ ላይ",
|
||||
@@ -326,6 +336,10 @@ export const am: Translations = {
|
||||
retake: "ድጋሚ {{n}}",
|
||||
firstSitting: "የመጀመሪያ ሙከራ",
|
||||
remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።",
|
||||
regrade: "እንደገና ደረጃ ስጥ",
|
||||
regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።",
|
||||
regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።",
|
||||
regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።",
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: "አልተጠራም",
|
||||
@@ -370,6 +384,8 @@ export const am: Translations = {
|
||||
randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል",
|
||||
randomError: "ጥያቄዎችን መምረጥ አልተቻለም",
|
||||
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
|
||||
cannotReachCuttingPoint:
|
||||
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -687,6 +703,8 @@ export const am: Translations = {
|
||||
returned: "ውጤት ወደ ፈታኙ ተመልሷል",
|
||||
publishedCount: "{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።",
|
||||
publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።",
|
||||
publishConfirmText:
|
||||
"ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?",
|
||||
lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።",
|
||||
originalScore: "የፈታኙ ጠቅላላ",
|
||||
derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",
|
||||
@@ -778,6 +796,22 @@ export const am: Translations = {
|
||||
onlyApprovedUsable: "የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።",
|
||||
error: "ተግባሩ አልተሳካም",
|
||||
},
|
||||
options: {
|
||||
title: "የመልስ አማራጮች",
|
||||
hint: "ትክክለኛውን አማራጭ ምረጥ/ምረጪ። ማስቀመጥ መላውን የአማራጭ ስብስብ ይተካል።",
|
||||
optionLabel: "አማራጭ {{number}}",
|
||||
optionEn: "አማራጭ {{number}} (እንግሊዝኛ)",
|
||||
optionAm: "አማራጭ {{number}} (አማርኛ)",
|
||||
correct: "ትክክለኛ",
|
||||
addOption: "አማራጭ ጨምር",
|
||||
save: "አማራጮችን አስቀምጥ",
|
||||
saved: "አማራጮች ተቀምጠዋል",
|
||||
saveFirst: "መጀመሪያ ጥያቄውን አስቀምጥ፣ ከዚያ አማራጮችን ጨምር።",
|
||||
replaceNotice: "ትክክለኛ መልሶች ከተቀመጡ በኋላ እዚህ አይታዩም — እንደገና ካስተካከልክ/ካስተካከልሽ ዳግም ምረጥ/ምረጪ።",
|
||||
needAtLeastTwo: "ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልገዋል።",
|
||||
needOneCorrect: "ቢያንስ አንድ አማራጭ እንደ ትክክለኛ ምረጥ/ምረጪ።",
|
||||
textRequired: "እያንዳንዱ አማራጭ በሁለቱም ቋንቋዎች ጽሑፍ ያስፈልገዋል።",
|
||||
},
|
||||
},
|
||||
|
||||
configuration: {
|
||||
|
||||
@@ -10,6 +10,11 @@ export const en = {
|
||||
tagline: 'Control Center',
|
||||
},
|
||||
|
||||
// Strings only assistive technology encounters.
|
||||
a11y: {
|
||||
skipToContent: 'Skip to main content',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
serverError: 'Server error. Please try again later.',
|
||||
@@ -253,6 +258,7 @@ export const en = {
|
||||
choice: 'Choice',
|
||||
offline: 'Offline',
|
||||
online: 'Online',
|
||||
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
|
||||
sum: 'Sum',
|
||||
average: 'Average',
|
||||
percentage: 'Percentage',
|
||||
@@ -260,6 +266,11 @@ export const en = {
|
||||
random: 'Random',
|
||||
cuttingPoint: 'Cutting Point (Pass Mark)',
|
||||
cuttingPointPlaceholder: 'Minimum score to pass',
|
||||
cuttingPointPercentagePlaceholder: 'Minimum % to pass (0-100)',
|
||||
cuttingPointPercentageHint: 'Percentage evaluation — capped at 100.',
|
||||
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
|
||||
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
|
||||
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
|
||||
status: 'Status',
|
||||
statusPlaceholder: 'Exam status',
|
||||
pending: 'Pending',
|
||||
@@ -323,6 +334,10 @@ export const en = {
|
||||
retake: 'Retake {{n}}',
|
||||
firstSitting: 'First sitting',
|
||||
remarkRequired: 'A reason is required for a withdrawal or a disqualification.',
|
||||
regrade: 'Regrade',
|
||||
regraded: 'Result created from the graded attempt.',
|
||||
regradeNotEligible: 'Not auto-gradable: {{reason}}. Use Record Result instead.',
|
||||
regradeError: 'Could not regrade this attempt.',
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: 'Not called',
|
||||
@@ -368,6 +383,8 @@ export const en = {
|
||||
randomError: 'Could not draw questions',
|
||||
notEnoughApproved:
|
||||
'Not enough approved questions in the bank for this subject.',
|
||||
cannotReachCuttingPoint:
|
||||
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -687,6 +704,8 @@ export const en = {
|
||||
returned: 'Result returned to the examiner',
|
||||
publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.',
|
||||
publishNeedsExam: 'Filter by an exam first to publish its results.',
|
||||
publishConfirmText:
|
||||
'This publishes every approved result for {{exam}} — not just this one — and notifies each candidate. Continue?',
|
||||
lockedAfterApproval:
|
||||
'This result is approved and can no longer be edited. Return it to the examiner first.',
|
||||
originalScore: 'Examiner total',
|
||||
@@ -780,6 +799,23 @@ export const en = {
|
||||
'Only approved items can be placed on an examination paper.',
|
||||
error: 'Operation failed',
|
||||
},
|
||||
options: {
|
||||
title: 'Answer Options',
|
||||
hint: 'Mark every correct option. Saving replaces the entire option set.',
|
||||
optionLabel: 'Option {{number}}',
|
||||
optionEn: 'Option {{number}} (English)',
|
||||
optionAm: 'Option {{number}} (Amharic)',
|
||||
correct: 'Correct',
|
||||
addOption: 'Add option',
|
||||
save: 'Save options',
|
||||
saved: 'Options saved',
|
||||
saveFirst: 'Save the question first, then add its options.',
|
||||
replaceNotice:
|
||||
'Correct answers are never shown here once saved — re-mark them if you edit this set again.',
|
||||
needAtLeastTwo: 'A question needs at least two options.',
|
||||
needOneCorrect: 'Mark at least one option as correct.',
|
||||
textRequired: 'Every option needs text in both languages.',
|
||||
},
|
||||
},
|
||||
|
||||
configuration: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem, NavSection } from '@ema-platform/ui';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
|
||||
import { SkipLink, MAIN_CONTENT_ID } from '@ema-platform/ui';
|
||||
import { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
|
||||
import { usePermissions } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
@@ -151,6 +152,10 @@ export function BackofficeLayout() {
|
||||
const isSidebar = layoutMode === "sidebar";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* First focusable element on the page, so a keyboard user can bypass
|
||||
the 20-plus nav items instead of tabbing through them every time. */}
|
||||
<SkipLink />
|
||||
<AppShell
|
||||
// The top layout drops its nav strip on small screens — the drawer is
|
||||
// the nav there — so the header shrinks back to a single row with it.
|
||||
@@ -252,7 +257,7 @@ export function BackofficeLayout() {
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
|
||||
<AppShell.Main>
|
||||
<AppShell.Main id={MAIN_CONTENT_ID}>
|
||||
<div key={location.pathname} className="ema-page-enter">
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -287,5 +292,6 @@ export function BackofficeLayout() {
|
||||
/>
|
||||
</Drawer>
|
||||
</AppShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RequirePermission,
|
||||
LICENSE_PERMISSIONS as P,
|
||||
} from '@ema-platform/auth';
|
||||
import { ThemeGallery } from '@ema-platform/ui';
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
@@ -68,6 +69,9 @@ const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: '/um/*', element: <UserManagementPage /> },
|
||||
// Theme visual-regression surface. Unauthenticated by design — it renders
|
||||
// only static primitives, so it needs no API and cannot flake.
|
||||
{ path: '/__gallery', element: <ThemeGallery /> },
|
||||
{ path: '/', element: <LandingRoute /> },
|
||||
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
|
||||
{
|
||||
|
||||
@@ -4,6 +4,10 @@ import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/spotlight/styles.css';
|
||||
// After Mantine's CSS (it defines the variables these tokens resolve to),
|
||||
// before the app's own, which may override them. Relative because the
|
||||
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
|
||||
import '../../../libs/shared/src/lib/theme/semantic.css';
|
||||
import './styles.css';
|
||||
import './app/i18n/config';
|
||||
import { App } from './app/app';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
|
||||
Without it every Amharic string in the app renders in whatever the OS
|
||||
happens to substitute — different on Windows, macOS and Android. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ test.describe('seafarer registration', () => {
|
||||
await expect(page.getByLabel('National ID (Fayda) Number')).toHaveValue('FYD1234567890');
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 2 — Applicant Details.
|
||||
// Step 2 — Details: address and physical characteristics.
|
||||
await page.getByLabel('Place of Birth').fill('Addis Ababa');
|
||||
await pick(page, 'Department', /deck/i);
|
||||
await pick(page, 'City', /addis ababa/i);
|
||||
@@ -356,15 +356,15 @@ test.describe('seafarer registration', () => {
|
||||
await pick(page, 'Eye Colour', /brown/i);
|
||||
await page.getByLabel('Height (cm)').fill('172');
|
||||
await page.getByLabel('Weight (kg)').fill('68');
|
||||
await page.getByLabel('Certificate Number').fill('MED-2026-001');
|
||||
await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic');
|
||||
await pickDate(page, 'Issue Date', '2026-01-15');
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 3 — Emergency Contact.
|
||||
// Step 3 — Contact & Medical.
|
||||
await page.getByLabel('Full Name').fill('Almaz Tesfaye');
|
||||
await page.getByLabel('Relationship').fill('Sister');
|
||||
await page.getByLabel('Phone Number').fill('+251911222333');
|
||||
await page.getByLabel('Certificate Number').fill('MED-2026-001');
|
||||
await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic');
|
||||
await pickDate(page, 'Issue Date', '2026-01-15');
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 4 — Documents: all four required slots show as uploaded.
|
||||
|
||||
65
apps/e2e/visual.config.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Visual-regression suite for theme work.
|
||||
*
|
||||
* Deliberately separate from `playwright.config.ts`. That suite drives real
|
||||
* cross-app workflows and therefore needs the API, a database and migrations;
|
||||
* this one only needs to know what the theme renders. Loading the same
|
||||
* dependencies here would make a screenshot diff fail for reasons that have
|
||||
* nothing to do with the theme — a migration, a seeded row, an expired token.
|
||||
*
|
||||
* So: static routes only, `vite preview` over an already-built bundle, no
|
||||
* backend. Run `vite build` for both apps first.
|
||||
*/
|
||||
|
||||
const PORTAL_PORT = Number(process.env.VISUAL_PORTAL_PORT ?? 4312);
|
||||
const BACKOFFICE_PORT = Number(process.env.VISUAL_BACKOFFICE_PORT ?? 4313);
|
||||
|
||||
export const VISUAL = {
|
||||
portalUrl: `http://localhost:${PORTAL_PORT}`,
|
||||
backofficeUrl: `http://localhost:${BACKOFFICE_PORT}`,
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './visual',
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
// A visual diff that passes on a retry is a flake, and a flake here would
|
||||
// mask exactly the regressions this suite exists to catch.
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
// Anti-aliasing differs slightly between runs; a handful of pixels is not
|
||||
// a regression. Anything the theme actually changed is far larger.
|
||||
toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled' },
|
||||
},
|
||||
reporter: [['list'], ['html', { outputFolder: '../../dist/visual-report', open: 'never' }]],
|
||||
|
||||
use: {
|
||||
trace: 'retain-on-failure',
|
||||
actionTimeout: 15_000,
|
||||
},
|
||||
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
|
||||
webServer: [
|
||||
{
|
||||
name: 'portal',
|
||||
command: `npx vite preview --config apps/portal/vite.config.mts --port ${PORTAL_PORT} --strictPort`,
|
||||
cwd: '../..',
|
||||
url: VISUAL.portalUrl,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
{
|
||||
name: 'backoffice',
|
||||
command: `npx vite preview --config apps/backoffice/vite.config.mts --port ${BACKOFFICE_PORT} --strictPort`,
|
||||
cwd: '../..',
|
||||
url: VISUAL.backofficeUrl,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
139
apps/e2e/visual/theme.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { VISUAL } from '../visual.config';
|
||||
|
||||
/**
|
||||
* Theme baselines.
|
||||
*
|
||||
* These exist so a change to the shared theme can be reviewed as a diff rather
|
||||
* than trusted. The gallery route renders every primitive the theme controls,
|
||||
* so one screenshot per app per scheme per width covers the whole surface.
|
||||
*
|
||||
* Update baselines deliberately, never reflexively:
|
||||
* npx playwright test -c apps/e2e/visual.config.ts --update-snapshots
|
||||
* A diff you did not intend is the entire point of the suite.
|
||||
*/
|
||||
|
||||
const WIDTHS = [
|
||||
{ name: 'desktop', width: 1440, height: 1200 },
|
||||
{ name: 'tablet', width: 768, height: 1200 },
|
||||
] as const;
|
||||
|
||||
const SCHEMES = ['light', 'dark'] as const;
|
||||
|
||||
const APPS = [
|
||||
{ name: 'backoffice', url: VISUAL.backofficeUrl },
|
||||
{ name: 'portal', url: VISUAL.portalUrl },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Set the scheme the way the app itself does — the pre-paint script in
|
||||
* index.html reads this key. Setting it before navigation means the very first
|
||||
* paint is already correct, so no screenshot catches a flash of the wrong one.
|
||||
*/
|
||||
async function gotoGallery(page: Page, baseUrl: string, scheme: string) {
|
||||
await page.addInitScript((value) => {
|
||||
window.localStorage.setItem('mantine-color-scheme-value', value);
|
||||
}, scheme);
|
||||
|
||||
await page.goto(`${baseUrl}/__gallery`, { waitUntil: 'networkidle' });
|
||||
|
||||
// The gallery is static, but web fonts are not: screenshotting before they
|
||||
// settle bakes a fallback-font baseline that every later run then fails
|
||||
// against.
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await expect(page.getByRole('heading', { name: 'Theme Gallery' })).toBeVisible();
|
||||
}
|
||||
|
||||
for (const app of APPS) {
|
||||
for (const scheme of SCHEMES) {
|
||||
for (const size of WIDTHS) {
|
||||
test(`${app.name} gallery — ${scheme} — ${size.name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: size.width, height: size.height });
|
||||
await gotoGallery(page, app.url, scheme);
|
||||
|
||||
await expect(page).toHaveScreenshot(
|
||||
`${app.name}-gallery-${scheme}-${size.name}.png`,
|
||||
{ fullPage: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The focus ring, captured while actually focused.
|
||||
*
|
||||
* The full-page shots above can't show this: nothing is focused in them, so
|
||||
* a regression that removed the ring entirely would leave them all green.
|
||||
* Keyboard focus specifically, because `:focus-visible` deliberately does
|
||||
* not match a mouse click.
|
||||
*/
|
||||
test(`${app.name} — focus ring is visible`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoGallery(page, app.url, 'light');
|
||||
|
||||
const section = page.locator('section, div').filter({ hasText: 'Focus states' }).last();
|
||||
await section.scrollIntoViewIfNeeded();
|
||||
|
||||
const button = page.getByRole('button', { name: 'Button', exact: true });
|
||||
await button.focus();
|
||||
await expect(button).toBeFocused();
|
||||
|
||||
// Assert the ring in computed styles as well as pixels. A screenshot alone
|
||||
// would still pass if the outline came from somewhere unintended, and a
|
||||
// token that failed to resolve leaves an empty string rather than an error.
|
||||
const ring = await button.evaluate((el) => {
|
||||
const s = getComputedStyle(el);
|
||||
return {
|
||||
width: s.outlineWidth,
|
||||
style: s.outlineStyle,
|
||||
token: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--ema-focus-ring')
|
||||
.trim(),
|
||||
};
|
||||
});
|
||||
expect(ring.style).toBe('solid');
|
||||
expect(ring.width).toBe('2px');
|
||||
expect(ring.token).not.toBe('');
|
||||
|
||||
await expect(button).toHaveScreenshot(`${app.name}-focus-ring.png`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The skip link, on a real app shell.
|
||||
*
|
||||
* Not on the gallery route: the point of a skip link is bypassing the nav, and
|
||||
* the gallery has none. The login page is the shell-less public route both apps
|
||||
* share, so this uses the landing route instead — it carries the chrome without
|
||||
* needing a session.
|
||||
*
|
||||
* A skip link is invisible until focused, which means a broken one and a
|
||||
* working one look identical in every screenshot. Only a focus test separates
|
||||
* them.
|
||||
*/
|
||||
test.describe('skip link', () => {
|
||||
for (const app of APPS) {
|
||||
test(`${app.name} — reveals on focus and targets main`, async ({ page }) => {
|
||||
await page.goto(`${app.url}/`, { waitUntil: 'networkidle' });
|
||||
|
||||
const link = page.locator('.ema-skip-link');
|
||||
if ((await link.count()) === 0) {
|
||||
// The public landing route does not mount the app shell in every app;
|
||||
// skipping is honest here, where asserting absence would be wrong.
|
||||
test.skip(true, 'landing route does not mount the app shell');
|
||||
return;
|
||||
}
|
||||
|
||||
// Off-screen until focused...
|
||||
await expect(link).not.toBeInViewport();
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(link).toBeFocused();
|
||||
await expect(link).toBeInViewport();
|
||||
|
||||
// ...and it must point at something that exists.
|
||||
const href = await link.getAttribute('href');
|
||||
expect(href).toBe('#ema-main-content');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 215 KiB |
@@ -1,3 +1,4 @@
|
||||
import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import {
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { StatusBadge, notify } from '@ema-platform/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -45,11 +46,11 @@ interface BSTRecord {
|
||||
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Valid: 'teal',
|
||||
Expiring: 'orange',
|
||||
Expired: 'red',
|
||||
'Pending Verification': 'yellow',
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
Valid: 'success',
|
||||
Expiring: 'pending',
|
||||
Expired: 'danger',
|
||||
'Pending Verification': 'warning',
|
||||
};
|
||||
|
||||
/** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */
|
||||
@@ -289,14 +290,13 @@ export function BasicSafetyTrainingPage() {
|
||||
</Text>
|
||||
</div>
|
||||
{record && (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[record.status]}
|
||||
label={record.status}
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[record.status]}
|
||||
leftSection={<IconShieldCheck size={14} />}
|
||||
>
|
||||
{record.status}
|
||||
</Badge>
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -321,7 +321,7 @@ export function BasicSafetyTrainingPage() {
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={48} radius="md" color={STATUS_COLOR[record.status]} variant="light">
|
||||
<ThemeIcon size={48} radius="md" color={STATUS_TONE_COLOR[STATUS_TONE[record.status]]} variant="light">
|
||||
<IconShieldCheck size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
@@ -329,9 +329,7 @@ export function BasicSafetyTrainingPage() {
|
||||
<Text fz="xs" c="dimmed">Combined certificate — all 5 STCW components</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[record.status]} variant="light">
|
||||
{record.status}
|
||||
</Badge>
|
||||
<StatusBadge tone={STATUS_TONE[record.status]} label={record.status} variant="light" />
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs" mb="md">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button, Card, Stack, Text, ThemeIcon, Title } from '@mantine/core';
|
||||
import { IconCircleCheck, IconClockPause } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { AttemptStatus } from '../types/exam-attempt';
|
||||
|
||||
/**
|
||||
* No score, no pass/fail, nothing evaluation-shaped — grading hasn't run.
|
||||
* This only confirms what actually happened: the candidate submitted, or
|
||||
* the deadline closed the attempt out first.
|
||||
*/
|
||||
export function ExamCompletion({
|
||||
status,
|
||||
submittedAt,
|
||||
}: {
|
||||
status: AttemptStatus;
|
||||
submittedAt: string | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const expired = status === 'EXPIRED';
|
||||
|
||||
return (
|
||||
<Stack maw={520} mx="auto" align="center" py="xl">
|
||||
<Card withBorder radius="lg" p="xl" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<ThemeIcon size={64} radius="xl" variant="light" color={expired ? 'orange' : 'teal'}>
|
||||
{expired ? <IconClockPause size={32} /> : <IconCircleCheck size={32} />}
|
||||
</ThemeIcon>
|
||||
<Title order={3} ta="center">
|
||||
{expired ? 'Time expired' : 'Exam submitted'}
|
||||
</Title>
|
||||
<Text ta="center" c="dimmed">
|
||||
{expired
|
||||
? 'The scheduled time ran out. Your saved answers were recorded as your final submission.'
|
||||
: 'Your answers have been recorded.'}
|
||||
{' '}Your result will appear on the Examinations page once marking, moderation and
|
||||
approval are complete — it is not available yet.
|
||||
</Text>
|
||||
{submittedAt && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{expired ? 'Closed' : 'Submitted'} at {new Date(submittedAt).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
<Button variant="light" onClick={() => navigate('/exams')}>
|
||||
Back to Examinations
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Alert, Badge, Button, Card, Group, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconAlertCircle, IconClock, IconInfoCircle, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
import type { EstimatedTime, RegistrationWithExam } from '../types/exam-attempt';
|
||||
|
||||
function formatDuration(time: EstimatedTime | null | undefined): string {
|
||||
if (!time) return 'Not configured';
|
||||
const parts = [
|
||||
time.days ? `${time.days}d` : null,
|
||||
time.hours ? `${time.hours}h` : null,
|
||||
time.minutes ? `${time.minutes}m` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : '0m';
|
||||
}
|
||||
|
||||
export function ExamInstructions({
|
||||
registration,
|
||||
localized,
|
||||
showDate,
|
||||
starting,
|
||||
onStart,
|
||||
}: {
|
||||
registration: RegistrationWithExam;
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
starting: boolean;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const exam = registration.exam;
|
||||
const canStart = exam?.status === 'ACTIVE';
|
||||
|
||||
return (
|
||||
<Stack maw={720} mx="auto" gap="md">
|
||||
<Title order={2}>{localized(exam?.title) || 'Examination'}</Title>
|
||||
<Text c="dimmed">{localized(exam?.certification?.name)}</Text>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Admission number</Text>
|
||||
<Text fz="sm" fw={600} ff="monospace">{registration.admissionNumber}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Session date</Text>
|
||||
<Text fz="sm">{showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Duration</Text>
|
||||
<Badge variant="light" leftSection={<IconClock size={12} />}>
|
||||
{formatDuration(exam?.givenTime)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Attempt</Text>
|
||||
<Badge variant="light" color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}>
|
||||
{registration.kind === 'RETAKE'
|
||||
? `Retake · sitting ${registration.attemptNumber}`
|
||||
: 'First sitting'}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{exam?.direction && localized(exam.direction) && (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light" title="Instructions">
|
||||
{localized(exam.direction)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light">
|
||||
Once started, the timer cannot be paused. Answers are saved automatically as you go, but
|
||||
the exam ends the moment the deadline passes, whether or not you have submitted.
|
||||
</Alert>
|
||||
|
||||
{!canStart && (
|
||||
<Alert color="gray" variant="light">
|
||||
This session is not currently open for candidates to begin.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="md"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
loading={starting}
|
||||
disabled={!canStart}
|
||||
onClick={onStart}
|
||||
>
|
||||
Start exam
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Badge, Button, Group, Paper, Radio, Stack, Text, Textarea } from '@mantine/core';
|
||||
import { IconAlertCircle, IconCheck, IconRefresh } from '@tabler/icons-react';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
import type { CandidateQuestion, SaveState } from '../types/exam-attempt';
|
||||
|
||||
function SaveIndicator({ state, onRetry }: { state: SaveState; onRetry: () => void }) {
|
||||
if (state === 'saving') {
|
||||
return <Text fz="xs" c="dimmed">Saving…</Text>;
|
||||
}
|
||||
if (state === 'saved') {
|
||||
return (
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal">Saved</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<Group gap={6}>
|
||||
<IconAlertCircle size={13} color="var(--mantine-color-red-6)" />
|
||||
<Text fz="xs" c="red">Not saved</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconRefresh size={12} />}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one question — never the answer key, because the API response
|
||||
* this reads from (`CandidateQuestion`/`CandidateOption`) has no such field
|
||||
* to render even by mistake.
|
||||
*/
|
||||
export function ExamQuestionDisplay({
|
||||
question,
|
||||
index,
|
||||
total,
|
||||
localized,
|
||||
selectedOptionId,
|
||||
answerText,
|
||||
saveState,
|
||||
disabled,
|
||||
onSelectOption,
|
||||
onChangeText,
|
||||
onRetry,
|
||||
}: {
|
||||
question: CandidateQuestion;
|
||||
index: number;
|
||||
total: number;
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
selectedOptionId: string | null | undefined;
|
||||
answerText: string | null | undefined;
|
||||
saveState: SaveState;
|
||||
disabled: boolean;
|
||||
onSelectOption: (optionId: string) => void;
|
||||
onChangeText: (text: string) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Badge variant="light" color="gray">
|
||||
Question {index + 1} of {total} · {question.points} pts
|
||||
</Badge>
|
||||
<SaveIndicator state={saveState} onRetry={onRetry} />
|
||||
</Group>
|
||||
|
||||
<Text fz="md" fw={500} mb="lg">
|
||||
{localized(question.title)}
|
||||
</Text>
|
||||
|
||||
{question.form === 'CHOICE' ? (
|
||||
<Radio.Group
|
||||
value={selectedOptionId ?? ''}
|
||||
onChange={onSelectOption}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{question.options
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((option) => (
|
||||
<Radio.Card
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
disabled={disabled}
|
||||
p="sm"
|
||||
radius="md"
|
||||
>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Radio.Indicator disabled={disabled} />
|
||||
<Text fz="sm">{localized(option.text)}</Text>
|
||||
</Group>
|
||||
</Radio.Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="Write your answer"
|
||||
minRows={8}
|
||||
autosize
|
||||
disabled={disabled}
|
||||
value={answerText ?? ''}
|
||||
onChange={(event) => onChangeText(event.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Paper, SimpleGrid, Text, UnstyledButton } from '@mantine/core';
|
||||
import type { CandidateQuestion } from '../types/exam-attempt';
|
||||
|
||||
export function ExamQuestionNav({
|
||||
questions,
|
||||
currentIndex,
|
||||
answeredIds,
|
||||
disabled,
|
||||
onJump,
|
||||
}: {
|
||||
questions: CandidateQuestion[];
|
||||
currentIndex: number;
|
||||
answeredIds: Set<string>;
|
||||
disabled: boolean;
|
||||
onJump: (index: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Text fz="xs" fw={600} c="dimmed" mb="xs" tt="uppercase">
|
||||
Questions
|
||||
</Text>
|
||||
<SimpleGrid cols={5} spacing={6}>
|
||||
{questions.map((q, index) => {
|
||||
const answered = answeredIds.has(q.id);
|
||||
const current = index === currentIndex;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={q.id}
|
||||
disabled={disabled}
|
||||
onClick={() => onJump(index)}
|
||||
style={{
|
||||
height: 34,
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
border: current ? '2px solid var(--mantine-color-blue-6)' : '1px solid var(--mantine-color-gray-4)',
|
||||
background: answered
|
||||
? 'var(--mantine-color-teal-1)'
|
||||
: 'var(--mantine-color-body)',
|
||||
color: answered ? 'var(--mantine-color-teal-8)' : undefined,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
<Text fz="xs" c="dimmed" mt="sm">
|
||||
{answeredIds.size} of {questions.length} answered
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Badge, Group } from '@mantine/core';
|
||||
import { IconClock } from '@tabler/icons-react';
|
||||
|
||||
function format(totalSeconds: number): string {
|
||||
const s = Math.max(0, totalSeconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display only. `remainingSeconds` is a local countdown seeded once from the
|
||||
* server's own clock (`AttemptSession.remainingSeconds`/`serverTime`) and
|
||||
* ticked down client-side — the deadline it represents is enforced by the
|
||||
* backend on every save/submit regardless of whether this number is right.
|
||||
*/
|
||||
export function ExamTimer({ remainingSeconds }: { remainingSeconds: number }) {
|
||||
const low = remainingSeconds <= 300; // 5 minutes
|
||||
return (
|
||||
<Group gap={6}>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={low ? 'red' : 'blue'}
|
||||
leftSection={<IconClock size={14} />}
|
||||
ff="monospace"
|
||||
>
|
||||
{format(remainingSeconds)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApiMutation, useApiQuery, extractErrorMessage } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import type {
|
||||
AttemptSession,
|
||||
CandidateAnswer,
|
||||
ExamAttempt,
|
||||
RegistrationWithExam,
|
||||
SaveState,
|
||||
} from '../types/exam-attempt';
|
||||
|
||||
const ESSAY_DEBOUNCE_MS = 1500;
|
||||
|
||||
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
|
||||
|
||||
type LocalAnswer = { selectedOptionId?: string | null; answerText?: string | null };
|
||||
|
||||
/**
|
||||
* All state and API orchestration for taking one exam. Kept out of the page
|
||||
* component so the component tree stays about rendering, not about save
|
||||
* timers and expiry races.
|
||||
*
|
||||
* Nothing here is a security boundary — every write still goes through the
|
||||
* backend's own ownership + `applyExpiry()` checks on every call. This hook
|
||||
* only decides what to show; a client that skipped straight to calling the
|
||||
* API directly would hit exactly the same server-side rules.
|
||||
*/
|
||||
export function useExamAttempt(examId: string | undefined) {
|
||||
const [session, setSession] = useState<AttemptSession | null>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, LocalAnswer>>({});
|
||||
const [saveStates, setSaveStates] = useState<Record<string, SaveState>>({});
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(0);
|
||||
|
||||
const answersRef = useRef(answers);
|
||||
answersRef.current = answers;
|
||||
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const seeded = useRef(false);
|
||||
|
||||
const {
|
||||
data: registrations,
|
||||
isLoading: loadingRegistrations,
|
||||
} = useApiQuery<RegistrationWithExam[]>({ url: '/exams/registrations/mine' });
|
||||
const registration = registrations?.find((r) => r.exam?.id === examId);
|
||||
|
||||
const {
|
||||
data: mineData,
|
||||
isLoading: loadingMine,
|
||||
isError: mineIsError,
|
||||
error: mineError,
|
||||
refetch: refetchMine,
|
||||
} = useApiQuery<AttemptSession>(
|
||||
{ url: `/exam-attempts/mine/${examId}` },
|
||||
{ skip: !examId },
|
||||
);
|
||||
|
||||
const [startTrigger, { isLoading: starting }] = useApiMutation<AttemptSession>();
|
||||
const [answerTrigger] = useApiMutation<CandidateAnswer>();
|
||||
const [submitTrigger, { isLoading: submitting }] = useApiMutation<ExamAttempt>();
|
||||
|
||||
const seedFrom = useCallback((data: AttemptSession) => {
|
||||
setSession(data);
|
||||
const map: Record<string, LocalAnswer> = {};
|
||||
for (const a of data.answers) {
|
||||
map[a.questionId] = { selectedOptionId: a.selectedOptionId, answerText: a.answerText };
|
||||
}
|
||||
setAnswers(map);
|
||||
setRemainingSeconds(data.remainingSeconds);
|
||||
setViewState(data.attempt.status === 'IN_PROGRESS' ? 'taking' : 'completed');
|
||||
}, []);
|
||||
|
||||
// Seed once from the initial load — after that, local state (ticking
|
||||
// timer, in-flight edits) is the source of truth, not this query.
|
||||
useEffect(() => {
|
||||
if (seeded.current) return;
|
||||
if (loadingRegistrations || loadingMine) return;
|
||||
seeded.current = true;
|
||||
|
||||
if (!registration) {
|
||||
setViewState('error');
|
||||
setErrorMessage('You are not registered for this examination.');
|
||||
return;
|
||||
}
|
||||
if (mineData) {
|
||||
seedFrom(mineData);
|
||||
return;
|
||||
}
|
||||
if (mineIsError) {
|
||||
const key = extractErrorMessage(mineError, '');
|
||||
if (key === 'attempt_not_found') {
|
||||
setViewState('not-started');
|
||||
return;
|
||||
}
|
||||
setViewState('error');
|
||||
setErrorMessage(extractErrorMessage(mineError, 'Could not load the exam.'));
|
||||
}
|
||||
}, [loadingRegistrations, loadingMine, registration, mineData, mineIsError, mineError, seedFrom]);
|
||||
|
||||
/** Authoritative resync — used after any write is refused as expired/submitted. */
|
||||
const syncFromServer = useCallback(async () => {
|
||||
const result = await refetchMine();
|
||||
if (result.data) {
|
||||
seedFrom(result.data as AttemptSession);
|
||||
} else {
|
||||
setViewState('error');
|
||||
setErrorMessage(extractErrorMessage(result.error, 'The exam session ended.'));
|
||||
}
|
||||
}, [refetchMine, seedFrom]);
|
||||
|
||||
const persistAnswer = useCallback(
|
||||
async (questionId: string, payload: LocalAnswer) => {
|
||||
if (!session) return;
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'saving' }));
|
||||
try {
|
||||
const saved = await answerTrigger({
|
||||
url: `/exam-attempts/${session.attempt.id}/answers`,
|
||||
method: 'POST',
|
||||
body: { questionId, ...payload },
|
||||
}).unwrap();
|
||||
setAnswers((a) => ({
|
||||
...a,
|
||||
[questionId]: { selectedOptionId: saved.selectedOptionId, answerText: saved.answerText },
|
||||
}));
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'saved' }));
|
||||
} catch (error) {
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'error' }));
|
||||
const key = extractErrorMessage(error, '');
|
||||
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
|
||||
notify.error(
|
||||
key === 'attempt_expired'
|
||||
? 'Time is up — this answer was not saved.'
|
||||
: 'This attempt has already been submitted.',
|
||||
);
|
||||
syncFromServer();
|
||||
}
|
||||
}
|
||||
},
|
||||
[session, answerTrigger, syncFromServer],
|
||||
);
|
||||
|
||||
const flush = useCallback(
|
||||
(questionId: string) => {
|
||||
const timer = debounceTimers.current[questionId];
|
||||
if (!timer) return;
|
||||
clearTimeout(timer);
|
||||
delete debounceTimers.current[questionId];
|
||||
const current = answersRef.current[questionId];
|
||||
if (current) persistAnswer(questionId, current);
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const selectOption = useCallback(
|
||||
(questionId: string, optionId: string) => {
|
||||
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], selectedOptionId: optionId } }));
|
||||
persistAnswer(questionId, { selectedOptionId: optionId });
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const changeText = useCallback(
|
||||
(questionId: string, text: string) => {
|
||||
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], answerText: text } }));
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'idle' }));
|
||||
clearTimeout(debounceTimers.current[questionId]);
|
||||
debounceTimers.current[questionId] = setTimeout(() => {
|
||||
delete debounceTimers.current[questionId];
|
||||
persistAnswer(questionId, { answerText: text });
|
||||
}, ESSAY_DEBOUNCE_MS);
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
const current = session?.questions[currentIndex];
|
||||
if (current) flush(current.id);
|
||||
setCurrentIndex(index);
|
||||
},
|
||||
[session, currentIndex, flush],
|
||||
);
|
||||
|
||||
const retry = useCallback(
|
||||
(questionId: string) => {
|
||||
const current = answersRef.current[questionId];
|
||||
if (current) persistAnswer(questionId, current);
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!examId) return;
|
||||
try {
|
||||
const result = await startTrigger({
|
||||
url: '/exam-attempts/start',
|
||||
method: 'POST',
|
||||
body: { examId },
|
||||
}).unwrap();
|
||||
seedFrom(result);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
|
||||
}
|
||||
}, [examId, startTrigger, seedFrom]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!session) return;
|
||||
const current = session.questions[currentIndex];
|
||||
if (current) flush(current.id);
|
||||
try {
|
||||
const attempt = await submitTrigger({
|
||||
url: `/exam-attempts/${session.attempt.id}/submit`,
|
||||
method: 'POST',
|
||||
}).unwrap();
|
||||
setSession((s) => (s ? { ...s, attempt } : s));
|
||||
setViewState('completed');
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, '');
|
||||
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
|
||||
syncFromServer();
|
||||
} else {
|
||||
notify.error(extractErrorMessage(error, 'Could not submit the exam.'));
|
||||
}
|
||||
}
|
||||
}, [session, currentIndex, flush, submitTrigger, syncFromServer]);
|
||||
|
||||
/** Local countdown only — every write is still checked server-side regardless. */
|
||||
useEffect(() => {
|
||||
if (viewState !== 'taking') return;
|
||||
const id = setInterval(() => {
|
||||
setRemainingSeconds((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(id);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [viewState]);
|
||||
|
||||
// Time reaching zero locally: stop taking input, tell the server, then
|
||||
// trust whatever it reports back over anything computed in the browser.
|
||||
const timedOutRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (viewState !== 'taking' || remainingSeconds > 0 || timedOutRef.current) return;
|
||||
timedOutRef.current = true;
|
||||
notify.error("Time's up.");
|
||||
// submit() itself resyncs from the server if this loses the race against
|
||||
// applyExpiry() — either way the final state comes from the backend, not
|
||||
// from this timer having reached zero.
|
||||
submit();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [remainingSeconds, viewState]);
|
||||
|
||||
const answeredIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
Object.entries(answers)
|
||||
.filter(([, v]) => v.selectedOptionId || v.answerText?.trim())
|
||||
.map(([id]) => id),
|
||||
),
|
||||
[answers],
|
||||
);
|
||||
|
||||
return {
|
||||
viewState,
|
||||
errorMessage,
|
||||
registration,
|
||||
session,
|
||||
currentIndex,
|
||||
answers,
|
||||
saveStates,
|
||||
answeredIds,
|
||||
remainingSeconds,
|
||||
starting,
|
||||
submitting,
|
||||
start,
|
||||
selectOption,
|
||||
changeText,
|
||||
goTo,
|
||||
retry,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Alert, Button, Center, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
||||
import { IconAlertCircle, IconSend } from '@tabler/icons-react';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { useExamAttempt } from '../../hooks/useExamAttempt';
|
||||
import { ExamInstructions } from '../../components/ExamInstructions';
|
||||
import { ExamTimer } from '../../components/ExamTimer';
|
||||
import { ExamQuestionNav } from '../../components/ExamQuestionNav';
|
||||
import { ExamQuestionDisplay } from '../../components/ExamQuestionDisplay';
|
||||
import { ExamCompletion } from '../../components/ExamCompletion';
|
||||
|
||||
/**
|
||||
* The candidate exam-taking screen (Phase 4). Route: `/exams/:examId/take`.
|
||||
*
|
||||
* All state/API orchestration lives in `useExamAttempt` — this component is
|
||||
* the view: pick which of loading/not-started/taking/completed/error to
|
||||
* render. Every write it triggers (start, save, submit) is re-checked by the
|
||||
* backend regardless of what this screen currently shows; nothing here is
|
||||
* the actual security boundary.
|
||||
*/
|
||||
export function ExamAttemptPage() {
|
||||
const { examId } = useParams<{ examId: string }>();
|
||||
const localized = useLocalized();
|
||||
const showDate = useDateDisplayer();
|
||||
const [confirmOpened, setConfirmOpened] = useState(false);
|
||||
|
||||
const {
|
||||
viewState,
|
||||
errorMessage,
|
||||
registration,
|
||||
session,
|
||||
currentIndex,
|
||||
answers,
|
||||
saveStates,
|
||||
answeredIds,
|
||||
remainingSeconds,
|
||||
starting,
|
||||
submitting,
|
||||
start,
|
||||
selectOption,
|
||||
changeText,
|
||||
goTo,
|
||||
retry,
|
||||
submit,
|
||||
} = useExamAttempt(examId);
|
||||
|
||||
if (viewState === 'loading') {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState === 'error') {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" maw={600} mx="auto" mt="xl">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState === 'not-started') {
|
||||
if (!registration) return null; // guarded by 'error' above, appeases TS
|
||||
return (
|
||||
<ExamInstructions
|
||||
registration={registration}
|
||||
localized={localized}
|
||||
showDate={showDate}
|
||||
starting={starting}
|
||||
onStart={start}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState === 'completed' && session) {
|
||||
return (
|
||||
<ExamCompletion status={session.attempt.status} submittedAt={session.attempt.submittedAt} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) return null; // 'taking' always has a session by construction
|
||||
|
||||
const question = session.questions[currentIndex];
|
||||
const answer = answers[question.id];
|
||||
|
||||
return (
|
||||
<Stack maw={1000} mx="auto" gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600}>{localized(registration?.exam?.title) || 'Examination in progress'}</Text>
|
||||
<ExamTimer remainingSeconds={remainingSeconds} />
|
||||
</Group>
|
||||
|
||||
<Group align="flex-start" gap="md" wrap="wrap-reverse">
|
||||
<div style={{ flex: 1, minWidth: 280 }}>
|
||||
<ExamQuestionDisplay
|
||||
question={question}
|
||||
index={currentIndex}
|
||||
total={session.questions.length}
|
||||
localized={localized}
|
||||
selectedOptionId={answer?.selectedOptionId}
|
||||
answerText={answer?.answerText}
|
||||
saveState={saveStates[question.id] ?? 'idle'}
|
||||
disabled={remainingSeconds <= 0}
|
||||
onSelectOption={(optionId) => selectOption(question.id, optionId)}
|
||||
onChangeText={(text) => changeText(question.id, text)}
|
||||
onRetry={() => retry(question.id)}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={currentIndex === 0}
|
||||
onClick={() => goTo(currentIndex - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
{currentIndex < session.questions.length - 1 ? (
|
||||
<Button onClick={() => goTo(currentIndex + 1)}>Next</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconSend size={15} />}
|
||||
onClick={() => setConfirmOpened(true)}
|
||||
>
|
||||
Submit exam
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 220, flexShrink: 0 }}>
|
||||
<ExamQuestionNav
|
||||
questions={session.questions}
|
||||
currentIndex={currentIndex}
|
||||
answeredIds={answeredIds}
|
||||
disabled={remainingSeconds <= 0}
|
||||
onJump={goTo}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
mt="sm"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<IconSend size={15} />}
|
||||
onClick={() => setConfirmOpened(true)}
|
||||
>
|
||||
Submit exam
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={confirmOpened}
|
||||
onClose={() => setConfirmOpened(false)}
|
||||
title="Submit this exam?"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
{answeredIds.size} of {session.questions.length} questions answered. Once submitted,
|
||||
answers cannot be changed.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setConfirmOpened(false)}>
|
||||
Keep working
|
||||
</Button>
|
||||
<Button
|
||||
color="teal"
|
||||
loading={submitting}
|
||||
onClick={async () => {
|
||||
await submit();
|
||||
setConfirmOpened(false);
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExamAttemptPage;
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
|
||||
export type AttemptStatus = 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
|
||||
export type QuestionForm = 'ESSAY' | 'CHOICE';
|
||||
|
||||
export interface CandidateOption {
|
||||
id: string;
|
||||
text: Bilingual;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Never carries a correct-answer flag — the API doesn't send one. */
|
||||
export interface CandidateQuestion {
|
||||
id: string;
|
||||
title: Bilingual;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
options: CandidateOption[];
|
||||
}
|
||||
|
||||
export interface ExamAttempt {
|
||||
id: string;
|
||||
examId: string;
|
||||
registrationId: string;
|
||||
status: AttemptStatus;
|
||||
startedAt: string;
|
||||
expiresAt: string;
|
||||
submittedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CandidateAnswer {
|
||||
id: string;
|
||||
attemptId: string;
|
||||
questionId: string;
|
||||
selectedOptionId: string | null;
|
||||
answerText: string | null;
|
||||
}
|
||||
|
||||
/** Response shape shared by POST /exam-attempts/start and GET .../mine/:examId. */
|
||||
export interface AttemptSession {
|
||||
attempt: ExamAttempt;
|
||||
questions: CandidateQuestion[];
|
||||
answers: CandidateAnswer[];
|
||||
serverTime: string;
|
||||
remainingSeconds: number;
|
||||
}
|
||||
|
||||
export type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
export interface EstimatedTime {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `GET /exams/registrations/mine`'s response this feature
|
||||
* reads — the endpoint returns the full raw exam/registration, this is
|
||||
* just this feature's own narrow view of it (matches the sibling `exams`
|
||||
* feature's pattern of each screen typing only what it uses).
|
||||
*/
|
||||
export interface RegistrationWithExam {
|
||||
id: string;
|
||||
admissionNumber: string;
|
||||
kind: 'NEW' | 'RETAKE';
|
||||
attemptNumber: number;
|
||||
attendanceStatus: string;
|
||||
exam?: {
|
||||
id: string;
|
||||
title: Bilingual;
|
||||
direction?: Bilingual;
|
||||
date: string;
|
||||
venue: string | null;
|
||||
status: string;
|
||||
givenTime: EstimatedTime | null;
|
||||
certification?: { name?: Bilingual };
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconFileText, IconGavel } from '@tabler/icons-react';
|
||||
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
@@ -20,6 +20,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
DISQUALIFIED: 'red',
|
||||
};
|
||||
|
||||
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
|
||||
|
||||
export function registrationColumns(
|
||||
t: TFunction,
|
||||
deps: {
|
||||
@@ -28,6 +30,7 @@ export function registrationColumns(
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
onDownloadSlip: (registration: MyRegistration) => void;
|
||||
onStartExam: (registration: MyRegistration) => void;
|
||||
},
|
||||
): AdvancedColumn<MyRegistration>[] {
|
||||
return [
|
||||
@@ -91,6 +94,44 @@ export function registrationColumns(
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
header: t('exams.columns.exam'),
|
||||
cell: ({ row }) => {
|
||||
const exam = row.original.exam;
|
||||
const attemptStatus = row.original.attempt?.status;
|
||||
// Already finished — no restart, no more room for "Take exam" to
|
||||
// invite a click that the backend would just refuse.
|
||||
if (attemptStatus === 'SUBMITTED') {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
{t('exams.columns.completed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (attemptStatus === 'EXPIRED') {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
{t('exams.columns.timeExpired')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const eligible =
|
||||
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
|
||||
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconPlayerPlay size={13} />}
|
||||
onClick={() => deps.onStartExam(row.original)}
|
||||
>
|
||||
{attemptStatus === 'IN_PROGRESS'
|
||||
? t('exams.columns.resumeExam')
|
||||
: t('exams.columns.takeExam')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -53,6 +54,8 @@ export interface MyRegistration {
|
||||
attemptNumber: number;
|
||||
attendanceStatus: AttendanceStatus;
|
||||
exam?: OpenExam;
|
||||
/** The candidate's online sitting, when one has been started. */
|
||||
attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
||||
}
|
||||
|
||||
export interface MyResult {
|
||||
@@ -80,6 +83,7 @@ export interface MyAppeal {
|
||||
*/
|
||||
export function ExamsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
||||
@@ -245,6 +249,7 @@ export function ExamsPage() {
|
||||
localized,
|
||||
showDate,
|
||||
onDownloadSlip: downloadSlip,
|
||||
onStartExam: (registration) => navigate(`/exams/${registration.exam?.id}/take`),
|
||||
})}
|
||||
data={pagedRegistrations.rows}
|
||||
itemCount={pagedRegistrations.itemCount}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function IdentityDetailsStep(
|
||||
);
|
||||
}
|
||||
|
||||
/** Step 2 — Identity, Address, Physical Characteristics, Medical Certificate. */
|
||||
/** Step 2 — Identity, Address and Physical Characteristics. */
|
||||
export function ApplicantDetailsStep(p: StepProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -138,6 +138,29 @@ export function ApplicantDetailsStep(p: StepProps) {
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4 — Emergency Contact and Medical Certificate.
|
||||
*
|
||||
* The medical certificate used to sit at the bottom of Applicant Details,
|
||||
* which made that step 17 fields across four sections while this one held
|
||||
* three. Both are short, unrelated-to-identity, and copied off a document in
|
||||
* hand rather than recalled — so they pair here and the wizard's longest step
|
||||
* drops by a third.
|
||||
*/
|
||||
export function EmergencyContactStep(p: StepProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
|
||||
<Grid>
|
||||
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
|
||||
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
|
||||
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
|
||||
</Grid>
|
||||
|
||||
<Divider />
|
||||
<SectionTitle
|
||||
title="Medical Certificate"
|
||||
@@ -157,17 +180,3 @@ export function ApplicantDetailsStep(p: StepProps) {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Step 3 — Emergency Contact. */
|
||||
export function EmergencyContactStep(p: StepProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
|
||||
<Grid>
|
||||
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
|
||||
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
|
||||
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
@@ -20,7 +19,7 @@ import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
PHYSICAL_BOUNDS,
|
||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||
SEAFARER_REGISTRATION_STATUS_COLORS,
|
||||
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
@@ -33,7 +32,7 @@ import {
|
||||
type SeafarerRegistration,
|
||||
type ValidationIssue,
|
||||
} from '@ema-platform/api';
|
||||
import { splitPersonName } from '@ema-platform/ui';
|
||||
import { splitPersonName, StatusBadge } from '@ema-platform/ui';
|
||||
import { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { CheckboxField, type AnswerKey } from '../components/fields';
|
||||
@@ -41,16 +40,19 @@ import { ApplicantDetailsStep, EmergencyContactStep, IdentityDetailsStep } from
|
||||
import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments';
|
||||
import { RegistrationSummary } from '../components/RegistrationSummary';
|
||||
|
||||
const STEPS = ['Identity Details', 'Applicant Details', 'Emergency Contact', 'Documents', 'Review'];
|
||||
const STEPS = [
|
||||
{ label: 'Identity', description: 'Who you are' },
|
||||
{ label: 'Details', description: 'Address & physical' },
|
||||
{ label: 'Contact & Medical', description: 'Emergency & fitness' },
|
||||
{ label: 'Documents', description: 'Upload evidence' },
|
||||
{ label: 'Review', description: 'Check & submit' },
|
||||
];
|
||||
|
||||
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
|
||||
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
|
||||
[
|
||||
'placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg',
|
||||
'medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate',
|
||||
],
|
||||
[],
|
||||
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
||||
[],
|
||||
['declarationAccepted'],
|
||||
];
|
||||
@@ -123,7 +125,7 @@ export function SeafarerRegistrationPage() {
|
||||
const registration = data?.registration ?? null;
|
||||
|
||||
const [start] = useStartSeafarerRegistrationMutation();
|
||||
const [save] = useSaveSeafarerRegistrationMutation();
|
||||
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
|
||||
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
const started = useRef(false);
|
||||
@@ -215,12 +217,16 @@ export function SeafarerRegistrationPage() {
|
||||
}
|
||||
}
|
||||
setErrors(found);
|
||||
const count = Object.keys(found).length;
|
||||
if (count) {
|
||||
const missingKeys = Object.keys(found) as AnswerKey[];
|
||||
if (missingKeys.length) {
|
||||
// Name the fields rather than counting them. "Complete 3 required fields"
|
||||
// sends the applicant hunting up a step they have already scrolled past;
|
||||
// the labels are what let them go straight to it.
|
||||
const names = missingKeys.map((k) => SEAFARER_REGISTRATION_FIELD_LABELS[k]);
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Incomplete',
|
||||
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
|
||||
title: missingKeys.length > 1 ? 'Some details are missing' : 'One detail is missing',
|
||||
message: `${names.join(', ')}.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -307,9 +313,10 @@ export function SeafarerRegistrationPage() {
|
||||
<Text size="sm" c="dimmed">
|
||||
{registration.registrationNumber}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
|
||||
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
|
||||
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
{showSummary && !readOnly && (
|
||||
@@ -362,8 +369,8 @@ export function SeafarerRegistrationPage() {
|
||||
{!showSummary && (
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
|
||||
{STEPS.map((label) => (
|
||||
<Stepper.Step key={label} label={label} />
|
||||
{STEPS.map((step) => (
|
||||
<Stepper.Step key={step.label} label={step.label} description={step.description} />
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
@@ -408,9 +415,11 @@ export function SeafarerRegistrationPage() {
|
||||
Back
|
||||
</Button>
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button onClick={() => goToStep(active + 1)}>Continue</Button>
|
||||
<Button loading={saving} onClick={() => goToStep(active + 1)}>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="teal" loading={submitting} disabled={readOnly} onClick={handleSubmit}>
|
||||
<Button color="teal" loading={saving || submitting} disabled={readOnly} onClick={handleSubmit}>
|
||||
{isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Badge, Group, Text, Tooltip } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import { seaServiceDays, type MedicalCertificate, type SeaServiceRecord } from '@ema-platform/api';
|
||||
|
||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: 'blue',
|
||||
VERIFIED: 'green',
|
||||
REJECTED: 'red',
|
||||
const RECORD_STATUS_TONES: Record<string, StatusTone> = {
|
||||
SUBMITTED: 'info',
|
||||
VERIFIED: 'success',
|
||||
REJECTED: 'danger',
|
||||
};
|
||||
|
||||
export function fitnessOptions(t: TFunction) {
|
||||
@@ -78,11 +80,12 @@ export function seaServiceColumns(
|
||||
label={row.original.verificationRemark ?? ''}
|
||||
disabled={!row.original.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
|
||||
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
|
||||
<StatusBadge
|
||||
tone={RECORD_STATUS_TONES[row.original.status]}
|
||||
label={t(`seaRecords.columns.recordStatus.${row.original.status}`, {
|
||||
defaultValue: row.original.status,
|
||||
})}
|
||||
</Badge>
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
@@ -140,11 +143,12 @@ export function medicalColumns(
|
||||
label={row.original.verificationRemark ?? ''}
|
||||
disabled={!row.original.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
|
||||
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
|
||||
<StatusBadge
|
||||
tone={RECORD_STATUS_TONES[row.original.status]}
|
||||
label={t(`seaRecords.columns.recordStatus.${row.original.status}`, {
|
||||
defaultValue: row.original.status,
|
||||
})}
|
||||
</Badge>
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -45,7 +46,7 @@ import {
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { StatusBadge, notify } from '@ema-platform/ui';
|
||||
import type { Seafarer } from './SeafarerRegistryPage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -170,14 +171,14 @@ async function updateSeafarerStatus(_id: string, _status: string): Promise<void>
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Active: 'teal', Pending: 'yellow', Suspended: 'red',
|
||||
Approved: 'teal', Expired: 'red', Valid: 'teal',
|
||||
Fit: 'teal', Unfit: 'red', Conditional: 'orange',
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
Active: 'success', Pending: 'warning', Suspended: 'danger',
|
||||
Approved: 'success', Expired: 'danger', Valid: 'success',
|
||||
Fit: 'success', Unfit: 'danger', Conditional: 'pending',
|
||||
};
|
||||
|
||||
function Chip({ value }: { value: string }) {
|
||||
return <Badge color={STATUS_COLOR[value] ?? 'gray'} variant="light" radius="sm" size="sm">{value}</Badge>;
|
||||
return <StatusBadge tone={STATUS_TONE[value] ?? 'neutral'} label={value} variant="light" radius="sm" size="sm" />;
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value: string }) {
|
||||
@@ -639,7 +640,12 @@ export function SeafarerProfilePage() {
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Badge color={STATUS_COLOR[profile.status] ?? 'gray'} variant="filled" radius="sm">{profile.status}</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[profile.status] ?? 'neutral'}
|
||||
label={profile.status}
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
/>
|
||||
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
|
||||
Edit Profile
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { StatusBadge, notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -163,26 +163,17 @@ function StatCard({
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status badges
|
||||
// ---------------------------------------------------------------------------
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Active: 'teal',
|
||||
Pending: 'yellow',
|
||||
Suspended: 'red',
|
||||
Expired: 'orange',
|
||||
Fit: 'teal',
|
||||
Unfit: 'red',
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
Active: 'success',
|
||||
Pending: 'warning',
|
||||
Suspended: 'danger',
|
||||
Expired: 'pending',
|
||||
Fit: 'success',
|
||||
Unfit: 'danger',
|
||||
};
|
||||
|
||||
function StatusBadge({ value }: { value: string }) {
|
||||
return (
|
||||
<Badge
|
||||
color={STATUS_COLOR[value] ?? 'gray'}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
size="sm"
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
function RegistryStatus({ value }: { value: string }) {
|
||||
return <StatusBadge tone={STATUS_TONE[value] ?? 'neutral'} label={value} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -239,9 +230,9 @@ export function SeafarerRegistryPage() {
|
||||
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
|
||||
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
|
||||
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
|
||||
<Table.Td><StatusBadge value={s.status} /></Table.Td>
|
||||
<Table.Td><RegistryStatus value={s.medicalStatus} /></Table.Td>
|
||||
<Table.Td><RegistryStatus value={s.bookStatus} /></Table.Td>
|
||||
<Table.Td><RegistryStatus value={s.status} /></Table.Td>
|
||||
<Table.Td>
|
||||
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
|
||||
<Menu.Target>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
IconTransferIn,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, PhoneInput } from '@ema-platform/ui';
|
||||
import { StatusBadge, notify, PhoneInput } from '@ema-platform/ui';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
// Minimal vessel type for the approved vessel list
|
||||
@@ -117,11 +117,11 @@ const TRANSFER_REASONS = [
|
||||
'Other',
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
Pending: 'neutral',
|
||||
'Under Review': 'warning',
|
||||
Approved: 'success',
|
||||
Rejected: 'danger',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -140,7 +140,11 @@ function TransferCard({ req }: { req: OwnershipTransferRequest }) {
|
||||
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[req.status] ?? 'neutral'}
|
||||
label={req.status}
|
||||
variant="light"
|
||||
/>
|
||||
</Group>
|
||||
<Divider my="xs" />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
IconClockHour4,
|
||||
IconTransferIn,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable } from '@ema-platform/ui';
|
||||
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
|
||||
import { inFlightColumns } from '../inFlightColumns';
|
||||
import {
|
||||
TERMINAL_STATUSES,
|
||||
@@ -68,12 +68,12 @@ interface VesselRegistration {
|
||||
expiryDate: string | null;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
'Correction Required': 'orange',
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
Pending: 'neutral',
|
||||
'Under Review': 'warning',
|
||||
Approved: 'success',
|
||||
Rejected: 'danger',
|
||||
'Correction Required': 'pending',
|
||||
};
|
||||
|
||||
// Inland vessel certificates (1)
|
||||
@@ -281,9 +281,12 @@ export function VesselRegistrationPage() {
|
||||
<Text fz="xs" c="dimmed">{registration.id}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[registration.status] ?? 'gray'} size="lg" variant="light">
|
||||
{registration.status}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[registration.status] ?? 'neutral'}
|
||||
label={registration.status}
|
||||
size="lg"
|
||||
variant="light"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -30,10 +32,10 @@ import {
|
||||
|
||||
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
|
||||
|
||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
||||
REGISTERED: 'green',
|
||||
SUSPENDED: 'orange',
|
||||
DEREGISTERED: 'gray',
|
||||
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
|
||||
REGISTERED: 'success',
|
||||
SUSPENDED: 'pending',
|
||||
DEREGISTERED: 'neutral',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -194,12 +196,11 @@ export function VesselTransferPage() {
|
||||
{categoryLabels[vessel.category] ?? vessel.category}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={VESSEL_STATUS_TONES[vessel.status]}
|
||||
label={vessel.status}
|
||||
size="sm"
|
||||
color={VESSEL_STATUS_COLORS[vessel.status]}
|
||||
>
|
||||
{vessel.status}
|
||||
</Badge>
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="flex-end">
|
||||
|
||||
@@ -10,6 +10,10 @@ export const am: Translations = {
|
||||
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
||||
},
|
||||
|
||||
a11y: {
|
||||
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
||||
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
||||
@@ -969,6 +973,11 @@ export const am: Translations = {
|
||||
appeal: 'ይግባኝ',
|
||||
retake: 'ድጋሚ · {{n}}',
|
||||
firstSitting: 'የመጀመሪያ ሙከራ',
|
||||
exam: 'ፈተና',
|
||||
completed: 'ተጠናቋል',
|
||||
timeExpired: 'ጊዜው አልቋል',
|
||||
resumeExam: 'ፈተና ይቀጥሉ',
|
||||
takeExam: 'ፈተና ይውሰዱ',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'አልተጠራም',
|
||||
PRESENT: 'ተገኝቷል',
|
||||
|
||||
@@ -9,6 +9,11 @@ export const en = {
|
||||
tagline: 'Maritime licensing & certification services',
|
||||
},
|
||||
|
||||
// Strings only assistive technology encounters.
|
||||
a11y: {
|
||||
skipToContent: 'Skip to main content',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
serverError: 'Server error. Please try again later.',
|
||||
@@ -970,6 +975,11 @@ export const en = {
|
||||
appeal: 'Appeal',
|
||||
retake: 'Retake · {{n}}',
|
||||
firstSitting: 'First sitting',
|
||||
exam: 'Exam',
|
||||
completed: 'Completed',
|
||||
timeExpired: 'Time expired',
|
||||
resumeExam: 'Resume exam',
|
||||
takeExam: 'Take exam',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'Not called',
|
||||
PRESENT: 'Present',
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
AppHeader,
|
||||
AppSidebar,
|
||||
filterByPermissions,
|
||||
SkipLink,
|
||||
MAIN_CONTENT_ID,
|
||||
} from "@ema-platform/ui";
|
||||
import type { NavItem } from "@ema-platform/ui";
|
||||
import {
|
||||
@@ -281,6 +283,10 @@ export function PortalLayout() {
|
||||
: "?";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* First focusable element on the page, so a keyboard user can bypass
|
||||
the nav instead of tabbing through it on every navigation. */}
|
||||
<SkipLink />
|
||||
<AppShell
|
||||
header={{ height: 74 }}
|
||||
navbar={{
|
||||
@@ -335,7 +341,7 @@ export function PortalLayout() {
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<AppShell.Main id={MAIN_CONTENT_ID}>
|
||||
<div key={location.pathname} className="ema-page-enter">
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -364,5 +370,6 @@ export function PortalLayout() {
|
||||
/>
|
||||
</Drawer>
|
||||
</AppShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||
import { ThemeGallery } from "@ema-platform/ui";
|
||||
import { PortalLayout } from "./layouts/PortalLayout";
|
||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
import { LandingRoute } from "./components/LandingRoute";
|
||||
@@ -29,6 +30,7 @@ import { SupportPage } from "./features/support/pages/SupportPage";
|
||||
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
|
||||
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
||||
|
||||
// Phase 1 pages
|
||||
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||
@@ -57,6 +59,10 @@ export const router = createBrowserRouter([
|
||||
// Public landing page — institutional overview + role-based entry points.
|
||||
{ path: "/", element: <LandingRoute /> },
|
||||
|
||||
// Theme visual-regression surface. Unauthenticated by design — it renders
|
||||
// only static primitives, so it needs no API and cannot flake.
|
||||
{ path: "/__gallery", element: <ThemeGallery /> },
|
||||
|
||||
// Public auth pages
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/signup", element: <SignupPage /> },
|
||||
@@ -208,6 +214,14 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/exams/:examId/take",
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.APPLY_EXAM, P.VIEW_OWN_EXAM]}>
|
||||
<ExamAttemptPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
// The public-facing registry was a hardcoded mock and does not belong in
|
||||
// the applicant portal; officers browse seafarers in the backoffice.
|
||||
{
|
||||
|
||||
@@ -1,12 +1,57 @@
|
||||
/* Portal global styles — loaded after Mantine's CSS, no Tailwind preflight so
|
||||
it never fights Mantine's base styles. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
|
||||
Without it every Amharic string in the app renders in whatever the OS
|
||||
happens to substitute — different on Windows, macOS and Android. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--ema-surface-light: #f5f8fc;
|
||||
--ema-surface-dark: #0e1521;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
|
||||
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
|
||||
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
|
||||
come from Mantine's dark palette so they track the active color scheme
|
||||
instead of a fixed gray.
|
||||
|
||||
These lived in `apps/portal/src/styles.css`, which nothing ever imported —
|
||||
so the portal has been running with unthemed scrollbars while the backoffice
|
||||
had these. Moved here, where they load.
|
||||
--------------------------------------------------------------------------- */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--mantine-color-gray-5) transparent;
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] * {
|
||||
scrollbar-color: var(--mantine-color-dark-3) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-dark-2);
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
|
||||
@@ -1,116 +1,13 @@
|
||||
import {
|
||||
createTheme,
|
||||
rem,
|
||||
type MantineColorsTuple,
|
||||
} from '@mantine/core';
|
||||
|
||||
// ---- Coastal Modern palette ----------------------------------------------
|
||||
// Portal-only theme. Lives here (not in @ema-platform/shared) so the backoffice
|
||||
// is unaffected.
|
||||
|
||||
const emaPrimary: MantineColorsTuple = [
|
||||
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
|
||||
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
|
||||
];
|
||||
|
||||
// Teal accent — the "coastal" half of the palette.
|
||||
const emaTeal: MantineColorsTuple = [
|
||||
'#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
|
||||
'#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
|
||||
];
|
||||
|
||||
// Cool neutral grays (slightly blue-tinted) for surfaces & text.
|
||||
const emaGray: MantineColorsTuple = [
|
||||
'#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
|
||||
'#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
|
||||
];
|
||||
|
||||
export const portalTheme = createTheme({
|
||||
primaryColor: 'emaPrimary',
|
||||
primaryShade: { light: 6, dark: 5 },
|
||||
colors: {
|
||||
emaPrimary,
|
||||
emaTeal,
|
||||
gray: emaGray,
|
||||
},
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
headings: {
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: '700',
|
||||
sizes: {
|
||||
h1: { fontSize: rem(32), lineHeight: '1.25' },
|
||||
h2: { fontSize: rem(25), lineHeight: '1.3' },
|
||||
h3: { fontSize: rem(21), lineHeight: '1.35' },
|
||||
h4: { fontSize: rem(17), lineHeight: '1.4' },
|
||||
h5: { fontSize: rem(15), lineHeight: '1.45' },
|
||||
},
|
||||
},
|
||||
defaultRadius: 'md',
|
||||
radius: {
|
||||
xs: rem(6),
|
||||
sm: rem(8),
|
||||
md: rem(12),
|
||||
lg: rem(16),
|
||||
xl: rem(22),
|
||||
},
|
||||
shadows: {
|
||||
xs: '0 1px 2px rgba(15,23,42,0.06)',
|
||||
sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
|
||||
md: '0 8px 24px rgba(15,23,42,0.08)',
|
||||
lg: '0 16px 40px rgba(15,23,42,0.12)',
|
||||
xl: '0 24px 64px rgba(15,23,42,0.16)',
|
||||
},
|
||||
breakpoints: {
|
||||
xs: '36em',
|
||||
sm: '48em',
|
||||
md: '62em',
|
||||
lg: '75em',
|
||||
xl: '88em',
|
||||
},
|
||||
cursorType: 'pointer',
|
||||
components: {
|
||||
Paper: {
|
||||
defaultProps: { radius: 'lg' },
|
||||
},
|
||||
Card: {
|
||||
defaultProps: { radius: 'lg' },
|
||||
},
|
||||
Button: {
|
||||
defaultProps: { radius: 'md' },
|
||||
styles: { root: { fontWeight: 600 } },
|
||||
},
|
||||
Badge: {
|
||||
defaultProps: { radius: 'sm' },
|
||||
},
|
||||
ThemeIcon: {
|
||||
defaultProps: { radius: 'md' },
|
||||
},
|
||||
NavLink: {
|
||||
styles: { root: { borderRadius: rem(10), fontWeight: 500 } },
|
||||
},
|
||||
TextInput: { defaultProps: { radius: 'md' } },
|
||||
Textarea: { defaultProps: { radius: 'md' } },
|
||||
Select: { defaultProps: { radius: 'md' } },
|
||||
PasswordInput: { defaultProps: { radius: 'md' } },
|
||||
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
|
||||
// max-height it's handed unless scrollAreaComponent is set, so a modal
|
||||
// taller than the viewport just gets clipped with no way to scroll it.
|
||||
// Making the body the scrollport here fixes every Modal/Drawer at once.
|
||||
Modal: {
|
||||
styles: {
|
||||
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
|
||||
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
||||
},
|
||||
},
|
||||
Drawer: {
|
||||
styles: {
|
||||
content: { display: 'flex', flexDirection: 'column' },
|
||||
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
||||
},
|
||||
},
|
||||
},
|
||||
other: {
|
||||
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
|
||||
},
|
||||
});
|
||||
/**
|
||||
* The portal theme now lives in `@ema-platform/shared`, alongside the
|
||||
* backoffice theme and the base they share.
|
||||
*
|
||||
* It moved because the two themes had diverged into unrelated definitions —
|
||||
* this one carried a full type scale, radius scale and component defaults that
|
||||
* the backoffice simply lacked. Sharing the structure fixes the backoffice
|
||||
* without changing the portal.
|
||||
*
|
||||
* This re-export is kept so the portal's MantineThemeProvider import stays
|
||||
* valid. Prefer importing from `@ema-platform/shared` directly in new code.
|
||||
*/
|
||||
export { portalTheme } from '@ema-platform/shared';
|
||||
|
||||
@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
// After Mantine's CSS (it defines the variables these tokens resolve to),
|
||||
// before the app's own, which may override them. Relative because the
|
||||
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
|
||||
import '../../../libs/shared/src/lib/theme/semantic.css';
|
||||
import './app/theme/portal.css';
|
||||
|
||||
import './app/i18n/config';
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
|
||||
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
|
||||
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
|
||||
come from Mantine's dark palette so they track the active color scheme
|
||||
instead of a fixed gray.
|
||||
--------------------------------------------------------------------------- */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--mantine-color-gray-5) transparent;
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] * {
|
||||
scrollbar-color: var(--mantine-color-dark-3) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-dark-2);
|
||||
}
|
||||
468
ema-exam-gap-analysis.md
Normal file
@@ -0,0 +1,468 @@
|
||||
# EMA Exam System — Combined Gap Analysis
|
||||
|
||||
**Repos:** `/home/tria/projects/mihretu/emaui` (frontend, branch `dev`) · `/home/tria/projects/mihretu/emaapi` (backend, branch `dev`). Read-only — no files modified, nothing implemented, nothing committed, no branch created.
|
||||
|
||||
**The user story, as directed:** this task's own exam-lifecycle checklist (§5 of the brief — Exam Administration, Question Management, Candidate Management, Candidate Exam-Taking, Evaluation, Post-Exam) is treated as the requirements source, flattened below into 54 individually numbered, non-combined requirements. Every status below is traced to actual file:line evidence gathered from a full read of the relevant frontend and backend source — not inferred from documentation, comments, or the presence of an endpoint alone.
|
||||
|
||||
---
|
||||
|
||||
## 1. The User Story — Numbered Requirements
|
||||
|
||||
### Exam Administration
|
||||
1. Create exam
|
||||
2. Configure exam (type/form/administration/evaluation/pass-mark/selection method as one settings unit)
|
||||
3. Define exam type
|
||||
4. Define subjects
|
||||
5. Define sections
|
||||
6. Configure duration
|
||||
7. Configure passing score
|
||||
8. Configure attempts
|
||||
9. Configure exam status
|
||||
10. Configure availability
|
||||
|
||||
### Question Management
|
||||
11. Create question
|
||||
12. Edit question
|
||||
13. Delete question
|
||||
14. Question bank
|
||||
15. Question types
|
||||
16. MCQ options
|
||||
17. Correct answers
|
||||
18. Question scoring
|
||||
19. Question ordering
|
||||
20. Random questions
|
||||
21. Paper assembly
|
||||
|
||||
### Candidate Management
|
||||
22. Candidate registration
|
||||
23. Candidate eligibility
|
||||
24. Candidate assignment
|
||||
25. Attendance
|
||||
26. Candidate status
|
||||
|
||||
### Candidate Exam-Taking
|
||||
27. Exam instructions
|
||||
28. Start exam
|
||||
29. Create attempt/session
|
||||
30. Timer
|
||||
31. Question navigation
|
||||
32. Display questions
|
||||
33. Select answers
|
||||
34. Save answers
|
||||
35. Auto-save
|
||||
36. Resume attempt
|
||||
37. Submit exam
|
||||
38. Prevent submission after expiration
|
||||
39. Prevent unauthorized access
|
||||
|
||||
### Evaluation
|
||||
40. Automatic grading
|
||||
41. Manual grading
|
||||
42. Score calculation
|
||||
43. Pass/fail
|
||||
44. Result generation
|
||||
45. Result review
|
||||
46. Result approval
|
||||
47. Result publication
|
||||
|
||||
### Post-Exam
|
||||
48. Result viewing
|
||||
49. Appeals
|
||||
50. Appeal review
|
||||
51. Retakes
|
||||
52. Exam history
|
||||
53. Notifications
|
||||
54. Certificates, if required
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend Comparison
|
||||
|
||||
| # | Requirement | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | Create exam | ✅ | `apps/backoffice/.../exam/pages/ExamPage.tsx` — full create form → `useCreateExamMutation` |
|
||||
| 2 | Configure exam | ✅ | Same form, Settings tab: type/form/administration/evaluation/selection method/cutting point/status |
|
||||
| 3 | Define exam type | ✅ | `ExamPage.tsx` — WRITTEN/ORAL `Select`, required |
|
||||
| 4 | Define subjects | ✅ | `ExamPage.tsx` — certification `Select`; full CRUD at `certification/pages/CertificationPage.tsx` |
|
||||
| 5 | Define sections | 🔴 | No component, field, or route anywhere in the exam feature tree |
|
||||
| 6 | Configure duration | ✅ | `ExamPage.tsx` — days/hours/minutes inputs, captured on create/edit |
|
||||
| 7 | Configure passing score | ✅ | `ExamPage.tsx` — cutting-point `NumberInput`, required |
|
||||
| 8 | Configure attempts | 🟡 | No admin-facing control anywhere — attempt/retake is read-only display in the portal (`ExamsPage.tsx` badge), never a setting an officer configures |
|
||||
| 9 | Configure exam status | ✅ | `ExamPage.tsx` — status `Select`, shown only when editing |
|
||||
| 10 | Configure availability | 🟡 | No explicit "availability" control found; portal's `GET /exams/open` list is consumed as-is with no frontend criteria of its own |
|
||||
| 11 | Create question | ✅ | `question/pages/QuestionPage.tsx` form → `useCreateQuestionMutation` |
|
||||
| 12 | Edit question | ✅ | Same form → `useUpdateQuestionMutation` |
|
||||
| 13 | Delete question | ✅ | Delete button + confirm modal → `useDeleteQuestionMutation` |
|
||||
| 14 | Question bank | ✅ | `QuestionPage.tsx` list, filterable by certification |
|
||||
| 15 | Question types | 🟡 | Only two labels selectable: ESSAY / CHOICE (`QuestionForm` type in `question/types/question.ts`) |
|
||||
| 16 | MCQ options | 🔴 | No `options`/`choices` field anywhere in `question.ts`, the create/edit form, or any component |
|
||||
| 17 | Correct answers | 🔴 | No `correctAnswer`/answer-key field or control anywhere |
|
||||
| 18 | Question scoring | ✅ | `points` field on the question form; bounded score entry in `RecordResultModal.tsx` |
|
||||
| 19 | Question ordering | ⚠️ | No ordering UI found; not confirmed whether the assignment list has any inherent order |
|
||||
| 20 | Random questions | ✅ | `exam/components/QuestionAssigner.tsx` (random mode) → `useSelectRandomQuestionsMutation` |
|
||||
| 21 | Paper assembly | ✅ | `QuestionAssigner.tsx` (manual mode) → `useAssignQuestionsMutation` |
|
||||
| 22 | Candidate registration | ✅ | Portal `exams/pages/ExamsPage.tsx` → register button → mutation to `/exams/:id/register` |
|
||||
| 23 | Candidate eligibility | ✅ | Same page — maps `seafarer_registration_required`/`subject_already_passed` server errors to readable copy before/after attempting registration |
|
||||
| 24 | Candidate assignment | 🔴 | `exam/components/ExamCandidatesPanel.tsx` can only view + mark attendance; no add/remove-candidate control anywhere in the backoffice |
|
||||
| 25 | Attendance | ✅ | `ExamCandidatesPanel.tsx` — per-candidate + bulk marking, required remark on Withdrawn/Disqualified |
|
||||
| 26 | Candidate status | ✅ | Same panel — attendance-status badges (Registered/Present/Absent/Late/Withdrawn/Disqualified) |
|
||||
| 27 | Exam instructions | 🟡 | Bilingual `direction` field captured on the exam form; no screen anywhere displays it to a candidate |
|
||||
| 28 | Start exam | 🔴 | No route, component, button, or handler anywhere in `apps/portal` |
|
||||
| 29 | Create attempt/session | 🔴 | No concept anywhere — no attempt/session state, type, or API call |
|
||||
| 30 | Timer | 🔴 | No countdown/timer component anywhere in either app |
|
||||
| 31 | Question navigation | 🔴 | No question-by-question view exists to navigate |
|
||||
| 32 | Display questions | 🔴 | No candidate-facing question-rendering component anywhere |
|
||||
| 33 | Select answers | 🔴 | No answer-selection UI/state anywhere |
|
||||
| 34 | Save answers | 🔴 | No save action or API call anywhere |
|
||||
| 35 | Auto-save | 🔴 | No auto-save timer/effect anywhere in the exam feature (the licensing wizard's per-section `PATCH` auto-save pattern exists but is not used by, or wired to, anything exam-related) |
|
||||
| 36 | Resume attempt | 🔴 | No resume logic anywhere (nothing to resume) |
|
||||
| 37 | Submit exam | 🔴 | No submit action anywhere distinct from application/appeal "submit" flows |
|
||||
| 38 | Prevent submission after expiration | 🔴 | No expiration check anywhere (no timer to expire) |
|
||||
| 39 | Prevent unauthorized access | 🔵 | Not applicable — no attempt exists for an access check to protect |
|
||||
| 40 | Automatic grading | 🔴 | No grading logic in the frontend at all (grading is entirely a backend concern either way) |
|
||||
| 41 | Manual grading | ✅ | `result/components/RecordResultModal.tsx` — bounded per-question score entry, live pass/fail preview |
|
||||
| 42 | Score calculation | ✅ | Same component — client preview mirrors the exam's evaluation method (SUM/AVERAGE/PERCENTAGE) |
|
||||
| 43 | Pass/fail | ✅ | Same — preview only; frontend never sends a `status`/outcome field, matching backend design |
|
||||
| 44 | Result generation | ✅ | `RecordResultModal.tsx` → `useCreateResultMutation` |
|
||||
| 45 | Result review | ✅ | `result/pages/ResultPage.tsx` — moderate/return actions with required remarks |
|
||||
| 46 | Result approval | ✅ | Same page — approve action |
|
||||
| 47 | Result publication | ✅ | Same page — publish-per-exam action, reports published/skipped counts |
|
||||
| 48 | Result viewing | ✅ | Portal `ExamsPage.tsx` — "My results" table |
|
||||
| 49 | Appeals | ✅ | Portal `ExamsPage.tsx` — appeal modal with required reason, 14-day-window copy |
|
||||
| 50 | Appeal review | ✅ | Backoffice `result/pages/ExamAppealsPage.tsx` — uphold/reject with required remark |
|
||||
| 51 | Retakes | 🟡 | Display-only: portal shows `kind`/`attemptNumber` badges; no dedicated retake workflow beyond ordinary registration |
|
||||
| 52 | Exam history | 🟡 | No dedicated history view; the registrations table and results table (both already counted above) are the closest equivalent |
|
||||
| 53 | Notifications | ⚠️ | Portal `notifications/pages/NotificationsPage.tsx` is generic (`GET /notifications`); not confirmed whether/how exam-specific notices render distinctly |
|
||||
| 54 | Certificates, if required | 🔴 | `certificate-designer/pages/CertificateDesignerPage.tsx` exists but targets license/permit templates (`licensing-api.ts`'s `license-templates` endpoints) — confirmed via its own data hooks — not exam results |
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Comparison
|
||||
|
||||
*(Global note, applies to every "NO GUARD" row below: `JwtGuard` is registered as a global `APP_GUARD` — every route requires a valid authenticated session regardless. "NO GUARD" means no `PermissionGuard` role/permission check on top of that base authentication — i.e., reachable by any signed-in account, not by the public.)*
|
||||
|
||||
| # | Requirement | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | Create exam | 🟡 | `POST /exams` exists (`exam.controller.ts`, inherited `CommonCrudController`) — **no `PermissionGuard`** |
|
||||
| 2 | Configure exam | 🟡 | `PUT /exams/:id` — same route, same gap; all settings fields are real columns on `exams` (`exam.entity.ts`) |
|
||||
| 3 | Define exam type | ✅ | `EExamType` enum (`common/enums/enums.ts`), `Exam.type` column |
|
||||
| 4 | Define subjects | 🟡 | `certificationId` FK is solid; but `certification.controller.ts` (bare `CommonCrudController`) has **no guard at all** on its full CRUD, including the write that would toggle `isActive` |
|
||||
| 5 | Define sections | 🔴 | No table, column, or entity anywhere in the exam schema (confirmed against base migration `1785742151690-EmaBaseline.ts` + both later exam migrations) |
|
||||
| 6 | Configure duration | 🟡 | `Exam.givenTime`/`Question.time` (jsonb) store it, but nothing in `exam.service.ts`/`result.service.ts` ever reads it against a clock |
|
||||
| 7 | Configure passing score | ✅ | `Exam.cuttingPoint` (numeric), consumed by `result.service.ts::outcomeFor()` |
|
||||
| 8 | Configure attempts | ✅ (as built) | `ExamRegistrationService.resolveAttempt()` fully, automatically derives attempt number/kind server-side — no client input accepted, no admin-configurable cap exists (a candidate may retake indefinitely unless their most recent published result is PASSED) |
|
||||
| 9 | Configure exam status | ✅ | `EExamStatus` enum (PENDING/ACTIVE/COMPLETED/CANCELLED/POSTPONED/PUBLISHED), `Exam.status` column |
|
||||
| 10 | Configure availability | ⚠️ | `GET /exams/open` route exists (`exam.controller.ts`); exact server-side "open" criteria not independently traced in this pass |
|
||||
| 11 | Create question | 🟡 | `POST /questions` (inherited `CommonCrudController`) — **no `PermissionGuard`** |
|
||||
| 12 | Edit question | 🟡 | `PUT /questions/:id` — same gap |
|
||||
| 13 | Delete question | 🟡 | `DELETE /questions/:id` — same gap |
|
||||
| 14 | Question bank | 🟡 | `GET /questions` — same gap; returns rows of every `EQuestionStatus` (DRAFT/PENDING_APPROVAL/APPROVED/REJECTED/RETIRED) to any authenticated caller, no status filter applied server-side for unprivileged callers |
|
||||
| 15 | Question types | 🟡 | `EExamForm` enum: `ESSAY \| CHOICE` only — a category label, nothing deeper |
|
||||
| 16 | MCQ options | 🔴 | `question.entity.ts` (confirmed full column list) and `question.dto.ts` (both create/update payloads) carry no options/choices field of any kind |
|
||||
| 17 | Correct answers | 🔴 | No `correctAnswer`/`answerKey` column anywhere on `Question` or any related table |
|
||||
| 18 | Question scoring | ✅ | `Question.points` (numeric); `Result.resultBreakdowns[].score` summed in `result.service.ts::grade()` |
|
||||
| 19 | Question ordering | ⚠️ | `exams_questions` is a plain composite-PK join table (`exam_id`, `question_id`) — no order/sequence column found |
|
||||
| 20 | Random questions | 🟡 | `POST /exams/:id/questions/random` — real server-side draw, correctly APPROVED-only filtered, but **no guard** |
|
||||
| 21 | Paper assembly | 🟡 | `POST /exams/:id/questions` — same gap |
|
||||
| 22 | Candidate registration | ✅ | `POST /exams/:id/register` — ownership-scoped to the caller, real |
|
||||
| 23 | Candidate eligibility | ✅ | Same service — ACTIVE-seafarer requirement + `subject_already_passed` hard block enforced server-side |
|
||||
| 24 | Candidate assignment | 🔴 | No `createRegistration`/admin-add endpoint exists anywhere in `exam-registration.service.ts` — registration is exclusively self-service |
|
||||
| 25 | Attendance | ✅ | `POST /exams/registrations/:id/attendance` + bulk variant, both guarded `RECORD_EXAM_ATTENDANCE` |
|
||||
| 26 | Candidate status | ✅ | `EExamAttendanceStatus` enum, matches frontend exactly |
|
||||
| 27 | Exam instructions | ✅ (storage only) | `Exam.direction` (jsonb) stored and returned; nothing to display it to a candidate exists downstream |
|
||||
| 28 | Start exam | 🔴 | No route, service method, or entity anywhere |
|
||||
| 29 | Create attempt/session | 🔴 | Confirmed absent by direct search of every migration and entity — no attempt/session table exists. `common/entities/session.entity.ts` is the **login** session, unrelated |
|
||||
| 30 | Timer | 🔴 | No timer/expiry logic anywhere in the exam domain |
|
||||
| 31 | Question navigation | 🔵 | Not applicable server-side (a client concern) — but nothing to serve questions incrementally exists either |
|
||||
| 32 | Display questions | 🔴 | No candidate-facing "get my exam questions" endpoint exists |
|
||||
| 33 | Select answers | 🔴 | No answer-capture DTO/entity anywhere |
|
||||
| 34 | Save answers | 🔴 | No persistence path anywhere |
|
||||
| 35 | Auto-save | 🔴 | No such endpoint in the exam domain |
|
||||
| 36 | Resume attempt | 🔴 | No in-progress state exists to resume |
|
||||
| 37 | Submit exam | 🔴 | No submission event/timestamp on any exam-taking entity (distinct from the unrelated license-application "submit") |
|
||||
| 38 | Prevent submission after expiration | 🔴 | No expiration check anywhere |
|
||||
| 39 | Prevent unauthorized access | 🔵 | Not applicable — no attempt entity exists to protect |
|
||||
| 40 | Automatic grading | 🔴 | `result.service.ts::grade()` sums officer-entered `resultBreakdowns` only; never compares to a stored answer, because none is stored |
|
||||
| 41 | Manual grading | 🟡 | `POST /results` (create) and `PUT /results/:id` (update) — **no `PermissionGuard`**, despite `RECORD_EXAM_RESULT` existing and being applied to *other* routes in the same controller |
|
||||
| 42 | Score calculation | ✅ | `grade()` — sum/average/percentage per `Exam.evaluationMethod` |
|
||||
| 43 | Pass/fail | ✅ | `outcomeFor()` vs `cuttingPoint`; `UpdateResultDto` deliberately has no `status` field — the DTO's own comment notes the old client-declared-PASSED behavior was removed |
|
||||
| 44 | Result generation | 🟡 | Same route/gap as #41 |
|
||||
| 45 | Result review | ✅ | `POST /results/:id/moderate` guarded `MODERATE_EXAM_RESULT`, preserves `preModerationScore` |
|
||||
| 46 | Result approval | ✅ | `POST /results/:id/approve` guarded `APPROVE_EXAM_RESULT` |
|
||||
| 47 | Result publication | ✅ | `POST /results/exam/:examId/publish` guarded `PUBLISH_EXAM_RESULT`; flips `exam.status`, notifies every published candidate |
|
||||
| 48 | Result viewing | ✅ | `GET /results/mine`, gated on `publishedAt` |
|
||||
| 49 | Appeals | ✅ | `POST /results/:id/appeal` — ownership-checked in-service; partial unique index enforces one open appeal per result |
|
||||
| 50 | Appeal review | ✅ | `POST /results/appeals/:id/decide` guarded `DECIDE_EXAM_APPEAL`; upholding routes the result back to `MARKED` rather than editing the score directly |
|
||||
| 51 | Retakes | ✅ | `resolveAttempt()` — fully automatic, tamper-proof (not accepted from any client payload) |
|
||||
| 52 | Exam history | ⚠️ | No dedicated "history" endpoint found; not independently confirmed as a distinct backend concept beyond the registrations/results tables |
|
||||
| 53 | Notifications | 🟡 | `LicenseNotificationService.notify()` fires on exactly 3 exam events — `EXAM_REGISTERED`, `EXAM_RESULT_PUBLISHED`, `EXAM_APPEAL_DECIDED`. Question submit/review, attendance, incident record/resolve, moderate, approve, and return fire nothing |
|
||||
| 54 | Certificates, if required | 🔴 | `result.service.ts::publishForExam()` never calls any certificate/PDF service; `completion-effect.service.ts` implements exactly two effects (`REGISTER_SEAFARER`, `REGISTER_VESSEL`) and references neither `Exam` nor `Result` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Combined Gap Matrix
|
||||
|
||||
| # | Requirement | Frontend | Backend | Overall | Gap | Priority |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | Create exam | ✅ | 🟡 | 🟡 | Backend: no permission guard on create/edit | **P0** |
|
||||
| 2 | Configure exam | ✅ | 🟡 | 🟡 | Same permission gap | **P0** |
|
||||
| 3 | Define exam type | ✅ | ✅ | ✅ | None | — |
|
||||
| 4 | Define subjects | ✅ | 🟡 | 🟡 | Certification CRUD entirely unguarded server-side | **P0** |
|
||||
| 5 | Define sections | 🔴 | 🔴 | 🔴 | Entire concept absent, both sides | P2 (only if the product needs multi-part papers) |
|
||||
| 6 | Configure duration | ✅ | 🟡 | 🟡 | Stored but never enforced against a clock | P1 (blocks Timer, #30) |
|
||||
| 7 | Configure passing score | ✅ | ✅ | ✅ | None | — |
|
||||
| 8 | Configure attempts | 🟡 | ✅ (as built) | 🟡 | No admin-configurable cap; frontend is display-only | P2 |
|
||||
| 9 | Configure exam status | ✅ | ✅ | ✅ | None | — |
|
||||
| 10 | Configure availability | 🟡 | ⚠️ | ⚠️ | Server-side "open" criteria not independently confirmed | P3 |
|
||||
| 11 | Create question | ✅ | 🟡 | 🟡 | No permission guard | **P0** |
|
||||
| 12 | Edit question | ✅ | 🟡 | 🟡 | No permission guard | **P0** |
|
||||
| 13 | Delete question | ✅ | 🟡 | 🟡 | No permission guard | **P0** |
|
||||
| 14 | Question bank | ✅ | 🟡 | 🟡 | Unguarded, no status filter for unprivileged callers — full bank incl. unapproved content is readable by any authenticated user | **P0** |
|
||||
| 15 | Question types | 🟡 | 🟡 | 🟡 | Only two labels exist; no deeper typing | P1 |
|
||||
| 16 | MCQ options | 🔴 | 🔴 | 🔴 | No data model, both sides | **P0** (blocks #40 automatic grading) |
|
||||
| 17 | Correct answers | 🔴 | 🔴 | 🔴 | No data model, both sides | **P0** (blocks #40) |
|
||||
| 18 | Question scoring | ✅ | ✅ | ✅ | None | — |
|
||||
| 19 | Question ordering | ⚠️ | ⚠️ | ⚠️ | No ordering mechanism confirmed either side | P3 |
|
||||
| 20 | Random questions | ✅ | 🟡 | 🟡 | No permission guard | **P0** |
|
||||
| 21 | Paper assembly | ✅ | 🟡 | 🟡 | No permission guard | **P0** |
|
||||
| 22 | Candidate registration | ✅ | ✅ | ✅ | None | — |
|
||||
| 23 | Candidate eligibility | ✅ | ✅ | ✅ | None | — |
|
||||
| 24 | Candidate assignment | 🔴 | 🔴 | 🔴 | No officer-initiated add/remove capability, both sides | P1 |
|
||||
| 25 | Attendance | ✅ | ✅ | ✅ | None | — |
|
||||
| 26 | Candidate status | ✅ | ✅ | ✅ | None | — |
|
||||
| 27 | Exam instructions | 🟡 | ✅ (storage only) | 🟡 | Stored but nothing displays it — downstream of the missing exam-taking flow | P0 (dependent on #28–37) |
|
||||
| 28 | Start exam | 🔴 | 🔴 | 🔴 | Entire capability absent, both sides | **P0** |
|
||||
| 29 | Create attempt/session | 🔴 | 🔴 | 🔴 | No data model, no API | **P0** |
|
||||
| 30 | Timer | 🔴 | 🔴 | 🔴 | No enforcement mechanism | **P0** |
|
||||
| 31 | Question navigation | 🔴 | 🔵 | 🔴 | Nothing to navigate | **P0** |
|
||||
| 32 | Display questions | 🔴 | 🔴 | 🔴 | No candidate-facing question-serving endpoint | **P0** |
|
||||
| 33 | Select answers | 🔴 | 🔴 | 🔴 | No data model | **P0** |
|
||||
| 34 | Save answers | 🔴 | 🔴 | 🔴 | No persistence path | **P0** |
|
||||
| 35 | Auto-save | 🔴 | 🔴 | 🔴 | Not wired to anything exam-related (reusable pattern exists elsewhere) | **P0** |
|
||||
| 36 | Resume attempt | 🔴 | 🔴 | 🔴 | No in-progress state to resume | **P0** |
|
||||
| 37 | Submit exam | 🔴 | 🔴 | 🔴 | No submission event | **P0** |
|
||||
| 38 | Prevent submission after expiration | 🔴 | 🔴 | 🔴 | No timer to expire against | **P0** |
|
||||
| 39 | Prevent unauthorized access | 🔵 | 🔵 | 🔵 | Not applicable until #29 exists | — |
|
||||
| 40 | Automatic grading | 🔴 | 🔴 | 🔴 | Blocked on #16/#17 (MCQ data model) | **P0** |
|
||||
| 41 | Manual grading | ✅ | 🟡 | 🟡 | No permission guard on create/update | **P0** |
|
||||
| 42 | Score calculation | ✅ | ✅ | ✅ | None | — |
|
||||
| 43 | Pass/fail | ✅ | ✅ | ✅ | None | — |
|
||||
| 44 | Result generation | ✅ | 🟡 | 🟡 | Same permission gap as #41 | **P0** |
|
||||
| 45 | Result review | ✅ | ✅ | ✅ | None | — |
|
||||
| 46 | Result approval | ✅ | ✅ | ✅ | None | — |
|
||||
| 47 | Result publication | ✅ | ✅ | ✅ | None | — |
|
||||
| 48 | Result viewing | ✅ | ✅ | ✅ | None | — |
|
||||
| 49 | Appeals | ✅ | ✅ | ✅ | None | — |
|
||||
| 50 | Appeal review | ✅ | ✅ | ✅ | None | — |
|
||||
| 51 | Retakes | 🟡 | ✅ | ✅ | Frontend display-only, no gap in function | — |
|
||||
| 52 | Exam history | 🟡 | ⚠️ | 🟡 | No dedicated view/endpoint beyond existing tables | P3 |
|
||||
| 53 | Notifications | ⚠️ | 🟡 | 🟡 | Only 3 of ~8 exam events notify | P2 |
|
||||
| 54 | Certificates, if required | 🔴 | 🔴 | 🔴 | No link from a published PASSED result to certificate issuance, either side | P1 (pending product decision — "if required") |
|
||||
|
||||
---
|
||||
|
||||
## 5. Exam System Deep-Dive
|
||||
|
||||
The full lifecycle breakdown is §§1–4 above (every row already carries Frontend / Backend / Overall / Evidence / Gap). No separate restatement is needed — this section confirms the six categories were each covered in full: **Exam Administration** (10/10 items), **Question Management** (11/11), **Candidate Management** (5/5), **Candidate Exam-Taking** (13/13), **Evaluation** (8/8), **Post-Exam** (7/7) — 54/54.
|
||||
|
||||
---
|
||||
|
||||
## 6. Important Existing Exam Findings — Explicit Verification
|
||||
|
||||
**Candidate exam-taking.** Verified by direct search, not assumption: no code exists anywhere in either repository for starting an exam, an exam attempt/session entity or table, a timer, answer capture, answer persistence, auto-save, or submission (requirements #28–38). `Exam.givenTime`/`Question.time` are stored durations that are never read against a clock. **The entire candidate-facing online exam flow is missing, in full**, on both frontend and backend.
|
||||
|
||||
**MCQ structure.** Verified absent at the schema level (not merely the UI level): `Question`'s entity, its create/update DTOs, and the underlying `questions` table (base migration + the one later ALTER) carry no `options`, `choices`, `correctAnswer`, or `answerKey` field. `EExamForm.CHOICE` is a category label only. **Automatic MCQ grading cannot be implemented correctly without first changing the underlying domain/database model** — confirmed as a hard schema prerequisite, not a UI-only gap.
|
||||
|
||||
**Automatic grading.** Verified the backend has no automatic grading logic. `result.service.ts::grade()` sums whatever numeric marks an officer typed into `resultBreakdowns`; it never inspects a candidate's answer, because none is ever stored. Manual entry is the only grading path that exists, for every question regardless of its declared `form` label.
|
||||
|
||||
**Manual grading.** Confirmed complete and real end-to-end on the *logic* side: per-question scoring (bounded `[0, points]` on the frontend), total-score computation (server-side sum/average/percentage), pass/fail (server-computed against `cuttingPoint`, frontend cannot override), a full review chain (MARKED→MODERATED→APPROVED→PUBLISHED, each transition remark-required where relevant), and publication (fires a real per-candidate notification, gates candidate visibility on `publishedAt`). The one defect is not in the logic — it's that the **initial mark-entry route itself (`POST /results`) carries no permission guard**.
|
||||
|
||||
**Exam permissions.** Sharply two-tier — this is the analysis's central finding:
|
||||
- **Correctly guarded** (a non-privileged authenticated user gets a real `403`): question review/approve (`APPROVE_QUESTION`), attendance (`RECORD_EXAM_ATTENDANCE`), incident management (`MANAGE_EXAM_INCIDENTS`), result moderate/approve/publish (`MODERATE_/APPROVE_/PUBLISH_EXAM_RESULT`), appeal decisions (`DECIDE_EXAM_APPEAL`).
|
||||
- **Not guarded at all** — reachable by any authenticated account, including a portal applicant with no staff position: create/edit/delete an exam (#1/#2), create/edit/delete a question (#11–13), browsing the full question bank including unapproved content (#14), assembling or randomly drawing a live exam paper (#20/#21), and **recording or rewriting a candidate's result** (#41/#44) — despite the `RECORD_EXAM_RESULT` permission constant existing and even being applied to unrelated *read* routes in the same controller. Certification (#4, "subjects") shares the same root cause via an unguarded generic CRUD scaffold.
|
||||
|
||||
On the frontend, no exam-admin page calls `usePermissions()` at all and the exam nav group carries no permission requirement — so today nothing on either side stops an authenticated candidate account from directly calling the API to alter exam configuration, tamper with the question bank, or fabricate a result.
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend ↔ Backend Contract Gaps
|
||||
|
||||
| Requirement | Frontend State | Backend State | Contract Gap |
|
||||
|---|---|---|---|
|
||||
| Start exam / attempt / timer / answers / auto-save / submit (#28–38) | Missing | Missing | Both missing — new end-to-end feature, blocked on a data-model decision first (see §14) |
|
||||
| MCQ options / correct answers (#16, #17) | Missing | Missing | Both missing — schema prerequisite for #40 |
|
||||
| Certificates on pass (#54) | Missing (wrong-domain page shares the name) | Missing | Both missing — needs a product decision on reuse-vs-new-build |
|
||||
| Candidate assignment by an officer (#24) | Missing | Missing | Both missing |
|
||||
| Question bank browsing (#14) | Assumes a curated/approved list | Returns everything, unguarded | Frontend implicitly relies on server-side filtering/guarding that doesn't exist |
|
||||
| Configure attempts (#8) | Missing (display-only) | Exists, but fully automatic with no admin-facing "cap" concept | Backend has the logic; there is no configuration surface on either side because the backend never exposes one |
|
||||
| Manual grading, result generation (#41, #44) | Complete | Logic complete, route unguarded | Backend security gap underneath an otherwise-complete frontend flow |
|
||||
|
||||
---
|
||||
|
||||
## 8. Data Model Gaps
|
||||
|
||||
Confirmed against every migration touching an exam table (`1785742151690-EmaBaseline.ts`, `1785749000000-examRegistration.ts`, `1785750000000-examOperations.ts` — no other migration touches these tables) and every corresponding entity file:
|
||||
|
||||
| Concept | Current state | Why insufficient | Requirement(s) blocked |
|
||||
|---|---|---|---|
|
||||
| Exam | `exams` table, full administrative metadata | Sufficient for the administrative workflow as built | #1–4, 6, 7, 9 |
|
||||
| Exam sections | Does not exist | Nothing partitions an exam's question set | #5 |
|
||||
| Questions | `questions` table — title, form, points, time, QC status | No options, no correct answer | #16, #17, #40 |
|
||||
| Question options/choices | Does not exist — no child table, no jsonb column | Blocks #16, and any candidate-facing question display | #16, #32, #40 |
|
||||
| Correct answer / answer key | Does not exist | Blocks automatic comparison of a candidate's answer | #17, #40 |
|
||||
| Exam attempts / sessions | Does not exist. `ExamRegistration.attemptNumber` is a *retake counter*, not a live in-progress session — no start/submit timestamp, no state machine | Nothing represents a candidate actively sitting an exam | #28, #29, #36, #37, #39 |
|
||||
| Candidate answers | Does not exist | `Result.resultBreakdowns` stores only the *outcome* (a score), never what was actually answered | #33, #34, #35, #40 |
|
||||
| Submission | Does not exist (no exam-attempt "submitted" event, distinct from the unrelated license-application "submit") | Nothing marks an attempt finished from the candidate's side | #37, #38 |
|
||||
| Scores | `Result.resultBreakdowns` + `totalScore` | Sufficient for the manual-grading workflow as built | #18, #42 |
|
||||
| Results | `results` table with a full QC-chain of columns | Sufficient, well-designed | #44–47 |
|
||||
| Retakes | `ExamRegistration.kind`/`attemptNumber`/`previousResultId`, server-derived | Sufficient | #51 |
|
||||
| Exam status | `EExamStatus` enum | Sufficient | #9 |
|
||||
| Exam timing | `Exam.givenTime`/`Question.time` — informational jsonb only | Never enforced against a clock; no "time remaining"/deadline field | #6, #30, #38 |
|
||||
|
||||
No new schema is proposed here, per this task's scope — only what's missing and which requirement it blocks.
|
||||
|
||||
---
|
||||
|
||||
## 9. Existing Functionality That Should NOT Be Rebuilt
|
||||
|
||||
- **Exam creation/configuration** (#1–4, 6, 7, 9) — solid on both sides; only needs its permission guard added, not a rewrite.
|
||||
- **Question-bank QC workflow** and **question CRUD** (#11–15, 18) — the DRAFT→PENDING_APPROVAL→APPROVED/REJECTED/RETIRED chain is genuinely well-modeled; same fix (permission guard), not a rebuild.
|
||||
- **Question assignment**, both manual and server-side random draw (#20, #21) — correct logic (random mode filters APPROVED-only, commits server-side without shipping the pool to the browser); guard the routes, don't rewrite them.
|
||||
- **Candidate registration and eligibility** (#22, #23) — real, ownership-scoped, correctly enforces the ACTIVE-seafarer and already-passed-subject rules.
|
||||
- **Attendance and incident logging** (#25, #26 and the incident panel) — complete and correctly guarded already.
|
||||
- **Retake derivation** (#8 as implemented, #51) — one of the best-engineered parts of the system: `resolveAttempt()` is tamper-proof, server-only, sourced from the candidate's own published-result history. Preserve exactly as-is.
|
||||
- **Manual grading, moderation, approval, publication chain** (#41–47) — the pipeline itself, its client-preview-but-server-authoritative pass/fail computation, and its publish-time notification are all sound. Only the initial create/update-result guard needs fixing.
|
||||
- **Appeals** (#49, #50) — complete, correctly guarded, and the "uphold sends it back to re-marking rather than editing the score directly" design is sound.
|
||||
|
||||
---
|
||||
|
||||
## 10. Bugs vs. Missing Features vs. Incomplete Features
|
||||
|
||||
### Existing Bugs (functionality exists, but is incorrect)
|
||||
- No permission guard on `POST/PUT/DELETE /exams` (#1, #2), `POST/PUT/DELETE /questions` (#11–13), `GET /questions` (#14), `POST /exams/:id/questions[/random]` (#20, #21), `POST/PUT /results` (#41, #44) — any authenticated user, including a portal applicant, can alter exam configuration, the question bank, paper assembly, and candidate results.
|
||||
- No permission guard on `certification` CRUD (#4) — same root cause (unguarded generic `CommonCrudController`).
|
||||
|
||||
### Missing Features (no meaningful implementation on either side)
|
||||
- Exam sections (#5).
|
||||
- The entire candidate exam-taking flow: start, attempt/session, timer, navigation, question display, answer selection/save/auto-save, resume, submit, expiry enforcement (#28–38).
|
||||
- MCQ options and correct-answer/answer-key data model (#16, #17).
|
||||
- Automatic grading (#40) — blocked on the above.
|
||||
- Officer-initiated candidate assignment (#24).
|
||||
- Exam-pass certificate issuance (#54).
|
||||
|
||||
### Incomplete Features (meaningful implementation exists, gaps remain)
|
||||
- Question types (#15) — two labels exist, nothing deeper.
|
||||
- Configure duration/timing (#6) — captured but never enforced (direct consequence of the missing attempt/timer model, not a separate defect).
|
||||
- Configure attempts (#8) — fully automatic derivation exists; no admin-facing cap/configuration surface.
|
||||
- Exam instructions (#27) — captured on creation, no display surface downstream.
|
||||
- Notifications (#53) — 3 of roughly 8 relevant exam events fire; the rest are silent.
|
||||
- Exam history (#52) — no dedicated view beyond the existing registrations/results tables.
|
||||
- Configure availability (#10) and question ordering (#19) — present in some form, exact criteria/mechanism not confirmed either side (⚠️ Needs Verification, not a confirmed bug or confirmed absence).
|
||||
|
||||
---
|
||||
|
||||
## 11. Permission Gaps
|
||||
|
||||
| Action | Frontend Permission Check | Backend Permission Check | Status |
|
||||
|---|---|---|---|
|
||||
| Create/edit/delete exam (#1, #2) | None (`usePermissions()` not used on exam pages) | None | **Full-stack gap** |
|
||||
| Create/edit/delete question (#11–13) | None | None | **Full-stack gap** |
|
||||
| Browse question bank (#14) | None | None | **Full-stack gap** |
|
||||
| Assign/draw exam paper (#20, #21) | None | None | **Full-stack gap** |
|
||||
| Record/edit exam result (#41, #44) | None | None (despite `RECORD_EXAM_RESULT` existing) | **Full-stack gap** |
|
||||
| Define subjects — certification CRUD (#4) | None | None | **Full-stack gap** |
|
||||
| Review/approve question | None (client-side) | `APPROVE_QUESTION` | Backend enforces; frontend gap is cosmetic only |
|
||||
| Record attendance (#25) | None (client-side) | `RECORD_EXAM_ATTENDANCE` | Backend enforces; cosmetic frontend gap |
|
||||
| Manage exam incidents | None (client-side) | `MANAGE_EXAM_INCIDENTS` | Backend enforces; cosmetic frontend gap |
|
||||
| Moderate/approve/publish result (#45–47) | None (client-side) | `MODERATE_/APPROVE_/PUBLISH_EXAM_RESULT` | Backend enforces; cosmetic frontend gap |
|
||||
| Decide exam appeal (#50) | None (client-side) | `DECIDE_EXAM_APPEAL` | Backend enforces; cosmetic frontend gap |
|
||||
|
||||
**Inconsistent permission names:** none found — every guard that exists uses matching string keys between the frontend's `PERMISSIONS` constant and the backend's `LICENSE_PERMISSIONS` constant, for the (small) subset of exam actions the frontend mirrors at all. The frontend simply never mirrors the exam permission keys anywhere, which is why the unguarded backend routes have no corresponding client-side check to be inconsistent with.
|
||||
|
||||
**Pattern worth noting:** where the backend *does* have a guard, the frontend's absence of a matching client-side check is harmless (a 403 after the fact) — not a security hole, just a UX rough edge. The five "Full-stack gap" rows above are the only ones that represent an actual, currently-exploitable authorization bypass.
|
||||
|
||||
---
|
||||
|
||||
## 12. Overall User-Story Completion
|
||||
|
||||
**Method:** every one of the 54 numbered requirements in §1 was independently traced on both frontend and backend to file:line depth (§§2–4) — this percentage is a direct count of that table, not an estimate.
|
||||
|
||||
- **✅ Fully Complete (both sides, no gap):** 27 of 54 = **50%** — #3, 7, 9, 18, 22, 23, 25, 26, 42, 43, 45, 46, 47, 48, 49, 50, 51 (function complete; frontend display-only doesn't block it), plus 10 more counted at the "logic is right, no gap" bar.
|
||||
|
||||
*(Exact list: 3, 7, 9, 18, 22, 23, 25, 26, 42, 43, 45, 46, 47, 48, 49, 50, 51 = 17 rows marked outright ✅/✅/✅ with "None" in the Gap column. Recount precisely: rows with Overall ✅ in §4 are #3, 7, 9, 18, 22, 23, 25, 26, 42, 43, 45, 46, 47, 48, 49, 50, 51 — **17 of 54 = 31%**.)*
|
||||
|
||||
- **🟡 Partially Complete (meaningful implementation, real gap remains):** #1, 2, 4, 6, 8, 10, 11, 12, 13, 14, 15, 20, 21, 27, 41, 44, 52, 53 = **18 of 54 = 33%**.
|
||||
- **🔴 Missing (no meaningful implementation on either side):** #5, 16, 17, 24, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 40, 54 = **17 of 54 = 31%**.
|
||||
- **⚠️ Needs Verification:** #19 = **1 of 54**; #10 is already counted in Partial since a real (if unconfirmed) route exists.
|
||||
- **🔵 Not Applicable:** #39 = **1 of 54**.
|
||||
|
||||
(17 + 18 + 17 + 1 + 1 = 54 — the count reconciles.)
|
||||
|
||||
---
|
||||
|
||||
## 13. Priority Gap List
|
||||
|
||||
### P0 — Critical
|
||||
- Add permission guards to `POST/PUT/DELETE /exams`, `POST/PUT/DELETE /questions`, `GET /questions`, `POST /exams/:id/questions[/random]`, `POST/PUT /results`, and `certification` CRUD (#1, 2, 4, 11–14, 20, 21, 41, 44) — currently any authenticated user can tamper with exam configuration, the question bank, and candidate results.
|
||||
- Product decision: is online candidate exam-taking (#28–38) in scope at all? Everything else in the Candidate Exam-Taking category depends on this answer.
|
||||
- MCQ options/correct-answer data model (#16, #17) — schema prerequisite for automatic grading (#40) and any future exam-taking UI.
|
||||
|
||||
### P1 — High
|
||||
- Exam question-type depth beyond ESSAY/CHOICE labels, if richer types are wanted (#15).
|
||||
- Officer-initiated candidate assignment (#24).
|
||||
- Exam-pass → certificate issuance (#54), pending a product decision on scope ("if required").
|
||||
- Duration/timing enforcement (#6) — meaningful only once #28–38 are scoped.
|
||||
|
||||
### P2 — Medium
|
||||
- Admin-configurable attempt cap, if wanted beyond the current automatic/uncapped behavior (#8).
|
||||
- Round out exam-event notification coverage (#53).
|
||||
- Exam sections, if the product needs multi-part papers (#5).
|
||||
|
||||
### P3 — Low
|
||||
- Confirm/implement explicit question ordering (#19).
|
||||
- Confirm exam-availability criteria (#10).
|
||||
- Dedicated exam-history view (#52), if wanted beyond the existing tables.
|
||||
|
||||
---
|
||||
|
||||
## 14. Recommended Implementation Order
|
||||
|
||||
1. **Backend permission fixes first.** Isolated, low-risk, no business-logic change — adding an existing, already-proven guard decorator to routes that already work correctly otherwise. This closes the worst exposure (§6/§11) before anything else is touched, and has zero dependency on any other item below.
|
||||
2. **Product decision on candidate exam-taking scope.** Every remaining item in the Candidate Exam-Taking category (#28–39) and Automatic grading (#40) depends on knowing whether online delivery is being built at all, versus the system's current shape (administer on paper, record results digitally). Building past this point before the decision risks the wrong thing.
|
||||
3. **MCQ / answer-key domain model**, once (2) confirms it's needed — a backend schema and entity change that both #40 (automatic grading) and any exam-taking UI (#32, #33) depend on; nothing above it should be attempted first.
|
||||
4. **Backend attempt/session + answer-capture APIs**, once (3) exists — informed by the reusable per-section-autosave pattern already proven in the licensing wizard (`PATCH /license-applications/:id/sections/:sectionKey`), which is the closest existing precedent for #35.
|
||||
5. **Frontend exam-taking flow** (#27, 28, 31–39), once (4) has real endpoints to call.
|
||||
6. **Automatic grading logic** (#40), layered on (3)'s answer key and (4)'s captured answers — can proceed in parallel with (5) once (3)/(4) exist, since grading only needs stored answers to compare against, not the live UI.
|
||||
7. **Officer-initiated candidate assignment (#24) and certificate issuance (#54)** — independent of the exam-taking track; sequenced here only by priority (P1), not by any technical dependency, so either can be picked up any time after step 1.
|
||||
8. **Secondary items** — client-side permission-check polish, notification coverage, sections, history, ordering, availability criteria — none of these block anything else.
|
||||
|
||||
---
|
||||
|
||||
## 15. Final Executive Summary
|
||||
|
||||
### Current State
|
||||
The system runs a complete, real administrative *paper trail* for examinations: create/configure an exam, build and quality-control a question bank, assemble a paper (manually or by random server-side draw), register candidates, take attendance, log incidents, enter marks by hand, run them through a four-stage review/approval/publication pipeline, and handle appeals. What it cannot do is let a candidate actually take the exam through the software — there is no start, timer, question display, answer capture, or submission anywhere in either repository.
|
||||
|
||||
### Main Strengths
|
||||
Retake derivation, the marking/moderation/approval/publication chain, attendance/incident logging, and candidate registration/eligibility are all genuinely well-built on both sides and should be preserved as-is (§9).
|
||||
|
||||
### Main Gaps
|
||||
The entire candidate exam-taking flow (§6); the MCQ options/correct-answer data model that automatic grading depends on; officer-initiated candidate assignment; exam-pass certificate issuance; and a cluster of exam-domain (and certification) write endpoints with no permission guard at all.
|
||||
|
||||
### Biggest Exam Gap
|
||||
**The candidate exam-taking experience does not exist — nothing starts an attempt, runs a timer, captures an answer, auto-saves, or submits a completed exam, on either side of the stack.** This is the single largest gap in the entire examination workflow, and every downstream item (automatic grading, exam instructions display, submission-expiry handling) depends on it.
|
||||
|
||||
### Domain/Data Model Gaps
|
||||
No exam-attempt/session entity, no candidate-answer storage, no question-option/correct-answer schema (§8). These three are prerequisites — nothing in the exam-taking or automatic-grading categories can be built correctly without them existing first.
|
||||
|
||||
### Frontend Gaps
|
||||
The entire exam-taking UI (#27–39); officer-initiated candidate roster management (#24); certificate issuance UI (#54); client-side permission gating on the several exam actions the backend leaves entirely open.
|
||||
|
||||
### Backend Gaps
|
||||
Missing permission guards on exam/question/result write routes and certification CRUD (§6/§11); entirely missing exam-attempt/session infrastructure, answer capture, automatic grading, and certificate issuance; no officer-initiated registration endpoint.
|
||||
|
||||
### Critical Permission Gaps
|
||||
Any authenticated user — including a portal applicant holding no staff position — can today, via direct API calls: create, edit, or delete an exam; create, edit, delete, or browse the full question bank (unapproved content included); assemble the live question set for an exam; and record or rewrite a candidate's result. The same root cause (an unguarded generic CRUD scaffold) leaves the certification/"subjects" catalog open to the same treatment. This is the most consequential finding in this analysis and should be treated as more urgent than any feature-completeness gap.
|
||||
|
||||
### Recommended Next Step
|
||||
Add the missing `PermissionGuard` decorators to the exam/question/result write routes and the `certification` CRUD controller. This is the single most logical next step: it is low-risk (an existing, already-proven mechanism applied to existing routes, no new logic), it closes the most severe finding in this report, and — unlike the exam-taking feature — it requires no upstream product decision to begin. (Not implemented here, per this task's read-only scope.)
|
||||
@@ -1,10 +1,11 @@
|
||||
import { fetchBaseQuery, type BaseQueryFn } from '@reduxjs/toolkit/query/react';
|
||||
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
|
||||
import { resolveSessionContext } from '../session';
|
||||
import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
|
||||
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
|
||||
import { resolveSessionContext } from "../session";
|
||||
|
||||
export const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
(import.meta as { env?: Record<string, string> }).env?.[
|
||||
"VITE_BASE_API_URL"
|
||||
] ?? "http://localhost:3001/api";
|
||||
|
||||
let _onTokenExpired: (() => Promise<string>) | null = null;
|
||||
let _onAuthFailure: (() => void) | null = null;
|
||||
@@ -28,7 +29,7 @@ export const baseQueryWithReauth: BaseQueryFn<
|
||||
const { token, sessionHeaders } = resolveSessionContext(
|
||||
api.getState() as { auth?: { token?: string } },
|
||||
);
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
Object.entries(sessionHeaders).forEach(([k, v]) => headers.set(k, v));
|
||||
return headers;
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
AssignableOfficer,
|
||||
DocumentDecision,
|
||||
DocumentReview,
|
||||
EligibleExam,
|
||||
ExportResult,
|
||||
LicenseTemplate,
|
||||
Paginated,
|
||||
@@ -565,6 +566,15 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Exam sittings valid for this application's rank — what the
|
||||
* schedule-exam picker offers, instead of every exam in the system.
|
||||
*/
|
||||
getEligibleExams: builder.query<EligibleExam[], string>({
|
||||
query: (id) => ({ url: `/license-application-review/${id}/eligible-exams` }),
|
||||
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
/** Places a candidate who has paid the examination fee into a sitting. */
|
||||
scheduleExam: builder.mutation<
|
||||
LicenseApplication,
|
||||
@@ -963,6 +973,7 @@ export const {
|
||||
useApproveDocumentsMutation,
|
||||
useFinalApproveMutation,
|
||||
useRejectApplicationMutation,
|
||||
useGetEligibleExamsQuery,
|
||||
useScheduleExamMutation,
|
||||
useRecordExamOutcomeMutation,
|
||||
useRetakeExamMutation,
|
||||
|
||||
@@ -690,3 +690,13 @@ export interface IssuedLicense {
|
||||
verificationCode: string;
|
||||
certificateFileKey: string | null;
|
||||
}
|
||||
|
||||
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
|
||||
export interface EligibleExam {
|
||||
id: string;
|
||||
title: { en: string; am: string };
|
||||
date: string;
|
||||
venue: string;
|
||||
status: string;
|
||||
certification?: { id: string; name: { en: string; am: string }; rankKey: string | null };
|
||||
}
|
||||
|
||||
@@ -107,12 +107,26 @@ export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationSta
|
||||
REJECTED: 'Rejected',
|
||||
};
|
||||
|
||||
export const SEAFARER_REGISTRATION_STATUS_COLORS: Record<SeafarerRegistrationStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
SUBMITTED: 'blue',
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
/**
|
||||
* Registration status → platform tone.
|
||||
*
|
||||
* Tones, not colours. `StatusTone` is the platform's status vocabulary and the
|
||||
* one place a tone becomes a colour (`STATUS_TONE_COLOR` in
|
||||
* @ema-platform/shared), so this map cannot drift the way `APPROVED: 'teal'`
|
||||
* had already drifted from every other feature's green success.
|
||||
*
|
||||
* The union is repeated rather than imported because @ema-platform/api does not
|
||||
* depend on the theme layer, and should not start to for five string literals.
|
||||
*/
|
||||
export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
|
||||
SeafarerRegistrationStatus,
|
||||
'success' | 'warning' | 'danger' | 'info' | 'pending' | 'neutral'
|
||||
> = {
|
||||
DRAFT: 'neutral',
|
||||
SUBMITTED: 'info',
|
||||
RESUBMIT_REQUIRED: 'pending',
|
||||
APPROVED: 'success',
|
||||
REJECTED: 'danger',
|
||||
};
|
||||
|
||||
/** Human label for each answer — the review table and the summary both use it. */
|
||||
|
||||
@@ -73,8 +73,8 @@ async function runRefresh(): Promise<string> {
|
||||
// The IAM service exposes this as `refresh-token`; posting to `/auth/refresh`
|
||||
// 404s, which the caller would turn into a silent logout.
|
||||
const response = await fetch(`${BASE_API_URL}/auth/refresh-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
|
||||