mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -167,7 +167,7 @@ export function DocumentRequirementEditorDrawer({
|
||||
<>
|
||||
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
|
||||
<ConditionBuilder
|
||||
value={(draft.conditionExpression ?? null) as ConditionValue | null}
|
||||
value={(draft.conditionExpression ?? { field: '', equals: '' }) as ConditionValue}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))}
|
||||
targets={conditionTargets}
|
||||
palette={palette}
|
||||
|
||||
@@ -44,6 +44,7 @@ export function CertificateRequirementsPage() {
|
||||
'certReq.subtitle',
|
||||
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
|
||||
)}
|
||||
noMargin
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
|
||||
@@ -16,6 +16,15 @@ export function certificationColumns(
|
||||
header: t('certification.columns.description'),
|
||||
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.rank', 'Rank'),
|
||||
cell: ({ row }) =>
|
||||
row.original.rankKey ? (
|
||||
<Badge size="sm" variant="outline" color="violet">{row.original.rankKey}</Badge>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed">—</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Card,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
||||
import {IconPlus} from '@tabler/icons-react';
|
||||
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
useUpdateCertificationMutation,
|
||||
useDeleteCertificationMutation,
|
||||
} from '../../api/certification-api';
|
||||
import type { Certification } from '../../types/certification';
|
||||
import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
|
||||
import { certificationColumns } from './columns';
|
||||
import { certificationActionsColumn } from './actions';
|
||||
|
||||
@@ -33,7 +22,7 @@ function CertificationForm({
|
||||
}: {
|
||||
editing: Certification | null;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => void;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -41,6 +30,7 @@ function CertificationForm({
|
||||
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
|
||||
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
||||
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
||||
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -48,7 +38,7 @@ function CertificationForm({
|
||||
notify.error('Name fields are required');
|
||||
return;
|
||||
}
|
||||
onSubmit({ nameEn, nameAm, descEn, descAm }, !!editing);
|
||||
onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -59,6 +49,17 @@ function CertificationForm({
|
||||
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
|
||||
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Select
|
||||
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
|
||||
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
|
||||
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
|
||||
data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]}
|
||||
value={rankKey}
|
||||
onChange={setRankKey}
|
||||
size="sm"
|
||||
clearable
|
||||
searchable
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||
@@ -91,15 +92,17 @@ export function CertificationPage() {
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => {
|
||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateCert({ id: editing.id, name, description }).unwrap();
|
||||
// null clears a previously-set rank; undefined would leave it
|
||||
// untouched server-side, so the two are not interchangeable here.
|
||||
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap();
|
||||
notify.success(t('certification.updated'));
|
||||
} else {
|
||||
await createCert({ name, description }).unwrap();
|
||||
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
|
||||
notify.success(t('certification.created'));
|
||||
}
|
||||
resetForm();
|
||||
@@ -120,7 +123,8 @@ export function CertificationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
||||
if (isError)
|
||||
return <ErrorState title={t('certification.loadError')} onRetry={refetch} />;
|
||||
|
||||
const columns = [
|
||||
...certificationColumns(t, locale),
|
||||
@@ -134,17 +138,18 @@ export function CertificationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t('certification.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<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
|
||||
|
||||
@@ -3,11 +3,30 @@ export interface LocalePair {
|
||||
am: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* STCW rank an exam certification is for — the join that lets the
|
||||
* schedule-exam picker offer only sittings valid for an application's rank.
|
||||
* Matches `LicenseApplication.formData.certificate.rank` / `rankEngine` /
|
||||
* `proficiency` on the backend. Not every certification is on the examined
|
||||
* ladder, so this stays a plain optional string rather than a required enum.
|
||||
*/
|
||||
export const RANK_KEY_OPTIONS = [
|
||||
{ value: 'OOW_DECK', label: 'Officer of the Watch (Deck)' },
|
||||
{ value: 'CHIEF_MATE', label: 'Chief Mate' },
|
||||
{ value: 'MASTER', label: 'Master' },
|
||||
{ value: 'OOW_ENGINE', label: 'Officer of the Watch (Engine)' },
|
||||
{ value: 'SECOND_ENGINEER', label: 'Second Engineer' },
|
||||
{ value: 'CHIEF_ENGINEER', label: 'Chief Engineer' },
|
||||
{ value: 'ABLE_SEAFARER_DECK', label: 'Able Seafarer Deck' },
|
||||
{ value: 'ABLE_SEAFARER_ENGINE', label: 'Able Seafarer Engine' },
|
||||
] as const;
|
||||
|
||||
export interface Certification {
|
||||
id: string;
|
||||
name: LocalePair;
|
||||
description: LocalePair;
|
||||
isActive: boolean;
|
||||
rankKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -20,6 +39,7 @@ export interface ListResponse<T> {
|
||||
export interface CreateCertificationPayload {
|
||||
name: LocalePair;
|
||||
description: LocalePair;
|
||||
rankKey?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCertificationPayload {
|
||||
@@ -27,4 +47,6 @@ export interface UpdateCertificationPayload {
|
||||
name?: LocalePair;
|
||||
description?: LocalePair;
|
||||
isActive?: boolean;
|
||||
/** Omit to leave unchanged, null to clear a previously-set rank. */
|
||||
rankKey?: string | null;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import type { Profession } from "../../types/configuration";
|
||||
import { professionColumns } from "./columns";
|
||||
import { professionActionsColumn } from "./actions";
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
interface ProfFormValues {
|
||||
nameEn: string;
|
||||
@@ -378,7 +379,7 @@ export function ConfigurationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>{t("configuration.title")}</Title>
|
||||
<PageHeader title={t("configuration.title")} noMargin />
|
||||
|
||||
<Tabs defaultValue="professions">
|
||||
<Tabs.List>
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
|
||||
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
IconAlertTriangle,
|
||||
IconCreditCard,
|
||||
IconFileText,
|
||||
IconInbox,
|
||||
IconUserCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
useGetAssignedToMeQuery,
|
||||
useGetQueueQuery,
|
||||
useListSeafarerDocumentsQuery,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
type LicenseApplication,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
AdvancedTable,
|
||||
PageHeader,
|
||||
PageLoader,
|
||||
StatTile,
|
||||
WaitingFor,
|
||||
useServerTable,
|
||||
} from '@ema-platform/ui';
|
||||
import { dashboardQueueColumns } from './columns';
|
||||
|
||||
/**
|
||||
* Backoffice home.
|
||||
*
|
||||
* Shows the licence pipeline, which is the part of the platform that has real
|
||||
* data behind it. The previous version charted invented registration volumes
|
||||
* and a fictional breakdown of staff roles.
|
||||
* Every figure here is counted from a queue the officer can open, and each
|
||||
* tile navigates to the list it counted — a dashboard that cannot be drilled
|
||||
* into is a poster. Nothing is charted: the platform exposes queues, not time
|
||||
* series, and an earlier version of this page invented both a registration
|
||||
* trend and a staff-role breakdown rather than admit that.
|
||||
*
|
||||
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
|
||||
* queues are permission-gated and an officer without them simply gets no
|
||||
* count — never a broken page — so the tiles read `—` rather than `0`, which
|
||||
* would be a lie.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -27,6 +44,13 @@ export function DashboardPage() {
|
||||
const mine = useGetAssignedToMeQuery();
|
||||
const table = useServerTable();
|
||||
|
||||
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
|
||||
const seamanBooks = useListSeafarerDocumentsQuery({
|
||||
kind: 'SEAMAN_BOOK',
|
||||
status: 'PAYMENT_PENDING',
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
||||
}
|
||||
@@ -34,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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ExamIncident,
|
||||
CreateIncidentPayload,
|
||||
ResolveIncidentPayload,
|
||||
RegradeOutcome,
|
||||
} from '../types/exam';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
@@ -20,7 +21,9 @@ const examApi = baseApi.injectEndpoints({
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
getExam: builder.query<Exam, string>({
|
||||
query: (id) => `/exams/${id}?i=questions`,
|
||||
// Nested relation so CHOICE questions carry their options here too —
|
||||
// needed to print real answer choices instead of blank A/B/C/D lines.
|
||||
query: (id) => `/exams/${id}?i=questions,questions.options`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createExam: builder.mutation<Exam, CreateExamPayload>({
|
||||
@@ -90,6 +93,14 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Staff-triggered re-run of auto-grading for one finalized attempt. */
|
||||
regradeAttempt: builder.mutation<RegradeOutcome, string>({
|
||||
query: (attemptId) => ({
|
||||
url: `/exam-attempts/${attemptId}/regrade`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -107,4 +118,5 @@ export const {
|
||||
useGetExamIncidentsQuery,
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
useRegradeAttemptMutation,
|
||||
} = examApi;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconUserCheck } from '@tabler/icons-react';
|
||||
import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
|
||||
import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) =>
|
||||
|
||||
export function examCandidateColumns(
|
||||
t: TFunction,
|
||||
handlers: { onRecord: (registration: ExamRegistration) => void },
|
||||
handlers: {
|
||||
onRecord: (registration: ExamRegistration) => void;
|
||||
onRegrade: (registration: ExamRegistration) => void;
|
||||
regrading?: string | null;
|
||||
},
|
||||
): AdvancedColumn<ExamRegistration>[] {
|
||||
return [
|
||||
{
|
||||
@@ -78,21 +82,51 @@ export function examCandidateColumns(
|
||||
header: '',
|
||||
label: t('exam.candidates.record'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordAttendanceMutation,
|
||||
useRegradeAttemptMutation,
|
||||
} from '../../api/exam-api';
|
||||
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
|
||||
import { candidateName, examCandidateColumns } from './columns';
|
||||
@@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
|
||||
const [regradeAttempt] = useRegradeAttemptMutation();
|
||||
const [regrading, setRegrading] = useState<string | null>(null);
|
||||
const [target, setTarget] = useState<ExamRegistration | null>(null);
|
||||
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
|
||||
const [remark, setRemark] = useState('');
|
||||
const table = useServerTable();
|
||||
|
||||
const regrade = async (registration: ExamRegistration) => {
|
||||
const attemptId = registration.attempt?.id;
|
||||
if (!attemptId) return;
|
||||
setRegrading(attemptId);
|
||||
try {
|
||||
const outcome = await regradeAttempt(attemptId).unwrap();
|
||||
if (outcome.graded) {
|
||||
notify.success(t('exam.candidates.regraded'));
|
||||
} else {
|
||||
notify.error(t('exam.candidates.regradeNotEligible', { reason: outcome.reason }));
|
||||
}
|
||||
refetch();
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.candidates.regradeError')));
|
||||
} finally {
|
||||
setRegrading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = (registration: ExamRegistration) => {
|
||||
setTarget(registration);
|
||||
setStatus(
|
||||
@@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
) : (
|
||||
<AdvancedTable
|
||||
tableName={t('exam.candidates.section')}
|
||||
columns={examCandidateColumns(t, { onRecord: startRecording })}
|
||||
columns={examCandidateColumns(t, {
|
||||
onRecord: startRecording,
|
||||
onRegrade: regrade,
|
||||
regrading,
|
||||
})}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
OPEN: 'red',
|
||||
UNDER_REVIEW: 'yellow',
|
||||
RESOLVED: 'teal',
|
||||
DISMISSED: 'gray',
|
||||
const STATUS_TONE: Record<ExamIncidentStatus, StatusTone> = {
|
||||
OPEN: 'danger',
|
||||
UNDER_REVIEW: 'warning',
|
||||
RESOLVED: 'success',
|
||||
DISMISSED: 'neutral',
|
||||
};
|
||||
|
||||
export function examIncidentColumns(
|
||||
@@ -56,13 +58,12 @@ export function examIncidentColumns(
|
||||
{
|
||||
header: t('exam.incidents.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status] ?? 'neutral'}
|
||||
label={t(`exam.incidentStatus.${row.original.status}`)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[row.original.status] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.incidentStatus.${row.original.status}`)}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import {
|
||||
@@ -57,13 +58,13 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
||||
import { PageLoader } from '@ema-platform/ui';
|
||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
ACTIVE: "blue",
|
||||
COMPLETED: "teal",
|
||||
CANCELLED: "red",
|
||||
POSTPONED: "orange",
|
||||
PUBLISHED: "green",
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
PENDING: 'neutral',
|
||||
ACTIVE: 'info',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
POSTPONED: 'pending',
|
||||
PUBLISHED: 'success',
|
||||
};
|
||||
|
||||
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
|
||||
@@ -171,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">
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Badge, Text } from "@mantine/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Exam } from "../../types/exam";
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
ACTIVE: "blue",
|
||||
COMPLETED: "teal",
|
||||
CANCELLED: "red",
|
||||
POSTPONED: "orange",
|
||||
PUBLISHED: "green",
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
PENDING: 'neutral',
|
||||
ACTIVE: 'info',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
POSTPONED: 'pending',
|
||||
PUBLISHED: 'success',
|
||||
};
|
||||
|
||||
export function examColumns(
|
||||
@@ -72,9 +74,12 @@ export function examColumns(
|
||||
{
|
||||
header: t("exam.columns.status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{t(`exam.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status]}
|
||||
label={t(`exam.status.${row.original.status}`)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Modal,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
TextInput,
|
||||
Textarea,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
@@ -35,6 +33,7 @@ import {
|
||||
import type { Exam } from "../../types/exam";
|
||||
import { examColumns } from "./columns";
|
||||
import { examActionsColumn } from "./actions";
|
||||
import { ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
|
||||
function ExamForm({
|
||||
editing,
|
||||
@@ -77,21 +76,23 @@ function ExamForm({
|
||||
editing?.cuttingPoint ?? 0,
|
||||
);
|
||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("basic");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!certificationId ||
|
||||
!titleEn ||
|
||||
!titleAm ||
|
||||
!date ||
|
||||
!type ||
|
||||
!form ||
|
||||
!venue ||
|
||||
!adminMethod ||
|
||||
!evalMethod
|
||||
) {
|
||||
notify.error("Please fill all required fields");
|
||||
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.fillRequiredBasic"));
|
||||
return;
|
||||
}
|
||||
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.directionBothLanguages"));
|
||||
return;
|
||||
}
|
||||
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
|
||||
setActiveTab("settings");
|
||||
notify.error(t("exam.form.fillRequiredSettings"));
|
||||
return;
|
||||
}
|
||||
onSubmit(
|
||||
@@ -121,7 +122,7 @@ function ExamForm({
|
||||
return (
|
||||
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs value={activeTab} onChange={setActiveTab} variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
||||
{t("exam.form.basicInfo")}
|
||||
@@ -252,6 +253,12 @@ function ExamForm({
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
disabled={adminMethod === "ONLINE"}
|
||||
description={
|
||||
adminMethod === "ONLINE"
|
||||
? t("exam.form.onlineChoiceOnlyHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.administration")}
|
||||
@@ -261,7 +268,14 @@ function ExamForm({
|
||||
{ value: "ONLINE", label: t("exam.form.online") },
|
||||
]}
|
||||
value={adminMethod}
|
||||
onChange={setAdminMethod}
|
||||
onChange={(value) => {
|
||||
setAdminMethod(value);
|
||||
// Online exams are graded automatically, and that only
|
||||
// has an answer model for CHOICE — matches the backend
|
||||
// rule (online_exam_requires_choice_form), not just a
|
||||
// UI nicety.
|
||||
if (value === "ONLINE") setForm("CHOICE");
|
||||
}}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
@@ -291,12 +305,22 @@ function ExamForm({
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.cuttingPoint")}
|
||||
placeholder={t("exam.form.cuttingPointPlaceholder")}
|
||||
placeholder={
|
||||
evalMethod === "PERCENTAGE"
|
||||
? t("exam.form.cuttingPointPercentagePlaceholder")
|
||||
: t("exam.form.cuttingPointPlaceholder")
|
||||
}
|
||||
value={cuttingPoint}
|
||||
onChange={(v) => setCuttingPoint(Number(v))}
|
||||
min={0}
|
||||
max={evalMethod === "PERCENTAGE" ? 100 : undefined}
|
||||
size="sm"
|
||||
required
|
||||
withAsterisk
|
||||
description={
|
||||
evalMethod === "PERCENTAGE"
|
||||
? t("exam.form.cuttingPointPercentageHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
@@ -353,6 +377,11 @@ export function ExamPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
const [changingStatusId, setChangingStatusId] = useState<string | null>(null);
|
||||
const [statusTarget, setStatusTarget] = useState<Exam | null>(null);
|
||||
const [pendingStatus, setPendingStatus] = useState<Exam["status"] | null>(null);
|
||||
const [statusOpened, { open: openStatus, close: closeStatus }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const certOptions = certifications
|
||||
.filter((c) => c.isActive)
|
||||
@@ -403,6 +432,21 @@ export function ExamPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeStatus = async () => {
|
||||
if (!statusTarget || !pendingStatus) return;
|
||||
setChangingStatusId(statusTarget.id);
|
||||
try {
|
||||
await updateExam({ id: statusTarget.id, status: pendingStatus }).unwrap();
|
||||
notify.success(t("exam.updated"));
|
||||
closeStatus();
|
||||
setStatusTarget(null);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setChangingStatusId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
@@ -416,13 +460,7 @@ export function ExamPage() {
|
||||
};
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
color="red"
|
||||
title={t("exam.loadError")}
|
||||
/>
|
||||
);
|
||||
return <ErrorState title={t("exam.loadError")} onRetry={refetch} />;
|
||||
|
||||
const columns = [
|
||||
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
|
||||
@@ -436,6 +474,12 @@ export function ExamPage() {
|
||||
openDelete();
|
||||
},
|
||||
onDetails: (exam) => navigate(`/exams/${exam.id}`),
|
||||
onOpenStatusChange: (exam) => {
|
||||
setStatusTarget(exam);
|
||||
setPendingStatus(exam.status);
|
||||
openStatus();
|
||||
},
|
||||
changingStatusId,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -443,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,19 @@ export type ExamStatus =
|
||||
| "POSTPONED"
|
||||
| "PUBLISHED";
|
||||
|
||||
/** Only populated when the exam is fetched with `?i=questions,questions.options`. */
|
||||
export interface QuestionOptionBrief {
|
||||
id: string;
|
||||
text: LocalePair;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface QuestionBrief {
|
||||
id: string;
|
||||
title: LocalePair;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
options?: QuestionOptionBrief[];
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
@@ -118,8 +126,14 @@ export interface ExamRegistration {
|
||||
lastName: string | null;
|
||||
seafarerNumber: string | null;
|
||||
};
|
||||
/** The candidate's online sitting, when one has been started. */
|
||||
attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
||||
}
|
||||
|
||||
export type RegradeOutcome =
|
||||
| { graded: true; resultId: string }
|
||||
| { graded: false; reason: string };
|
||||
|
||||
export interface RecordAttendancePayload {
|
||||
registrationId: string;
|
||||
status: AttendanceStatus;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Item } from '../../api/item-api';
|
||||
|
||||
const STATUS_COLORS: Record<Item['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
ACTIVE: 'green',
|
||||
ARCHIVED: 'orange',
|
||||
const STATUS_TONES: Record<Item['status'], StatusTone> = {
|
||||
DRAFT: 'neutral',
|
||||
ACTIVE: 'success',
|
||||
ARCHIVED: 'pending',
|
||||
};
|
||||
|
||||
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
|
||||
@@ -14,7 +15,7 @@ export function itemColumns(showDate: (date: string) => string): AdvancedColumn<
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={STATUS_COLORS[row.original.status]}>{row.original.status}</Badge>
|
||||
<StatusBadge tone={STATUS_TONES[row.original.status]} label={row.original.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Stack, Title, Paper } from '@mantine/core';
|
||||
import {Stack, Paper} from '@mantine/core';
|
||||
import { ItemTable } from '../components/ItemTable';
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
export function ItemPage() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>Items</Title>
|
||||
<PageHeader title="Items" noMargin />
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<ItemTable />
|
||||
</Paper>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const LICENSE_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'green',
|
||||
EXPIRED: 'yellow',
|
||||
SUSPENDED: 'orange',
|
||||
CANCELLED: 'red',
|
||||
SUPERSEDED: 'gray',
|
||||
const LICENSE_STATUS_TONES: Record<string, StatusTone> = {
|
||||
ACTIVE: 'success',
|
||||
EXPIRED: 'warning',
|
||||
SUSPENDED: 'pending',
|
||||
CANCELLED: 'danger',
|
||||
SUPERSEDED: 'neutral',
|
||||
};
|
||||
|
||||
export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
|
||||
@@ -70,13 +72,12 @@ export function licenseRegisterColumns(
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={LICENSE_STATUS_TONES[row.original.status] ?? 'neutral'}
|
||||
label={row.original.status}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={LICENSE_STATUS_COLORS[row.original.status] ?? 'gray'}
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Textarea} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -151,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>
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/c
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
import { useGetEligibleExamsQuery } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
applicationId: string;
|
||||
applicantName: string;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
@@ -22,27 +23,30 @@ interface Props {
|
||||
*
|
||||
* Sessions are picked from the exam calendar rather than typed, because the
|
||||
* candidate joins a scheduled sitting — this is an assignment, not the creation
|
||||
* of a per-candidate appointment.
|
||||
* of a per-candidate appointment. Scoped to sittings whose certification
|
||||
* matches this application's rank, so a Chief Mate candidate cannot be seated
|
||||
* into an OOW Deck sitting by accident.
|
||||
*/
|
||||
export function ScheduleExamModal({
|
||||
opened,
|
||||
applicationId,
|
||||
applicantName,
|
||||
loading,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: exams, isLoading } = useGetExamsQuery(undefined, { skip: !opened });
|
||||
const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
|
||||
const [examId, setExamId] = useState<string | null>(null);
|
||||
const [admissionNumber, setAdmissionNumber] = useState('');
|
||||
|
||||
const options = (exams?.items ?? []).map((exam) => ({
|
||||
const options = (exams ?? []).map((exam) => ({
|
||||
value: exam.id,
|
||||
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
}));
|
||||
const selected = exams?.items?.find((exam) => exam.id === examId);
|
||||
const selected = exams?.find((exam) => exam.id === examId);
|
||||
|
||||
function confirm() {
|
||||
if (!examId) return;
|
||||
@@ -72,7 +76,7 @@ export function ScheduleExamModal({
|
||||
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
|
||||
{t(
|
||||
'review.scheduleExam.noSessions',
|
||||
'No exam sessions exist yet. Create one in the Exams area first.',
|
||||
'No exam sessions for this rank exist yet. Create one in the Exams area first.',
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
@@ -73,6 +72,7 @@ import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
|
||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
|
||||
import { licenseQueueColumns } from "./columns";
|
||||
import { licenseQueueActionsColumn } from "./actions";
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
@@ -503,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
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
useLocalized,
|
||||
useApproveDocumentsMutation,
|
||||
useAssignApplicationMutation,
|
||||
useClaimApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
@@ -198,6 +199,7 @@ export function LicenseReviewPage() {
|
||||
[requirements],
|
||||
);
|
||||
|
||||
const [claimApplication] = useClaimApplicationMutation();
|
||||
const [completeReview] = useCompleteReviewMutation();
|
||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||
const [approveDocuments] = useApproveDocumentsMutation();
|
||||
@@ -531,8 +533,12 @@ export function LicenseReviewPage() {
|
||||
try {
|
||||
switch (action.id) {
|
||||
case "claim":
|
||||
// Claim is fired from the queue in practice; kept here for the case
|
||||
// where an officer opens an unclaimed application directly.
|
||||
// Usually fired from the queue, but an officer can also open an
|
||||
// unclaimed application directly and claim it from here.
|
||||
await run(
|
||||
() => claimApplication(id).unwrap(),
|
||||
t("review.done.claim", "Application claimed"),
|
||||
);
|
||||
break;
|
||||
case "complete-review":
|
||||
await run(
|
||||
@@ -724,7 +730,7 @@ export function LicenseReviewPage() {
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{headerName}</Title>
|
||||
<Title order={2}>{headerName}</Title>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.applicationNumber}
|
||||
@@ -1156,6 +1162,7 @@ export function LicenseReviewPage() {
|
||||
|
||||
<ScheduleExamModal
|
||||
opened={scheduleExamOpen}
|
||||
applicationId={id}
|
||||
applicantName={
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Paper,
|
||||
Text,
|
||||
Grid,
|
||||
Modal,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Group, Button, Paper, Text, Grid, Modal, ActionIcon, Tooltip, Loader, Center, Alert} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui';
|
||||
import { ModalFooter, notify, PageHeader, PageLoader, useErrorHandler } from '@ema-platform/ui';
|
||||
import { LocationTree } from '../components/LocationTree';
|
||||
import { LocationDetail } from '../components/LocationDetail';
|
||||
import { LocationForm } from '../components/LocationForm';
|
||||
@@ -108,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
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Badge, Card, Center, Container, Grid, Group, Loader, SimpleGrid, Stack, Text} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -74,12 +62,10 @@ export function LogisticsHeadDashboardPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Logistics overview
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="lg">
|
||||
Licence applications currently in the department.
|
||||
</Text>
|
||||
<PageHeader
|
||||
title="Logistics overview"
|
||||
subtitle="Licence applications currently in the department."
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||
{stats.map((stat) => (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import {
|
||||
seaServiceDays,
|
||||
type MedicalCertificate,
|
||||
@@ -18,10 +20,10 @@ export function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
|
||||
SUBMITTED: 'yellow',
|
||||
VERIFIED: 'teal',
|
||||
REJECTED: 'red',
|
||||
const STATUS_TONE: Record<SeafarerRecordStatus, StatusTone> = {
|
||||
SUBMITTED: 'warning',
|
||||
VERIFIED: 'success',
|
||||
REJECTED: 'danger',
|
||||
};
|
||||
|
||||
/** Only meaningful now the queue can show ruled records too. */
|
||||
@@ -33,9 +35,12 @@ function statusColumn<T extends { status: SeafarerRecordStatus }>(
|
||||
label: t('recordVerification.columns.status', 'Status'),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{t(`recordVerification.status.${row.original.status}`, row.original.status)}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status]}
|
||||
label={t(`recordVerification.status.${row.original.status}`, row.original.status)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Badge, Button, Center, Container, Group, Loader, Modal, Paper, SegmentedControl, Stack, Text, Textarea} from '@mantine/core';
|
||||
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
|
||||
import {
|
||||
AdvancedTable,
|
||||
notify,
|
||||
PdfPreviewModal,
|
||||
type AdvancedColumn,
|
||||
} from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, PdfPreviewModal, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -375,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}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -249,7 +249,7 @@ export function ProfilePage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={900}>
|
||||
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
|
||||
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} noMargin />
|
||||
|
||||
{/* Profile summary */}
|
||||
<Paper p="lg" shadow="sm" radius="lg" withBorder>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import type {
|
||||
Question,
|
||||
QuestionOption,
|
||||
ListResponse,
|
||||
CreateQuestionPayload,
|
||||
UpdateQuestionPayload,
|
||||
ReviewQuestionPayload,
|
||||
SetQuestionOptionsPayload,
|
||||
} from '../types/question';
|
||||
|
||||
const questionApi = baseApi.injectEndpoints({
|
||||
@@ -17,6 +19,11 @@ const questionApi = baseApi.injectEndpoints({
|
||||
query: (id) => `/questions/${id}`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
/** Same question, with `options` populated — the MCQ authoring editor. */
|
||||
getQuestionWithOptions: builder.query<Question, string>({
|
||||
query: (id) => `/questions/${id}?i=options`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createQuestion: builder.mutation<Question, CreateQuestionPayload>({
|
||||
query: (body) => ({ url: '/questions', method: 'POST', body }),
|
||||
invalidatesTags: ['Api'],
|
||||
@@ -47,6 +54,15 @@ const questionApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Full replace of a CHOICE question's options + correct-answer set (Phase 2). */
|
||||
setQuestionOptions: builder.mutation<QuestionOption[], SetQuestionOptionsPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/questions/${id}/options`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -54,9 +70,11 @@ const questionApi = baseApi.injectEndpoints({
|
||||
export const {
|
||||
useGetQuestionsQuery,
|
||||
useGetQuestionQuery,
|
||||
useGetQuestionWithOptionsQuery,
|
||||
useCreateQuestionMutation,
|
||||
useUpdateQuestionMutation,
|
||||
useDeleteQuestionMutation,
|
||||
useSubmitQuestionMutation,
|
||||
useReviewQuestionMutation,
|
||||
useSetQuestionOptionsMutation,
|
||||
} = questionApi;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetQuestionWithOptionsQuery,
|
||||
useSetQuestionOptionsMutation,
|
||||
} from '../api/question-api';
|
||||
|
||||
interface DraftOption {
|
||||
text: BilingualValue;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCQ options + correct-answer editor for a CHOICE-form question (Phase 2).
|
||||
*
|
||||
* Only reachable while editing an already-created question — options attach
|
||||
* to a question id, matching the backend's `PUT /questions/:id/options`
|
||||
* full-replace endpoint. Nothing here is ever shown to a candidate; this is
|
||||
* the authoring side only.
|
||||
*/
|
||||
export function QuestionOptionsEditor({ questionId }: { questionId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: question, isFetching } = useGetQuestionWithOptionsQuery(questionId);
|
||||
const [setOptions, { isLoading: isSaving }] = useSetQuestionOptionsMutation();
|
||||
const [draft, setDraft] = useState<DraftOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!question) return;
|
||||
const existing = question.options ?? [];
|
||||
setDraft(
|
||||
existing.length
|
||||
? existing
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((o) => ({ text: o.text, isCorrect: false }))
|
||||
: [
|
||||
{ text: { en: '', am: '' }, isCorrect: false },
|
||||
{ text: { en: '', am: '' }, isCorrect: false },
|
||||
],
|
||||
);
|
||||
// isCorrect never comes back from the API by design — an examiner
|
||||
// re-editing options re-marks the correct one(s) rather than us
|
||||
// pretending to know what they were.
|
||||
}, [question]);
|
||||
|
||||
const updateField = (index: number, lang: keyof BilingualValue, value: string) => {
|
||||
setDraft((prev) =>
|
||||
prev.map((o, i) => (i === index ? { ...o, text: { ...o.text, [lang]: value } } : o)),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCorrect = (index: number) => {
|
||||
setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, isCorrect: !o.isCorrect } : o)));
|
||||
};
|
||||
|
||||
const addOption = () => {
|
||||
setDraft((prev) => [...prev, { text: { en: '', am: '' }, isCorrect: false }]);
|
||||
};
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
setDraft((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (draft.length < 2) {
|
||||
notify.error(t('question.options.needAtLeastTwo'));
|
||||
return;
|
||||
}
|
||||
if (!draft.some((o) => o.isCorrect)) {
|
||||
notify.error(t('question.options.needOneCorrect'));
|
||||
return;
|
||||
}
|
||||
if (draft.some((o) => !o.text.en.trim() || !o.text.am.trim())) {
|
||||
notify.error(t('question.options.textRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setOptions({ id: questionId, options: draft }).unwrap();
|
||||
notify.success(t('question.options.saved'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching) return <Loader size="sm" />;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Alert icon={<IconInfoCircle size={15} />} color="blue" variant="light">
|
||||
{t('question.options.hint')}
|
||||
</Alert>
|
||||
{draft.map((option, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="center">
|
||||
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
|
||||
<Stack gap={6} style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
label={t('question.options.optionEn', { number: index + 1 })}
|
||||
value={option.text.en}
|
||||
onChange={(e) => updateField(index, 'en', e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t('question.options.optionAm', { number: index + 1 })}
|
||||
value={option.text.am}
|
||||
onChange={(e) => updateField(index, 'am', e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
</Stack>
|
||||
<Checkbox
|
||||
label={t('question.options.correct')}
|
||||
checked={option.isCorrect}
|
||||
onChange={() => toggleCorrect(index)}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
disabled={draft.length <= 2}
|
||||
onClick={() => removeOption(index)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconPlus size={14} />} onClick={addOption}>
|
||||
{t('question.options.addOption')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isSaving} onClick={handleSave}>
|
||||
{t('question.options.save')}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{t('question.options.replaceNotice')}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ActionIcon, Button, Group } from '@mantine/core';
|
||||
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconGavel,
|
||||
IconSend,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -21,52 +27,64 @@ export function questionActionsColumn(
|
||||
cell: ({ row }) => {
|
||||
const q = row.original;
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{(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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,17 @@ export type QuestionStatus =
|
||||
| 'REJECTED'
|
||||
| 'RETIRED';
|
||||
|
||||
/**
|
||||
* A CHOICE option, as returned by the authoring/QC endpoints. Never carries
|
||||
* a correctness flag — the API's own answer-key table is never joined into
|
||||
* this response either, so there's nothing to accidentally serialize here.
|
||||
*/
|
||||
export interface QuestionOption {
|
||||
id: string;
|
||||
text: LocalePair;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
certificationId: string;
|
||||
@@ -32,6 +43,8 @@ export interface Question {
|
||||
submittedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Only populated when explicitly requested (`?i=options`). */
|
||||
options?: QuestionOption[];
|
||||
}
|
||||
|
||||
export interface ReviewQuestionPayload {
|
||||
@@ -64,3 +77,13 @@ export interface UpdateQuestionPayload {
|
||||
points?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface QuestionOptionInput {
|
||||
text: LocalePair;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
export interface SetQuestionOptionsPayload {
|
||||
id: string;
|
||||
options: QuestionOptionInput[];
|
||||
}
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Button, Center, Group, Loader, Modal, Paper, Select, Stack, Text, Textarea} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
@@ -75,12 +62,11 @@ export function ExamAppealsPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2}>{t('result.appeals.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('result.appeals.subtitle')}
|
||||
</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={t('result.appeals.title')}
|
||||
subtitle={t('result.appeals.subtitle')}
|
||||
noMargin
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<AdvancedTable<ExamAppeal>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { IconEye, IconTrash } from '@tabler/icons-react';
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -13,6 +13,7 @@ export function resultActionsColumn(
|
||||
onQc: (result: Result, action: QcAction) => void;
|
||||
onViewDetail: (result: Result) => void;
|
||||
onDelete: (result: Result) => void;
|
||||
onPublish: (result: Result) => void;
|
||||
},
|
||||
): AdvancedColumn<Result> {
|
||||
return {
|
||||
@@ -21,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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
|
||||
import { Badge, Box, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Result, ResultReviewStatus } from '../../types/result';
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
PASSED: 'teal',
|
||||
FAILED: 'red',
|
||||
export const STATUS_TONE: Record<string, StatusTone> = {
|
||||
PASSED: 'success',
|
||||
FAILED: 'danger',
|
||||
};
|
||||
|
||||
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
|
||||
@@ -46,20 +48,22 @@ export function resultColumns(
|
||||
{
|
||||
header: t('result.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status]}
|
||||
label={t(`result.status.${row.original.status}`)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[row.original.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${STATUS_TONE_COLOR[STATUS_TONE[row.original.status]]}-6)`,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
import { useState, useCallback, type ElementType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Table,
|
||||
Badge,
|
||||
Modal,
|
||||
Text,
|
||||
Paper,
|
||||
Card,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Button,
|
||||
ThemeIcon,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconUser,
|
||||
IconCertificate,
|
||||
IconDeviceFloppy,
|
||||
IconPlus,
|
||||
IconClipboardList,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconChartBar,
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
||||
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
|
||||
import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
||||
@@ -53,7 +22,7 @@ import { useGetExamsQuery } from '../../../exam/api/exam-api';
|
||||
import { RecordResultModal } from '../../components/RecordResultModal';
|
||||
import type { Result, ResultBreakdown } from '../../types/result';
|
||||
import type { Exam } from '../../../exam/types/exam';
|
||||
import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns';
|
||||
import { resultColumns, STATUS_TONE, REVIEW_COLOR } from './columns';
|
||||
import { resultActionsColumn, type QcAction } from './actions';
|
||||
|
||||
function ResultStat({
|
||||
@@ -126,6 +95,8 @@ export function ResultPage() {
|
||||
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [publishTarget, setPublishTarget] = useState<Result | null>(null);
|
||||
const [publishOpened, { open: openPublish, close: closePublish }] = useDisclosure(false);
|
||||
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
|
||||
const [detailSaving, setDetailSaving] = useState(false);
|
||||
@@ -262,6 +233,19 @@ export function ResultPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */
|
||||
const handleConfirmPublish = async () => {
|
||||
if (!publishTarget) return;
|
||||
try {
|
||||
const outcome = await publishResults(publishTarget.examId).unwrap();
|
||||
notify.success(t('result.review.publishedCount', outcome));
|
||||
closePublish();
|
||||
setPublishTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.review.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetailClose = () => {
|
||||
closeDetail();
|
||||
setDetailBreakdowns([]);
|
||||
@@ -280,7 +264,7 @@ export function ResultPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
|
||||
if (isError) return <ErrorState title={t('result.loadError')} onRetry={refetch} />;
|
||||
|
||||
const columns = [
|
||||
...resultColumns(t, locale, showDate, getExamTitle),
|
||||
@@ -288,6 +272,7 @@ export function ResultPage() {
|
||||
onQc: openQc,
|
||||
onViewDetail: viewDetail,
|
||||
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
|
||||
onPublish: (r) => { setPublishTarget(r); openPublish(); },
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -295,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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -336,7 +336,7 @@ export function VesselRegistrationFormBuilderPage() {
|
||||
<IconSettings size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Vessel Registration Form Builder</Title>
|
||||
<Title order={2}>Vessel Registration Form Builder</Title>
|
||||
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Vessel } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
||||
REGISTERED: 'green',
|
||||
SUSPENDED: 'orange',
|
||||
DEREGISTERED: 'gray',
|
||||
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
|
||||
REGISTERED: 'success',
|
||||
SUSPENDED: 'pending',
|
||||
DEREGISTERED: 'neutral',
|
||||
};
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -63,13 +65,12 @@ export function vesselRegistrationQueueColumns(
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={VESSEL_STATUS_TONES[row.original.status]}
|
||||
label={row.original.status}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={VESSEL_STATUS_COLORS[row.original.status]}
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Card, Container, Drawer, Group, Loader, Modal, Select, Stack, Table, Text, TextInput, Textarea} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -238,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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -11,6 +11,10 @@ export const am: Translations = {
|
||||
tagline: "የቁጥጥር ማዕከል",
|
||||
},
|
||||
|
||||
a11y: {
|
||||
skipToContent: "ወደ ዋናው ይዘት ዝለል",
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
|
||||
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
|
||||
@@ -255,6 +259,7 @@ export const am: Translations = {
|
||||
choice: "ምርጫ",
|
||||
offline: "ከመስመር ውጪ",
|
||||
online: "በመስመር",
|
||||
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
|
||||
sum: "ድምር",
|
||||
average: "አማካይ",
|
||||
percentage: "መቶኛ",
|
||||
@@ -262,6 +267,11 @@ export const am: Translations = {
|
||||
random: "በዘፈቀደ",
|
||||
cuttingPoint: "የማለፊያ ነጥብ",
|
||||
cuttingPointPlaceholder: "ለማለፍ ዝቅተኛ ነጥብ",
|
||||
cuttingPointPercentagePlaceholder: "ለማለፍ ዝቅተኛ መቶኛ (0-100)",
|
||||
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
|
||||
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
|
||||
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
|
||||
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
|
||||
status: "ሁኔታ",
|
||||
statusPlaceholder: "የፈተና ሁኔታ",
|
||||
pending: "በመጠባበቅ ላይ",
|
||||
@@ -326,6 +336,10 @@ export const am: Translations = {
|
||||
retake: "ድጋሚ {{n}}",
|
||||
firstSitting: "የመጀመሪያ ሙከራ",
|
||||
remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።",
|
||||
regrade: "እንደገና ደረጃ ስጥ",
|
||||
regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።",
|
||||
regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።",
|
||||
regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።",
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: "አልተጠራም",
|
||||
@@ -370,6 +384,8 @@ export const am: Translations = {
|
||||
randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል",
|
||||
randomError: "ጥያቄዎችን መምረጥ አልተቻለም",
|
||||
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
|
||||
cannotReachCuttingPoint:
|
||||
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -687,6 +703,8 @@ export const am: Translations = {
|
||||
returned: "ውጤት ወደ ፈታኙ ተመልሷል",
|
||||
publishedCount: "{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።",
|
||||
publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።",
|
||||
publishConfirmText:
|
||||
"ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?",
|
||||
lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።",
|
||||
originalScore: "የፈታኙ ጠቅላላ",
|
||||
derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",
|
||||
@@ -778,6 +796,22 @@ export const am: Translations = {
|
||||
onlyApprovedUsable: "የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።",
|
||||
error: "ተግባሩ አልተሳካም",
|
||||
},
|
||||
options: {
|
||||
title: "የመልስ አማራጮች",
|
||||
hint: "ትክክለኛውን አማራጭ ምረጥ/ምረጪ። ማስቀመጥ መላውን የአማራጭ ስብስብ ይተካል።",
|
||||
optionLabel: "አማራጭ {{number}}",
|
||||
optionEn: "አማራጭ {{number}} (እንግሊዝኛ)",
|
||||
optionAm: "አማራጭ {{number}} (አማርኛ)",
|
||||
correct: "ትክክለኛ",
|
||||
addOption: "አማራጭ ጨምር",
|
||||
save: "አማራጮችን አስቀምጥ",
|
||||
saved: "አማራጮች ተቀምጠዋል",
|
||||
saveFirst: "መጀመሪያ ጥያቄውን አስቀምጥ፣ ከዚያ አማራጮችን ጨምር።",
|
||||
replaceNotice: "ትክክለኛ መልሶች ከተቀመጡ በኋላ እዚህ አይታዩም — እንደገና ካስተካከልክ/ካስተካከልሽ ዳግም ምረጥ/ምረጪ።",
|
||||
needAtLeastTwo: "ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልገዋል።",
|
||||
needOneCorrect: "ቢያንስ አንድ አማራጭ እንደ ትክክለኛ ምረጥ/ምረጪ።",
|
||||
textRequired: "እያንዳንዱ አማራጭ በሁለቱም ቋንቋዎች ጽሑፍ ያስፈልገዋል።",
|
||||
},
|
||||
},
|
||||
|
||||
configuration: {
|
||||
|
||||
@@ -10,6 +10,11 @@ export const en = {
|
||||
tagline: 'Control Center',
|
||||
},
|
||||
|
||||
// Strings only assistive technology encounters.
|
||||
a11y: {
|
||||
skipToContent: 'Skip to main content',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
serverError: 'Server error. Please try again later.',
|
||||
@@ -253,6 +258,7 @@ export const en = {
|
||||
choice: 'Choice',
|
||||
offline: 'Offline',
|
||||
online: 'Online',
|
||||
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
|
||||
sum: 'Sum',
|
||||
average: 'Average',
|
||||
percentage: 'Percentage',
|
||||
@@ -260,6 +266,11 @@ export const en = {
|
||||
random: 'Random',
|
||||
cuttingPoint: 'Cutting Point (Pass Mark)',
|
||||
cuttingPointPlaceholder: 'Minimum score to pass',
|
||||
cuttingPointPercentagePlaceholder: 'Minimum % to pass (0-100)',
|
||||
cuttingPointPercentageHint: 'Percentage evaluation — capped at 100.',
|
||||
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
|
||||
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
|
||||
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
|
||||
status: 'Status',
|
||||
statusPlaceholder: 'Exam status',
|
||||
pending: 'Pending',
|
||||
@@ -323,6 +334,10 @@ export const en = {
|
||||
retake: 'Retake {{n}}',
|
||||
firstSitting: 'First sitting',
|
||||
remarkRequired: 'A reason is required for a withdrawal or a disqualification.',
|
||||
regrade: 'Regrade',
|
||||
regraded: 'Result created from the graded attempt.',
|
||||
regradeNotEligible: 'Not auto-gradable: {{reason}}. Use Record Result instead.',
|
||||
regradeError: 'Could not regrade this attempt.',
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: 'Not called',
|
||||
@@ -368,6 +383,8 @@ export const en = {
|
||||
randomError: 'Could not draw questions',
|
||||
notEnoughApproved:
|
||||
'Not enough approved questions in the bank for this subject.',
|
||||
cannotReachCuttingPoint:
|
||||
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -687,6 +704,8 @@ export const en = {
|
||||
returned: 'Result returned to the examiner',
|
||||
publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.',
|
||||
publishNeedsExam: 'Filter by an exam first to publish its results.',
|
||||
publishConfirmText:
|
||||
'This publishes every approved result for {{exam}} — not just this one — and notifies each candidate. Continue?',
|
||||
lockedAfterApproval:
|
||||
'This result is approved and can no longer be edited. Return it to the examiner first.',
|
||||
originalScore: 'Examiner total',
|
||||
@@ -780,6 +799,23 @@ export const en = {
|
||||
'Only approved items can be placed on an examination paper.',
|
||||
error: 'Operation failed',
|
||||
},
|
||||
options: {
|
||||
title: 'Answer Options',
|
||||
hint: 'Mark every correct option. Saving replaces the entire option set.',
|
||||
optionLabel: 'Option {{number}}',
|
||||
optionEn: 'Option {{number}} (English)',
|
||||
optionAm: 'Option {{number}} (Amharic)',
|
||||
correct: 'Correct',
|
||||
addOption: 'Add option',
|
||||
save: 'Save options',
|
||||
saved: 'Options saved',
|
||||
saveFirst: 'Save the question first, then add its options.',
|
||||
replaceNotice:
|
||||
'Correct answers are never shown here once saved — re-mark them if you edit this set again.',
|
||||
needAtLeastTwo: 'A question needs at least two options.',
|
||||
needOneCorrect: 'Mark at least one option as correct.',
|
||||
textRequired: 'Every option needs text in both languages.',
|
||||
},
|
||||
},
|
||||
|
||||
configuration: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem, NavSection } from '@ema-platform/ui';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
|
||||
import { SkipLink, MAIN_CONTENT_ID } from '@ema-platform/ui';
|
||||
import { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
|
||||
import { usePermissions } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
@@ -151,6 +152,10 @@ export function BackofficeLayout() {
|
||||
const isSidebar = layoutMode === "sidebar";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* First focusable element on the page, so a keyboard user can bypass
|
||||
the 20-plus nav items instead of tabbing through them every time. */}
|
||||
<SkipLink />
|
||||
<AppShell
|
||||
// The top layout drops its nav strip on small screens — the drawer is
|
||||
// the nav there — so the header shrinks back to a single row with it.
|
||||
@@ -252,7 +257,7 @@ export function BackofficeLayout() {
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
|
||||
<AppShell.Main>
|
||||
<AppShell.Main id={MAIN_CONTENT_ID}>
|
||||
<div key={location.pathname} className="ema-page-enter">
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -287,5 +292,6 @@ export function BackofficeLayout() {
|
||||
/>
|
||||
</Drawer>
|
||||
</AppShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RequirePermission,
|
||||
LICENSE_PERMISSIONS as P,
|
||||
} from '@ema-platform/auth';
|
||||
import { ThemeGallery } from '@ema-platform/ui';
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
@@ -68,6 +69,9 @@ const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: '/um/*', element: <UserManagementPage /> },
|
||||
// Theme visual-regression surface. Unauthenticated by design — it renders
|
||||
// only static primitives, so it needs no API and cannot flake.
|
||||
{ path: '/__gallery', element: <ThemeGallery /> },
|
||||
{ path: '/', element: <LandingRoute /> },
|
||||
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
|
||||
{
|
||||
|
||||
@@ -4,6 +4,10 @@ import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/spotlight/styles.css';
|
||||
// After Mantine's CSS (it defines the variables these tokens resolve to),
|
||||
// before the app's own, which may override them. Relative because the
|
||||
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
|
||||
import '../../../libs/shared/src/lib/theme/semantic.css';
|
||||
import './styles.css';
|
||||
import './app/i18n/config';
|
||||
import { App } from './app/app';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
|
||||
Without it every Amharic string in the app renders in whatever the OS
|
||||
happens to substitute — different on Windows, macOS and Android. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user