Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-22 09:00:45 +03:00
122 changed files with 5900 additions and 2860 deletions

View File

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

View File

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

View File

@@ -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 }) => (

View File

@@ -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 && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('certification.add')}
</Button>
)}
</Group>
<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>
)
}
/>
{showForm && (
<CertificationForm

View File

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

View File

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

View File

@@ -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,73 +58,143 @@ 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
tableName="Awaiting claim"
columns={dashboardQueueColumns}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
onRowClick={() => navigate('/licence-review')}
refresh={queue.refetch}
emptyText="Nothing waiting to be claimed."
{/* 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')}
/>
</Card>
</Container>
<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}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
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>
}
/>
</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>
);
}

View File

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

View File

@@ -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 }) => (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
hideOnly
>
<Button
size="compact-xs"
variant="light"
leftSection={<IconUserCheck size={12} />}
onClick={() => handlers.onRecord(row.original)}
>
{t('exam.candidates.record')}
</Button>
</RequirePermission>
),
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
>
<Menu.Item
leftSection={<IconUserCheck size={14} />}
onClick={() => handlers.onRecord(row.original)}
>
{t('exam.candidates.record')}
</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>
);
},
},
];
}

View File

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

View File

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

View File

@@ -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,7 +172,12 @@ export function ExamDetailPage() {
notify.error(
key.startsWith('insufficient_approved_questions')
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
: key,
: 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,
: 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">

View File

@@ -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)}
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconDetails size={14} />}
onClick={() => handlers.onDetails(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
</RequirePermission>
<ActionIcon
variant="subtle"
color="red"
size="sm"
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>
),
};
}

View File

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

View File

@@ -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,26 +487,25 @@ 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 && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
size="sm"
>
{t("exam.add")}
</Button>
</RequirePermission>
)}
</Group>
<PageHeader
title={t("exam.title")}
subtitle={t("exam.subtitle")}
noMargin
action={
!showForm && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
size="sm"
>
{t("exam.add")}
</Button>
</RequirePermission>
)
}
/>
{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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,21 +139,19 @@ 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>
<TextInput
placeholder="Certificate № or company"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
<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} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
}
/>
<Card withBorder padding={0}>
<AdvancedTable<IssuedLicense>

View File

@@ -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>
) : (

View File

@@ -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,16 +503,11 @@ 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>
<Group gap="xs">
<PageHeader
title={queueTitle}
subtitle={typeCode ? t(`nav.type${typeCode}`, { defaultValue: typeCode }) : undefined}
action={
<Group gap="xs">
<SegmentedControl
size="xs"
value={density}
@@ -536,8 +531,9 @@ export function LicenseQueuePage() {
>
{t("queue.export", "Export CSV")}
</Button>
</Group>
</Group>
</Group>
}
/>
{/* Saved views, counted. */}
<Tabs

View File

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

View File

@@ -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,9 +94,11 @@ export function LocationPage() {
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={2}>{t('location.title')}</Title>
<Group gap="sm">
<PageHeader
title={t('location.title')}
noMargin
action={
<Group gap="sm">
{locationTypes.length > 0 && (
<Button
variant="light"
@@ -135,8 +123,9 @@ export function LocationPage() {
<IconSettings size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Group>
}
/>
{locationTypes.length === 0 && (
<Alert

View File

@@ -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) => (

View File

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

View File

@@ -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,22 +356,24 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
{isMedical
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
</Title>
<Text size="sm" c="dimmed" mb="md">
{isMedical
? t(
'recordVerification.medicalSubtitle',
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
)
: 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>
<PageHeader
title={
isMedical
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
: 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.',
)
: 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.',
)
}
/>
{statusFilter}

View File

@@ -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(
'paymentConfig.subtitle',
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
)}
</Text>
</div>
<ThemeIcon size="xl" radius="md" variant="light">
<IconCreditCard size={22} />
</ThemeIcon>
</Group>
<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.',
)}
noMargin
action={
<ThemeIcon size="xl" radius="md" variant="light">
<IconCreditCard size={22} />
</ThemeIcon>
}
/>
<Alert
variant="light"

View File

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

View File

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

View File

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

View File

@@ -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">
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
<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>
<Menu.Item
leftSection={<IconSend size={14} />}
onClick={() => handlers.onSubmitForApproval(q)}
>
{t('question.qc.submit')}
</Menu.Item>
</RequirePermission>
)}
{q.status === 'PENDING_APPROVAL' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Menu.Item color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
{t('question.qc.approve')}
</Menu.Item>
<Menu.Item color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
{t('question.qc.reject')}
</Menu.Item>
</RequirePermission>
)}
{q.status === 'APPROVED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Menu.Item
color="dark"
leftSection={<IconGavel size={14} />}
onClick={() => handlers.onReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Menu.Item>
</RequirePermission>
)}
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<Button
size="compact-xs"
variant="light"
leftSection={<IconSend size={12} />}
loading={handlers.isSubmittingReview}
onClick={() => handlers.onSubmitForApproval(q)}
<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.qc.submit')}
</Button>
{t('question.action.delete', 'Delete')}
</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')}>
{t('question.qc.approve')}
</Button>
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
{t('question.qc.reject')}
</Button>
</RequirePermission>
)}
{q.status === 'APPROVED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Button
size="compact-xs"
variant="subtle"
color="dark"
leftSection={<IconGavel size={12} />}
onClick={() => handlers.onReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Button>
</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>
</RequirePermission>
</Group>
</Menu.Dropdown>
</Menu>
);
},
};

View File

@@ -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,58 +141,181 @@ function QuestionForm({
editing: Question | null;
certOptions: { value: string; label: string }[];
isSubmitting: boolean;
onSubmit: (values: {
certificationId: string;
titleEn: string;
titleAm: string;
form: string;
points: number;
days: number;
hours: number;
minutes: number;
}, isEdit: boolean) => void;
onSubmit: (
values: {
certificationId: string;
titleEn: string;
titleAm: string;
form: string;
points: number;
days: number;
hours: number;
minutes: number;
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,99 +490,146 @@ 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')}
</Button>
</RequirePermission>
)}
</Group>
<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>
)
}
/>
{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}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('question.noQuestions')}
/>
</Card>
}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t("question.noQuestions")}
/>
<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>

View File

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

View File

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

View File

@@ -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,49 +22,66 @@ export function resultActionsColumn(
cell: ({ row }) => {
const r = row.original;
return (
<Group gap="xs">
{(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')}>
{t('result.review.moderate')}
</Button>
<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>
<Menu.Item color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
{t('result.review.moderate')}
</Menu.Item>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Menu.Item color="blue" onClick={() => handlers.onQc(r, 'approve')}>
{t('result.review.approve')}
</Menu.Item>
</RequirePermission>
</>
)}
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<RequirePermission
anyOf={[
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT,
]}
hideOnly
>
<Menu.Item color="orange" onClick={() => handlers.onQc(r, 'return')}>
{t('result.review.return')}
</Menu.Item>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
{t('result.review.approve')}
</Button>
)}
{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>
</>
)}
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<RequirePermission
anyOf={[
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT,
]}
hideOnly
>
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
{t('result.review.return')}
</Button>
)}
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => handlers.onDelete(r)}
>
{t('result.action.delete')}
</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"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => handlers.onDelete(r)}
>
{t('result.action.delete')}
</Button>
</RequirePermission>
</Group>
</Menu.Dropdown>
</Menu>
);
},
};

View File

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

View File

@@ -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,12 +280,12 @@ 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>
<Group gap="sm">
<PageHeader
title={t('result.title')}
subtitle={t('result.subtitle')}
noMargin
action={
<Group gap="sm">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
<Button
variant="light"
@@ -314,11 +299,14 @@ export function ResultPage() {
{t('result.review.publish')}
</Button>
</RequirePermission>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')}
</Button>
</Group>
</Group>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')}
</Button>
</RequirePermission>
</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,23 +339,17 @@ 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}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('result.noItems')}
/>
</Card>
</>
}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('result.noItems')}
/>
<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,14 +490,22 @@ export function ResultPage() {
<ModalFooter>
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
<Button
onClick={handleDetailSave}
size="sm"
loading={detailSaving}
leftSection={<IconDeviceFloppy size={15} />}
<RequirePermission
anyOf={[
LICENSE_PERMISSIONS.RECORD_EXAM_RESULT,
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
]}
hideOnly
>
{t('result.save')}
</Button>
<Button
onClick={handleDetailSave}
size="sm"
loading={detailSaving}
leftSection={<IconDeviceFloppy size={15} />}
>
{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">

View File

@@ -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,40 +117,39 @@ 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">
<TextInput
placeholder="Search number, name or seafarer №…"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={280}
/>
<Select
placeholder="All statuses"
data={STATUS_FILTERS}
value={status}
onChange={(v) => {
setStatus(v as SeafarerDocumentStatus | null);
setPage(0);
}}
clearable
w={220}
/>
</Group>
<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} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={260}
/>
<Select
placeholder="All statuses"
data={STATUS_FILTERS}
value={status}
onChange={(v) => {
setStatus(v as SeafarerDocumentStatus | null);
setPage(0);
}}
clearable
w={200}
/>
</>
}
itemCount={data?.total ?? 0}
pageIndex={page}
onPageChange={setPage}

View File

@@ -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,9 +138,10 @@ export function SeafarerDocumentReviewPage() {
{document.documentNumber}
</Badge>
)}
</Group>
</div>
<Group gap="xs">
</>
}
action={
<Group gap="xs">
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button loading={confirming} onClick={() => run(() => confirmPayment(id).unwrap(), 'Payment confirmed')}>
@@ -174,8 +173,9 @@ export function SeafarerDocumentReviewPage() {
</Button>
</RequirePermission>
)}
</Group>
</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 ? (
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
{applicant.registrationNumber}
</Link>
) : (
applicant?.registrationNumber
)
}
/>
</Table.Tbody>
</Table>
<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}`}>
<Text fz="xs" c="blue.6">{applicant.registrationNumber}</Text>
</Link>
</>
)}
</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 />

View File

@@ -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,40 +105,39 @@ 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">
<TextInput
placeholder="Search number, name or ID…"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={280}
/>
<Select
placeholder="All statuses"
data={STATUS_FILTERS}
value={status}
onChange={(v) => {
setStatus(v as SeafarerRegistrationStatus | null);
setPage(0);
}}
clearable
w={220}
/>
</Group>
<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} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={260}
/>
<Select
placeholder="All statuses"
data={STATUS_FILTERS}
value={status}
onChange={(v) => {
setStatus(v as SeafarerRegistrationStatus | null);
setPage(0);
}}
clearable
w={200}
/>
</>
}
itemCount={data?.total ?? 0}
pageIndex={page}
onPageChange={setPage}

View File

@@ -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,24 +93,26 @@ 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>
<Group gap="xs">
</>
}
action={
<Group gap="xs">
{canDecide && (
<>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REQUEST_ADJUSTMENT]} hideOnly>
@@ -146,8 +132,9 @@ export function SeafarerRegistrationReviewPage() {
</RequirePermission>
</>
)}
</Group>
</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];

View File

@@ -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,71 +43,77 @@ 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>
</SimpleGrid>
</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>
{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>
<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="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>
</Paper>
)}
</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)}>

View File

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

View File

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

View File

@@ -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,26 +221,28 @@ 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>
<TextInput
placeholder="Name, registration № or IMO"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
</>
}
action={
<TextInput
placeholder="Name, registration № or IMO"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
}
/>
<AdvancedTable
tableName="Vessel register"

View File

@@ -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
? `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>
<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.'
}
/>
<ReportFilters
query={query}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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