Merge pull request #26 from Tria-plc/WorkflowChange

Workflow change
This commit is contained in:
Nati Nigussie
2026-08-24 12:17:08 +03:00
committed by GitHub
132 changed files with 7186 additions and 3121 deletions

4
.gitignore vendored
View File

@@ -30,3 +30,7 @@ apps/backoffice/public/_um/
apps/backoffice/public/tinymce/
local-packages/iamui-extracted/
# Playwright visual-regression artifacts (baselines under apps/e2e/visual are tracked)
test-results/
dist/visual-report/

View File

@@ -1,13 +1,17 @@
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useLocalized, type LicenseType } from '@ema-platform/api';
import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api';
import { groupedTypeOptions } from '../config/designer';
interface Props {
licenseTypes: LicenseType[];
typeId: string | null;
onTypeChange: (id: string | null) => void;
/** The selected licence type's rank ladder — empty for non-CoC/CoP types. */
ranks: Rank[];
rankId: string | null;
onRankChange: (id: string | null) => void;
validityMonths: number;
onValidityChange: (months: number) => void;
currentValidityMonths?: number | null;
@@ -22,6 +26,9 @@ export function DesignerToolbar({
licenseTypes,
typeId,
onTypeChange,
ranks,
rankId,
onRankChange,
validityMonths,
onValidityChange,
currentValidityMonths,
@@ -49,6 +56,23 @@ export function DesignerToolbar({
w={340}
/>
{/* CoC/CoP only — a rank can carry its own design (e.g. Master's
certificate differs from an OOW's). "Default" (null) is the design
every other rank under the type falls back to. */}
{ranks.length > 0 && (
<Select
label={t('designer.rank', 'Rank')}
description={t('designer.rankHint', 'Leave as Default to design for every rank')}
data={[
{ value: '', label: t('designer.rankDefault', 'Default (all ranks)') },
...ranks.map((r) => ({ value: r.id, label: localized(r.name) })),
]}
value={rankId ?? ''}
onChange={(value) => onRankChange(value || null)}
w={220}
/>
)}
{/* Validity lives beside the design because it is the other half of
what a certificate promises. */}
<NumberInput

View File

@@ -26,6 +26,7 @@ import {
useGetBuiltInTemplateQuery,
useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery,
useGetRanksQuery,
useGetTemplateVariablesQuery,
usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation,
@@ -66,14 +67,19 @@ export function CertificateDesignerPage() {
const { data: licenseTypes } = useGetLicenseTypesQuery();
const [typeId, setTypeId] = useState<string | null>(null);
const [rankId, setRankId] = useState<string | null>(null);
const {
data: templates = [],
data: allTemplates = [],
isLoading,
isError,
error,
refetch,
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
// The list is per licence type; a rank-specific design and the type's
// default both come back, so the version list is scoped to whichever the
// toolbar has selected.
const templates = allTemplates.filter((tpl) => (tpl.rankId ?? null) === rankId);
const { data: variables = [] } = useGetTemplateVariablesQuery();
const { data: builtIn } = useGetBuiltInTemplateQuery();
@@ -95,6 +101,27 @@ export function CertificateDesignerPage() {
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
// A rank ladder only exists for CoC/CoP — every other licence type designs
// one certificate for everyone who holds it. Keyed on `key`, not
// `certificateCategory`: that STCW-mapping column is unset on the seeded
// CoC/CoP rows (it's authored later, per StcwMappingPanel), while `key` is
// the stable identity CertificateEligibilityService itself branches on.
// CoC/CoP are each a single LicenseType spanning every department's ladder
// (the applicant's own department, not the type, decides which ladder they
// climb), so the picker offers every rank in the ladder across all
// departments rather than one department's.
const rankCategory: 'COC' | 'COP' | null =
selectedType?.key === 'CERTIFICATE_OF_COMPETENCY'
? 'COC'
: selectedType?.key === 'CERTIFICATE_OF_PROFICIENCY'
? 'COP'
: null;
const isRankScoped = rankCategory !== null;
const { data: allRanks } = useGetRanksQuery(undefined, { skip: !isRankScoped });
const ranks = (allRanks?.items ?? [])
.filter((r) => r.certificateCategory === rankCategory)
.sort((a, b) => a.sortOrder - b.sortOrder);
// Default to the first licence type so the page is never an empty shell.
useEffect(() => {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
@@ -104,6 +131,12 @@ export function CertificateDesignerPage() {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]);
// Switching licence type leaves a stale rank selected from the previous
// type's ladder — reset to the type's default design.
useEffect(() => {
setRankId(null);
}, [typeId]);
function startNewVersion() {
setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
@@ -130,6 +163,12 @@ export function CertificateDesignerPage() {
setTypeId(value);
draft.setSelectedId(null);
}}
ranks={ranks}
rankId={rankId}
onRankChange={(value) => {
setRankId(value);
draft.setSelectedId(null);
}}
validityMonths={validityMonths}
onValidityChange={setValidityMonths}
currentValidityMonths={selectedType?.validityMonths}
@@ -369,6 +408,7 @@ export function CertificateDesignerPage() {
run(async () => {
const created = await createTemplate({
licenseTypeId: typeId as string,
rankId,
name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap();

View File

@@ -1,4 +1,5 @@
import { Autocomplete, Checkbox, Group, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
import { ActionIcon, Autocomplete, Checkbox, Group, Paper, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
import { useLocalized } from '@ema-platform/api';
@@ -7,6 +8,9 @@ import type { ConditionTarget } from '../config/schema-paths';
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
export type ConditionValue = FieldCondition & { previousDocExpired?: string };
/** One editable `anyOf` arm — a single-field condition, same shape a plain condition holds. */
type ConditionArm = Omit<ConditionValue, 'anyOf' | 'previousDocExpired'>;
type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
function operatorOf(condition: ConditionValue | undefined): Operator | null {
@@ -29,36 +33,46 @@ function coerce(raw: string, targetType: string | undefined): string | number |
return raw;
}
/** One-line summary of a condition for read-only chips ("when X = Y", "when X = Y or W = Z"). */
export function describeCondition(
condition: ConditionValue,
t: (key: string, fallback: string) => string,
): string {
if (condition.anyOf?.length) {
return condition.anyOf.map((arm) => describeCondition(arm, t)).join(` ${t('certReq.condition.or', 'or')} `);
}
if (!condition.field) return '';
const parts = [condition.field];
if (condition.equals !== undefined) parts.push(`= ${condition.equals}`);
if (condition.notEquals !== undefined) parts.push(`${condition.notEquals}`);
if (condition.in !== undefined) parts.push(`∈ [${condition.in.join(', ')}]`);
if (condition.isSet !== undefined) {
parts.push(condition.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'));
}
return parts.join(' ');
}
/**
* Authors one `FieldCondition` (`showWhen` on a section/field, or
* `conditionExpression` on a document requirement).
*
* The field-path box autocompletes from every field already defined in this
* licence type's schema (`targets`); when the chosen path resolves to a
* SELECT field, the value picker switches to that field's own options
* instead of free text — the condition can only ever reference an answer
* that could actually be chosen.
* The field/operator/value trio for one condition — a plain condition, or one
* arm of an `anyOf`. No enable switch of its own; the caller owns whether
* this row exists at all.
*/
export function ConditionBuilder({
function ConditionArmFields({
value,
onChange,
targets,
palette,
allowClear = true,
}: {
value: ConditionValue | null;
onChange: (value: ConditionValue | null) => void;
value: ConditionArm;
onChange: (value: ConditionArm) => void;
targets: ConditionTarget[];
palette: FormSchemaPalette | undefined;
/** Hide the "no condition" toggle — used where a condition is mandatory (CONDITIONAL document mode). */
allowClear?: boolean;
}) {
const { t } = useTranslation();
const localized = useLocalized();
const active = value !== null;
const operator = operatorOf(value ?? undefined) ?? 'equals';
const target = targets.find((c) => c.path === value?.field);
const operator = operatorOf(value) ?? 'equals';
const target = targets.find((c) => c.path === value.field);
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
function setField(field: string) {
@@ -66,8 +80,8 @@ export function ConditionBuilder({
}
function setOperator(next: Operator) {
if (!value?.field) return;
const base: ConditionValue = { field: value.field };
if (!value.field) return;
const base: ConditionArm = { field: value.field };
if (next === 'isSet') base.isSet = true;
else if (next === 'in') base.in = [];
else if (next === 'notEquals') base.notEquals = '';
@@ -76,14 +90,14 @@ export function ConditionBuilder({
}
function setValueRaw(raw: string) {
if (!value?.field) return;
if (!value.field) return;
const coerced = coerce(raw, target?.field.type);
if (operator === 'equals') onChange({ field: value.field, equals: coerced });
else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced });
}
function setInValues(raws: string[]) {
if (!value?.field) return;
if (!value.field) return;
onChange({
field: value.field,
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
@@ -92,16 +106,6 @@ export function ConditionBuilder({
return (
<Stack gap="xs">
{allowClear && (
<Switch
label={t('certReq.condition.enable', 'Only apply when a condition holds')}
checked={active}
onChange={(e) => onChange(e.currentTarget.checked ? { field: '', equals: '' } : null)}
/>
)}
{active && (
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
<Autocomplete
label={t('certReq.condition.field', 'Field path')}
placeholder="certificate.rank"
@@ -110,7 +114,7 @@ export function ConditionBuilder({
'Dot path into the form, e.g. sectionKey.fieldKey',
)}
data={targets.map((c) => c.path)}
value={value?.field ?? ''}
value={value.field ?? ''}
onChange={setField}
/>
@@ -130,7 +134,7 @@ export function ConditionBuilder({
value: o.value,
label: localized(o.label) || o.value,
}))}
value={String(value?.equals ?? value?.notEquals ?? '')}
value={String(value.equals ?? value.notEquals ?? '')}
onChange={(v) => v !== null && setValueRaw(v)}
/>
)}
@@ -140,14 +144,14 @@ export function ConditionBuilder({
<Checkbox
mt="xl"
label={t('certReq.condition.value', 'Value')}
checked={Boolean(value?.equals ?? value?.notEquals ?? false)}
checked={Boolean(value.equals ?? value.notEquals ?? false)}
onChange={(e) => setValueRaw(String(e.currentTarget.checked))}
/>
) : (
<TextInput
label={t('certReq.condition.value', 'Value')}
type={target?.field.type === 'NUMBER' || target?.field.type === 'MONEY' ? 'number' : 'text'}
value={String(value?.equals ?? value?.notEquals ?? '')}
value={String(value.equals ?? value.notEquals ?? '')}
onChange={(e) => setValueRaw(e.currentTarget.value)}
/>
)
@@ -164,7 +168,7 @@ export function ConditionBuilder({
value={null}
onChange={(v) => {
if (!v) return;
const current = (value?.in ?? []) as string[];
const current = (value.in ?? []) as string[];
if (!current.includes(v)) setInValues([...current, v]);
}}
/>
@@ -173,7 +177,7 @@ export function ConditionBuilder({
{operator === 'in' && target?.field.type !== 'SELECT' && (
<TextInput
label={t('certReq.condition.values', 'Any of (comma-separated)')}
value={(value?.in ?? []).join(', ')}
value={(value.in ?? []).join(', ')}
onChange={(e) =>
setInValues(
e.currentTarget.value
@@ -186,9 +190,9 @@ export function ConditionBuilder({
)}
</Group>
{operator === 'in' && (value?.in?.length ?? 0) > 0 && (
{operator === 'in' && (value.in?.length ?? 0) > 0 && (
<Group gap={4}>
{(value?.in ?? []).map((v, i) => (
{(value.in ?? []).map((v, i) => (
<Text
key={`${v}-${i}`}
fz="xs"
@@ -196,7 +200,7 @@ export function ConditionBuilder({
py={2}
bg="var(--mantine-color-gray-1)"
style={{ borderRadius: 4, cursor: 'pointer' }}
onClick={() => setInValues((value?.in ?? []).filter((_, idx) => idx !== i).map(String))}
onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
title={t('certReq.condition.removeValue', 'Click to remove')}
>
{String(v)} ×
@@ -205,7 +209,7 @@ export function ConditionBuilder({
</Group>
)}
{!target && value?.field && (
{!target && value.field && (
<Text fz="xs" c="dimmed">
{t(
'certReq.condition.unknownField',
@@ -214,6 +218,138 @@ export function ConditionBuilder({
</Text>
)}
</Stack>
);
}
const EMPTY_ARM: ConditionArm = { field: '', equals: '' };
/**
* Authors one `FieldCondition` (`showWhen` on a section/field, or
* `conditionExpression` on a document requirement).
*
* The field-path box autocompletes from every field already defined in this
* licence type's schema (`targets`); when the chosen path resolves to a
* SELECT field, the value picker switches to that field's own options
* instead of free text — the condition can only ever reference an answer
* that could actually be chosen.
*
* "Any of these" switches to authoring several single-field conditions whose
* OR is the real condition — needed when the same logical value can live on
* one of several mutually-exclusive fields (e.g. a rank split by
* department, see FieldCondition.anyOf).
*/
export function ConditionBuilder({
value,
onChange,
targets,
palette,
allowClear = true,
}: {
value: ConditionValue | null;
onChange: (value: ConditionValue | null) => void;
targets: ConditionTarget[];
palette: FormSchemaPalette | undefined;
/** Hide the "no condition" toggle — used where a condition is mandatory (CONDITIONAL document mode). */
allowClear?: boolean;
}) {
const { t } = useTranslation();
const active = value !== null;
const isAnyOf = Boolean(value?.anyOf);
const arms = (value?.anyOf ?? []) as ConditionArm[];
function setArm(i: number, arm: ConditionArm) {
const next = arms.slice();
next[i] = arm;
onChange({ anyOf: next });
}
function addArm() {
onChange({ anyOf: [...arms, { ...EMPTY_ARM }] });
}
function removeArm(i: number) {
onChange({ anyOf: arms.filter((_, idx) => idx !== i) });
}
function toggleAnyOf(next: boolean) {
if (next) {
// Seed the list from whatever single condition already existed, so
// switching modes doesn't discard work in progress.
const seed: ConditionArm = value?.field ? (value as ConditionArm) : { ...EMPTY_ARM };
onChange({ anyOf: [seed] });
} else {
// Same, in reverse — the first arm becomes the single condition.
onChange((arms[0] as ConditionValue) ?? { field: '', equals: '' });
}
}
return (
<Stack gap="xs">
{allowClear && (
<Switch
label={t('certReq.condition.enable', 'Only apply when a condition holds')}
checked={active}
onChange={(e) => onChange(e.currentTarget.checked ? { field: '', equals: '' } : null)}
/>
)}
{active && (
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
<Switch
size="sm"
label={t(
'certReq.condition.anyOfEnable',
'Any of these (the value can live on one of several fields)',
)}
checked={isAnyOf}
onChange={(e) => toggleAnyOf(e.currentTarget.checked)}
/>
{isAnyOf ? (
<Stack gap="sm">
{arms.map((arm, i) => (
<Paper key={i} withBorder p="sm" radius="sm">
<Group justify="space-between" mb="xs">
<Text fz="xs" fw={600} c="dimmed">
{t('certReq.condition.anyOfArm', 'Condition {{n}}', { n: i + 1 })}
</Text>
<ActionIcon
variant="subtle"
color="red"
size="sm"
disabled={arms.length <= 1}
onClick={() => removeArm(i)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
<ConditionArmFields
value={arm}
onChange={(next) => setArm(i, next)}
targets={targets}
palette={palette}
/>
</Paper>
))}
<Group>
<ActionIcon variant="light" onClick={addArm}>
<IconPlus size={16} />
</ActionIcon>
<Text fz="xs" c="dimmed">
{t('certReq.condition.anyOfAdd', 'Add another field')}
</Text>
</Group>
</Stack>
) : (
<ConditionArmFields
value={value as ConditionArm}
onChange={(next) => onChange(next)}
targets={targets}
palette={palette}
/>
)}
</Stack>
)}
</Stack>
);

View File

@@ -94,7 +94,10 @@ export function DocumentRequirementEditorDrawer({
return;
}
if (!draft.name.en?.trim()) return;
if (draft.mode === 'CONDITIONAL' && !draft.conditionExpression?.field) {
const hasCondition =
Boolean(draft.conditionExpression?.field) ||
Boolean(draft.conditionExpression?.anyOf?.length);
if (draft.mode === 'CONDITIONAL' && !hasCondition) {
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
return;
}
@@ -167,7 +170,7 @@ export function DocumentRequirementEditorDrawer({
<>
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
<ConditionBuilder
value={(draft.conditionExpression ?? null) as ConditionValue | null}
value={(draft.conditionExpression ?? { field: '', equals: '' }) as ConditionValue}
onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))}
targets={conditionTargets}
palette={palette}

View File

@@ -17,6 +17,7 @@ import {
} from '@ema-platform/api';
import { collectConditionTargets } from '../config/schema-paths';
import { useRequirementActions } from '../hooks/useRequirementActions';
import { describeCondition } from './ConditionBuilder';
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
@@ -137,13 +138,9 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
<Text fz="xs" c="dimmed" truncate>
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
</Text>
{req.mode === 'CONDITIONAL' && req.conditionExpression?.field && (
{req.mode === 'CONDITIONAL' && req.conditionExpression && (
<Text fz="xs" c="violet">
{t('certReq.doc.when', 'when')} {req.conditionExpression.field}{' '}
{req.conditionExpression.equals !== undefined && `= ${req.conditionExpression.equals}`}
{req.conditionExpression.notEquals !== undefined && `${req.conditionExpression.notEquals}`}
{req.conditionExpression.in !== undefined && `∈ [${req.conditionExpression.in.join(', ')}]`}
{req.conditionExpression.isSet !== undefined && (req.conditionExpression.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'))}
{t('certReq.doc.when', 'when')} {describeCondition(req.conditionExpression, t)}
</Text>
)}
</div>

View File

@@ -44,6 +44,7 @@ export function CertificateRequirementsPage() {
'certReq.subtitle',
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
)}
noMargin
/>
{isError ? (

View File

@@ -16,6 +16,15 @@ export function certificationColumns(
header: t('certification.columns.description'),
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
},
{
header: t('certification.columns.rank', 'Rank'),
cell: ({ row }) =>
row.original.rankKey ? (
<Badge size="sm" variant="outline" color="violet">{row.original.rankKey}</Badge>
) : (
<Text fz="sm" c="dimmed"></Text>
),
},
{
header: t('certification.columns.status'),
cell: ({ row }) => (

View File

@@ -1,27 +1,16 @@
import { useState } from 'react';
import {
Stack,
Title,
Group,
Button,
Modal,
Text,
TextInput,
Textarea,
Card,
Alert,
} from '@mantine/core';
import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
import {IconPlus} from '@tabler/icons-react';
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
import {
useGetCertificationsQuery,
useCreateCertificationMutation,
useUpdateCertificationMutation,
useDeleteCertificationMutation,
} from '../../api/certification-api';
import type { Certification } from '../../types/certification';
import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
import { certificationColumns } from './columns';
import { certificationActionsColumn } from './actions';
@@ -33,7 +22,7 @@ function CertificationForm({
}: {
editing: Certification | null;
isSubmitting: boolean;
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => void;
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
@@ -41,6 +30,7 @@ function CertificationForm({
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -48,7 +38,7 @@ function CertificationForm({
notify.error('Name fields are required');
return;
}
onSubmit({ nameEn, nameAm, descEn, descAm }, !!editing);
onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
};
return (
@@ -59,6 +49,17 @@ function CertificationForm({
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Select
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]}
value={rankKey}
onChange={setRankKey}
size="sm"
clearable
searchable
/>
<ModalFooter>
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
@@ -91,15 +92,17 @@ export function CertificationPage() {
setShowForm(false);
};
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => {
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => {
const name = { en: values.nameEn, am: values.nameAm };
const description = { en: values.descEn, am: values.descAm };
try {
if (isEdit && editing) {
await updateCert({ id: editing.id, name, description }).unwrap();
// null clears a previously-set rank; undefined would leave it
// untouched server-side, so the two are not interchangeable here.
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap();
notify.success(t('certification.updated'));
} else {
await createCert({ name, description }).unwrap();
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
notify.success(t('certification.created'));
}
resetForm();
@@ -120,7 +123,8 @@ export function CertificationPage() {
}
};
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
if (isError)
return <ErrorState title={t('certification.loadError')} onRetry={refetch} />;
const columns = [
...certificationColumns(t, locale),
@@ -134,17 +138,18 @@ export function CertificationPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t('certification.title')}</Title>
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text>
</div>
{!showForm && (
<PageHeader
title={t('certification.title')}
subtitle={t('certification.subtitle')}
noMargin
action={
!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('certification.add')}
</Button>
)}
</Group>
)
}
/>
{showForm && (
<CertificationForm

View File

@@ -3,11 +3,30 @@ export interface LocalePair {
am: string;
}
/**
* STCW rank an exam certification is for — the join that lets the
* schedule-exam picker offer only sittings valid for an application's rank.
* Matches `LicenseApplication.formData.certificate.rank` / `rankEngine` /
* `proficiency` on the backend. Not every certification is on the examined
* ladder, so this stays a plain optional string rather than a required enum.
*/
export const RANK_KEY_OPTIONS = [
{ value: 'OOW_DECK', label: 'Officer of the Watch (Deck)' },
{ value: 'CHIEF_MATE', label: 'Chief Mate' },
{ value: 'MASTER', label: 'Master' },
{ value: 'OOW_ENGINE', label: 'Officer of the Watch (Engine)' },
{ value: 'SECOND_ENGINEER', label: 'Second Engineer' },
{ value: 'CHIEF_ENGINEER', label: 'Chief Engineer' },
{ value: 'ABLE_SEAFARER_DECK', label: 'Able Seafarer Deck' },
{ value: 'ABLE_SEAFARER_ENGINE', label: 'Able Seafarer Engine' },
] as const;
export interface Certification {
id: string;
name: LocalePair;
description: LocalePair;
isActive: boolean;
rankKey: string | null;
createdAt: string;
updatedAt: string;
}
@@ -20,6 +39,7 @@ export interface ListResponse<T> {
export interface CreateCertificationPayload {
name: LocalePair;
description: LocalePair;
rankKey?: string;
}
export interface UpdateCertificationPayload {
@@ -27,4 +47,6 @@ export interface UpdateCertificationPayload {
name?: LocalePair;
description?: LocalePair;
isActive?: boolean;
/** Omit to leave unchanged, null to clear a previously-set rank. */
rankKey?: string | null;
}

View File

@@ -0,0 +1,554 @@
import { useCallback, useState } from "react";
import {
Alert,
Button,
Group,
Modal,
NumberInput,
Select,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
import { IconInfoCircle, IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
AdvancedTable,
ModalFooter,
notify,
PageLoader,
useErrorHandler,
type AdvancedColumn,
} from "@ema-platform/ui";
import {
useLocalized,
useGetDepartmentsQuery,
useCreateDepartmentMutation,
useUpdateDepartmentMutation,
useDeleteDepartmentMutation,
useGetRanksQuery,
useCreateRankMutation,
useUpdateRankMutation,
useDeleteRankMutation,
type Department,
type Rank,
type RankCertificateCategory,
} from "@ema-platform/api";
const CATEGORY_OPTIONS: { value: RankCertificateCategory; label: string }[] = [
{ value: "COC", label: "CoC" },
{ value: "COP", label: "CoP" },
];
/**
* Departments and their CoC/CoP rank ladders, as backoffice-editable config.
*
* Both used to be hardcoded (`ESeafarerDepartment` and the `COC_LADDERS`/
* `COP_LADDERS` arrays) — this is the write side that config never had. A
* rank's `ladderOrder` is the rung position `resolveNextRank` climbs, so
* reordering here changes what an applicant is auto-advanced to next.
*/
export function RankDepartmentTab() {
const { t } = useTranslation();
const localized = useLocalized();
const { data: deptRes, isLoading: deptLoading, isFetching: deptFetching, refetch: refetchDepts } =
useGetDepartmentsQuery();
const { data: rankRes, isLoading: rankLoading, isFetching: rankFetching, refetch: refetchRanks } =
useGetRanksQuery();
const departments = deptRes?.items ?? [];
const ranks = rankRes?.items ?? [];
const deptOptions = departments.map((d) => ({ value: d.id, label: localized(d.name) }));
const deptName = useCallback(
(id: string) => departments.find((d) => d.id === id)?.code ?? "-",
[departments],
);
if (deptLoading || rankLoading) {
return <PageLoader label={t("configuration.loadingRanks", "Loading departments and ranks…")} height={300} />;
}
return (
<Stack gap="xl">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
<Text size="sm">
{t(
"configuration.rankLadderNotice",
"A rank's position is its rung on the ladder — an applicant is auto-advanced to the next position up from what they already hold.",
)}
</Text>
</Alert>
<DepartmentSection
departments={departments}
isFetching={deptFetching}
refetch={refetchDepts}
localized={localized}
/>
<RankSection
ranks={ranks}
deptOptions={deptOptions}
deptName={deptName}
isFetching={rankFetching}
refetch={refetchRanks}
localized={localized}
/>
</Stack>
);
}
// ------------------------------------------------------------- departments
function DepartmentSection({
departments,
isFetching,
refetch,
localized,
}: {
departments: Department[];
isFetching: boolean;
refetch: () => void;
localized: (v: Department["name"]) => string;
}) {
const { t } = useTranslation();
const { handleError } = useErrorHandler();
const [createDepartment, { isLoading: isCreating }] = useCreateDepartmentMutation();
const [updateDepartment, { isLoading: isUpdating }] = useUpdateDepartmentMutation();
const [deleteDepartment] = useDeleteDepartmentMutation();
const [editing, setEditing] = useState<Department | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Department | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const resetForm = useCallback(() => {
setEditing(null);
setShowForm(false);
}, []);
const form = useForm({
initialValues: { code: "", nameEn: "", nameAm: "", sortOrder: 0 },
validate: {
code: (v) => (!v ? t("configuration.validation.codeRequired", "Code is required") : null),
nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
},
});
const openEdit = useCallback(
(dept: Department) => {
setEditing(dept);
form.setValues({
code: dept.code,
nameEn: dept.name.en ?? "",
nameAm: dept.name.am ?? "",
sortOrder: dept.sortOrder,
});
setShowForm(true);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleSubmit = form.onSubmit(async (values) => {
const name = { en: values.nameEn, am: values.nameAm };
try {
if (editing) {
await updateDepartment({
id: editing.id,
code: values.code,
name,
sortOrder: values.sortOrder,
}).unwrap();
notify.success(t("configuration.updated"));
} else {
await createDepartment({ code: values.code, name, sortOrder: values.sortOrder }).unwrap();
notify.success(t("configuration.created"));
}
resetForm();
form.reset();
} catch (e) {
handleError(e);
}
});
const confirmDelete = useCallback(async () => {
if (!deleteTarget) return;
try {
await deleteDepartment(deleteTarget.id).unwrap();
notify.success(t("configuration.deleted"));
closeDelete();
setDeleteTarget(null);
} catch (e) {
handleError(e);
}
}, [deleteTarget, deleteDepartment, closeDelete, handleError]);
const columns: AdvancedColumn<Department>[] = [
{ header: t("configuration.code", "Code"), cell: ({ row }) => row.original.code },
{ header: t("configuration.name"), cell: ({ row }) => localized(row.original.name) },
{ header: t("configuration.sortOrder", "Order"), cell: ({ row }) => row.original.sortOrder },
{
header: "actions",
size: 90,
cell: ({ row }) => (
<Group gap="xs">
<Button variant="subtle" size="xs" onClick={() => openEdit(row.original)}>
{t("configuration.edit", "Edit")}
</Button>
<Button
variant="subtle"
color="red"
size="xs"
onClick={() => {
setDeleteTarget(row.original);
openDelete();
}}
>
{t("configuration.delete")}
</Button>
</Group>
),
},
];
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-end">
<Title order={4}>{t("configuration.departments", "Departments")}</Title>
{!showForm && (
<Button
variant="light"
size="sm"
leftSection={<IconPlus size={16} />}
onClick={() => {
form.reset();
setShowForm(true);
}}
>
{t("configuration.addDepartment", "Add department")}
</Button>
)}
</Group>
<AdvancedTable
columns={columns}
data={[...departments].sort((a, b) => a.sortOrder - b.sortOrder)}
tableName={t("configuration.departments", "Departments")}
itemCount={departments.length}
pageIndex={0}
onPageChange={() => {}}
pageSize={departments.length || 10}
onPageSizeChange={() => {}}
refresh={refetch}
isLoading={isFetching}
/>
<Modal
opened={showForm}
onClose={resetForm}
title={editing ? t("configuration.update") : t("configuration.addDepartment", "Add department")}
size="sm"
>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput
label={t("configuration.code", "Code")}
placeholder="DECK"
{...form.getInputProps("code")}
size="sm"
/>
<TextInput label={t("configuration.nameEn")} {...form.getInputProps("nameEn")} size="sm" />
<TextInput label={t("configuration.nameAm")} {...form.getInputProps("nameAm")} size="sm" />
<NumberInput
label={t("configuration.sortOrder", "Sort order")}
{...form.getInputProps("sortOrder")}
size="sm"
/>
<ModalFooter>
<Button variant="default" onClick={resetForm} size="sm">
{t("configuration.cancel")}
</Button>
<Button type="submit" size="sm" loading={isCreating || isUpdating}>
{editing ? t("configuration.update") : t("configuration.create")}
</Button>
</ModalFooter>
</Stack>
</form>
</Modal>
<Modal opened={deleteOpened} onClose={closeDelete} title={t("configuration.confirmDelete")} size="sm">
<Text mb="md">
{t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })}
</Text>
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">
{t("configuration.cancel")}
</Button>
<Button color="red" onClick={confirmDelete} size="sm">
{t("configuration.delete")}
</Button>
</ModalFooter>
</Modal>
</Stack>
);
}
// ------------------------------------------------------------------ ranks
function RankSection({
ranks,
deptOptions,
deptName,
isFetching,
refetch,
localized,
}: {
ranks: Rank[];
deptOptions: { value: string; label: string }[];
deptName: (id: string) => string;
isFetching: boolean;
refetch: () => void;
localized: (v: Rank["name"]) => string;
}) {
const { t } = useTranslation();
const { handleError } = useErrorHandler();
const [createRank, { isLoading: isCreating }] = useCreateRankMutation();
const [updateRank, { isLoading: isUpdating }] = useUpdateRankMutation();
const [deleteRank] = useDeleteRankMutation();
const [editing, setEditing] = useState<Rank | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Rank | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const resetForm = useCallback(() => {
setEditing(null);
setShowForm(false);
}, []);
const form = useForm({
initialValues: {
departmentId: "",
certificateCategory: "COC" as RankCertificateCategory,
key: "",
nameEn: "",
nameAm: "",
ladderOrder: 0,
},
validate: {
departmentId: (v) => (!v ? t("configuration.validation.departmentRequired") : null),
key: (v) => (!v ? t("configuration.validation.keyRequired", "Key is required") : null),
nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
},
});
const openEdit = useCallback(
(rank: Rank) => {
setEditing(rank);
form.setValues({
departmentId: rank.departmentId,
certificateCategory: rank.certificateCategory,
key: rank.key,
nameEn: rank.name.en ?? "",
nameAm: rank.name.am ?? "",
ladderOrder: rank.ladderOrder,
});
setShowForm(true);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleSubmit = form.onSubmit(async (values) => {
const name = { en: values.nameEn, am: values.nameAm };
try {
if (editing) {
await updateRank({
id: editing.id,
departmentId: values.departmentId,
certificateCategory: values.certificateCategory,
key: values.key,
name,
ladderOrder: values.ladderOrder,
}).unwrap();
notify.success(t("configuration.updated"));
} else {
await createRank({
departmentId: values.departmentId,
certificateCategory: values.certificateCategory,
key: values.key,
name,
ladderOrder: values.ladderOrder,
}).unwrap();
notify.success(t("configuration.created"));
}
resetForm();
form.reset();
} catch (e) {
handleError(e);
}
});
const confirmDelete = useCallback(async () => {
if (!deleteTarget) return;
try {
await deleteRank(deleteTarget.id).unwrap();
notify.success(t("configuration.deleted"));
closeDelete();
setDeleteTarget(null);
} catch (e) {
handleError(e);
}
}, [deleteTarget, deleteRank, closeDelete, handleError]);
const sortedRanks = [...ranks].sort(
(a, b) =>
a.departmentId.localeCompare(b.departmentId) ||
a.certificateCategory.localeCompare(b.certificateCategory) ||
a.ladderOrder - b.ladderOrder,
);
const columns: AdvancedColumn<Rank>[] = [
{ header: t("configuration.department"), cell: ({ row }) => deptName(row.original.departmentId) },
{ header: t("configuration.category", "Ladder"), cell: ({ row }) => row.original.certificateCategory },
{ header: t("configuration.rankOrder", "Rung"), cell: ({ row }) => row.original.ladderOrder },
{ header: t("configuration.key", "Key"), cell: ({ row }) => row.original.key },
{ header: t("configuration.name"), cell: ({ row }) => localized(row.original.name) },
{
header: "actions",
size: 90,
cell: ({ row }) => (
<Group gap="xs">
<Button variant="subtle" size="xs" onClick={() => openEdit(row.original)}>
{t("configuration.edit", "Edit")}
</Button>
<Button
variant="subtle"
color="red"
size="xs"
onClick={() => {
setDeleteTarget(row.original);
openDelete();
}}
>
{t("configuration.delete")}
</Button>
</Group>
),
},
];
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-end">
<Title order={4}>{t("configuration.ranks", "Ranks")}</Title>
{!showForm && (
<Button
variant="light"
size="sm"
leftSection={<IconPlus size={16} />}
onClick={() => {
form.reset();
setShowForm(true);
}}
disabled={deptOptions.length === 0}
>
{t("configuration.addRank", "Add rank")}
</Button>
)}
</Group>
<AdvancedTable
columns={columns}
data={sortedRanks}
tableName={t("configuration.ranks", "Ranks")}
itemCount={sortedRanks.length}
pageIndex={0}
onPageChange={() => {}}
pageSize={sortedRanks.length || 10}
onPageSizeChange={() => {}}
refresh={refetch}
isLoading={isFetching}
/>
<Modal
opened={showForm}
onClose={resetForm}
title={editing ? t("configuration.update") : t("configuration.addRank", "Add rank")}
size="sm"
>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<Select
label={t("configuration.department")}
data={deptOptions}
{...form.getInputProps("departmentId")}
size="sm"
searchable
/>
<Select
label={t("configuration.category", "Ladder")}
data={CATEGORY_OPTIONS}
{...form.getInputProps("certificateCategory")}
size="sm"
allowDeselect={false}
/>
<TextInput
label={t("configuration.key", "Key")}
placeholder="CHIEF_MATE"
{...form.getInputProps("key")}
size="sm"
disabled={!!editing}
description={
editing
? t(
"configuration.keyLockedNotice",
"Not editable — issued licences already carry this key.",
)
: undefined
}
/>
<TextInput label={t("configuration.nameEn")} {...form.getInputProps("nameEn")} size="sm" />
<TextInput label={t("configuration.nameAm")} {...form.getInputProps("nameAm")} size="sm" />
<NumberInput
label={t("configuration.rankOrder", "Rung (0 = entry rank)")}
min={0}
{...form.getInputProps("ladderOrder")}
size="sm"
/>
<ModalFooter>
<Button variant="default" onClick={resetForm} size="sm">
{t("configuration.cancel")}
</Button>
<Button type="submit" size="sm" loading={isCreating || isUpdating}>
{editing ? t("configuration.update") : t("configuration.create")}
</Button>
</ModalFooter>
</Stack>
</form>
</Modal>
<Modal opened={deleteOpened} onClose={closeDelete} title={t("configuration.confirmDelete")} size="sm">
<Text mb="md">
{t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })}
</Text>
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">
{t("configuration.cancel")}
</Button>
<Button color="red" onClick={confirmDelete} size="sm">
{t("configuration.delete")}
</Button>
</ModalFooter>
</Modal>
</Stack>
);
}

View File

@@ -23,6 +23,7 @@ import {
IconCertificate,
IconHash,
IconInfoCircle,
IconAnchor,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
@@ -36,6 +37,7 @@ import {
import { LocationPage } from "../../../location/pages/LocationPage";
import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab";
import { RankDepartmentTab } from "./RankDepartmentTab";
import {
useGetOrganizationsQuery,
useGetProfessionsQuery,
@@ -46,6 +48,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 +381,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>
@@ -400,6 +403,9 @@ export function ConfigurationPage() {
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
{t("numberFormat.title", "Number Formats")}
</Tabs.Tab>
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
{t("configuration.ranksTab", "Ranks & Departments")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="professions" pt="md">
@@ -417,6 +423,10 @@ export function ConfigurationPage() {
<Tabs.Panel value="numberFormats" pt="md">
<NumberFormatTab />
</Tabs.Panel>
<Tabs.Panel value="ranks" pt="md">
<RankDepartmentTab />
</Tabs.Panel>
</Tabs>
</Stack>
);

View File

@@ -1,25 +1,42 @@
import { useNavigate } from 'react-router-dom';
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import {
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Text,
Title,
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
IconAlertTriangle,
IconCreditCard,
IconFileText,
IconInbox,
IconUserCheck,
} from '@tabler/icons-react';
import {
useGetAssignedToMeQuery,
useGetQueueQuery,
useListSeafarerDocumentsQuery,
useListSeafarerRegistrationsQuery,
type LicenseApplication,
} from '@ema-platform/api';
import {
AdvancedTable,
PageHeader,
PageLoader,
StatTile,
WaitingFor,
useServerTable,
} from '@ema-platform/ui';
import { dashboardQueueColumns } from './columns';
/**
* Backoffice home.
*
* Shows the licence pipeline, which is the part of the platform that has real
* data behind it. The previous version charted invented registration volumes
* and a fictional breakdown of staff roles.
* Every figure here is counted from a queue the officer can open, and each
* tile navigates to the list it counted — a dashboard that cannot be drilled
* into is a poster. Nothing is charted: the platform exposes queues, not time
* series, and an earlier version of this page invented both a registration
* trend and a staff-role breakdown rather than admit that.
*
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
* queues are permission-gated and an officer without them simply gets no
* count — never a broken page — so the tiles read `—` rather than `0`, which
* would be a lie.
*/
export function DashboardPage() {
const navigate = useNavigate();
@@ -27,6 +44,13 @@ export function DashboardPage() {
const mine = useGetAssignedToMeQuery();
const table = useServerTable();
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
const seamanBooks = useListSeafarerDocumentsQuery({
kind: 'SEAMAN_BOOK',
status: 'PAYMENT_PENDING',
take: 1,
});
if (queue.isLoading || mine.isLoading) {
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
}
@@ -34,60 +58,88 @@ export function DashboardPage() {
const unclaimed = queue.data?.items ?? [];
const inProgress = mine.data?.items ?? [];
const all = [...unclaimed, ...inProgress];
const paged = table.paginate(unclaimed.slice(0, 8));
const stats = [
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
{ label: 'Assigned to me', value: inProgress.length, color: 'indigo' },
{
label: 'Needs applicant action',
value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length,
color: 'orange',
},
{
label: 'Awaiting payment',
value: all.filter((a) => a.status === 'PAYMENT_PENDING').length,
color: 'yellow',
},
];
const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
/** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
const byAge = [...unclaimed].sort((a, b) =>
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
);
const paged = table.paginate(byAge.slice(0, 8));
/** `undefined` while loading or forbidden — rendered as "—", never as 0. */
const countOf = (q: { data?: { total: number }; isError: boolean }) =>
q.isError ? undefined : q.data?.total;
const show = (n: number | undefined) => (n === undefined ? '—' : n);
return (
<Container size="xl" py="md">
<Title order={3} mb="xs">
Dashboard
</Title>
<Text size="sm" c="dimmed" mb="lg">
Licence applications currently in the system.
</Text>
<Stack gap="lg">
<PageHeader
title="Dashboard"
subtitle="Work waiting across the Authority's review queues."
noMargin
/>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
{stats.map((stat) => (
<Card withBorder key={stat.label} padding="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{stat.label}
</Text>
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
{stat.value}
</Text>
</Card>
))}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<StatTile
label="Awaiting claim"
value={unclaimed.length}
hint="Licence applications nobody has picked up"
icon={IconInbox}
tone="info"
onClick={() => navigate('/licence-review')}
/>
<StatTile
label="Assigned to me"
value={inProgress.length}
hint="Your open licence reviews"
icon={IconFileText}
tone="neutral"
onClick={() => navigate('/licence-review')}
/>
<StatTile
label="Needs applicant action"
value={needsApplicant}
hint="Returned for corrections"
icon={IconAlertTriangle}
tone="pending"
/>
<StatTile
label="Awaiting payment"
value={awaitingPayment}
hint="Approved, fee not yet settled"
icon={IconCreditCard}
tone="warning"
/>
</SimpleGrid>
<Card withBorder padding={0} radius="md">
<Group justify="space-between" p="md" pb="xs">
<Text fw={600} size="sm">
Awaiting claim
</Text>
<Text
size="xs"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/licence-review')}
>
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
</Text>
</Group>
<AdvancedTable
{/* Same 4-column track as the row above, so a two-tile row lines up with
it instead of stretching each tile to half the page. */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<StatTile
label="Seafarer registrations"
value={show(countOf(registrations))}
hint="Submitted, awaiting review"
icon={IconUserCheck}
tone="info"
onClick={() => navigate('/seafarer-registrations')}
/>
<StatTile
label="Seaman books"
value={show(countOf(seamanBooks))}
hint="Released, awaiting payment"
icon={IconCreditCard}
tone="pending"
onClick={() => navigate('/seaman-book-queue')}
/>
</SimpleGrid>
<Grid gutter="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<AdvancedTable<LicenseApplication>
title="Awaiting claim — oldest first"
tableName="Awaiting claim"
columns={dashboardQueueColumns}
data={paged.rows}
@@ -95,12 +147,54 @@ export function DashboardPage() {
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
onRowClick={() => navigate('/licence-review')}
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
refresh={queue.refetch}
isLoading={queue.isFetching}
emptyText="Nothing waiting to be claimed."
toolbar={
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
Open queue
</Anchor>
}
/>
</Card>
</Container>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Paper withBorder radius="lg" p="lg" h="100%">
<Text fw={600} size="sm" mb="xs">
Longest waiting
</Text>
<Text size="xs" c="dimmed" mb="md">
Unclaimed applications, by how long they have sat.
</Text>
<Stack gap="sm">
{byAge.slice(0, 5).map((app) => (
<Group key={app.id} justify="space-between" wrap="nowrap" gap="sm">
<Anchor
size="sm"
lineClamp={1}
onClick={() => navigate(`/licence-review/${app.id}`)}
>
{app.applicationNumber}
</Anchor>
<WaitingFor
since={app.submittedAt ?? app.createdAt}
slaDays={
app.licenseType?.slaHours ? app.licenseType.slaHours / 24 : undefined
}
/>
</Group>
))}
{byAge.length === 0 && (
<Text size="sm" c="dimmed">
Nothing waiting.
</Text>
)}
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -11,6 +11,7 @@ import type {
ExamIncident,
CreateIncidentPayload,
ResolveIncidentPayload,
RegradeOutcome,
} from '../types/exam';
const examApi = baseApi.injectEndpoints({
@@ -20,7 +21,9 @@ const examApi = baseApi.injectEndpoints({
providesTags: ['Api'],
}),
getExam: builder.query<Exam, string>({
query: (id) => `/exams/${id}?i=questions`,
// Nested relation so CHOICE questions carry their options here too —
// needed to print real answer choices instead of blank A/B/C/D lines.
query: (id) => `/exams/${id}?i=questions,questions.options`,
providesTags: ['Api'],
}),
createExam: builder.mutation<Exam, CreateExamPayload>({
@@ -90,6 +93,14 @@ const examApi = baseApi.injectEndpoints({
}),
invalidatesTags: ['Api'],
}),
/** Staff-triggered re-run of auto-grading for one finalized attempt. */
regradeAttempt: builder.mutation<RegradeOutcome, string>({
query: (attemptId) => ({
url: `/exam-attempts/${attemptId}/regrade`,
method: 'POST',
}),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
@@ -107,4 +118,5 @@ export const {
useGetExamIncidentsQuery,
useRecordIncidentMutation,
useResolveIncidentMutation,
useRegradeAttemptMutation,
} = examApi;

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconUserCheck } from '@tabler/icons-react';
import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) =>
export function examCandidateColumns(
t: TFunction,
handlers: { onRecord: (registration: ExamRegistration) => void },
handlers: {
onRecord: (registration: ExamRegistration) => void;
onRegrade: (registration: ExamRegistration) => void;
regrading?: string | null;
},
): AdvancedColumn<ExamRegistration>[] {
return [
{
@@ -78,21 +82,51 @@ export function examCandidateColumns(
header: '',
label: t('exam.candidates.record'),
align: 'right',
cell: ({ row }) => (
cell: ({ row }) => {
const attemptStatus = row.original.attempt?.status;
const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED';
return (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
loading={handlers.regrading === row.original.attempt?.id}
>
<IconDotsVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
hideOnly
>
<Button
size="compact-xs"
variant="light"
leftSection={<IconUserCheck size={12} />}
<Menu.Item
leftSection={<IconUserCheck size={14} />}
onClick={() => handlers.onRecord(row.original)}
>
{t('exam.candidates.record')}
</Button>
</Menu.Item>
</RequirePermission>
),
{canRegrade && (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]}
hideOnly
>
<Menu.Item
color="grape"
leftSection={<IconRefresh size={14} />}
onClick={() => handlers.onRegrade(row.original)}
>
{t('exam.candidates.regrade')}
</Menu.Item>
</RequirePermission>
)}
</Menu.Dropdown>
</Menu>
);
},
},
];
}

View File

@@ -18,6 +18,7 @@ import { extractErrorMessage } from '@ema-platform/api';
import {
useGetExamRegistrationsQuery,
useRecordAttendanceMutation,
useRegradeAttemptMutation,
} from '../../api/exam-api';
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
import { candidateName, examCandidateColumns } from './columns';
@@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
const { t } = useTranslation();
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
const [regradeAttempt] = useRegradeAttemptMutation();
const [regrading, setRegrading] = useState<string | null>(null);
const [target, setTarget] = useState<ExamRegistration | null>(null);
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
const [remark, setRemark] = useState('');
const table = useServerTable();
const regrade = async (registration: ExamRegistration) => {
const attemptId = registration.attempt?.id;
if (!attemptId) return;
setRegrading(attemptId);
try {
const outcome = await regradeAttempt(attemptId).unwrap();
if (outcome.graded) {
notify.success(t('exam.candidates.regraded'));
} else {
notify.error(t('exam.candidates.regradeNotEligible', { reason: outcome.reason }));
}
refetch();
} catch (error) {
notify.error(extractErrorMessage(error, t('exam.candidates.regradeError')));
} finally {
setRegrading(null);
}
};
const startRecording = (registration: ExamRegistration) => {
setTarget(registration);
setStatus(
@@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
) : (
<AdvancedTable
tableName={t('exam.candidates.section')}
columns={examCandidateColumns(t, { onRecord: startRecording })}
columns={examCandidateColumns(t, {
onRecord: startRecording,
onRegrade: regrade,
regrading,
})}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}

View File

@@ -1,15 +1,17 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Button, Text } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
OPEN: 'red',
UNDER_REVIEW: 'yellow',
RESOLVED: 'teal',
DISMISSED: 'gray',
const STATUS_TONE: Record<ExamIncidentStatus, StatusTone> = {
OPEN: 'danger',
UNDER_REVIEW: 'warning',
RESOLVED: 'success',
DISMISSED: 'neutral',
};
export function examIncidentColumns(
@@ -56,13 +58,12 @@ export function examIncidentColumns(
{
header: t('exam.incidents.status'),
cell: ({ row }) => (
<Badge
<StatusBadge
tone={STATUS_TONE[row.original.status] ?? 'neutral'}
label={t(`exam.incidentStatus.${row.original.status}`)}
size="sm"
variant="light"
color={STATUS_COLOR[row.original.status] ?? 'gray'}
>
{t(`exam.incidentStatus.${row.original.status}`)}
</Badge>
/>
),
},
{

View File

@@ -1,3 +1,4 @@
import { type StatusTone } from '@ema-platform/shared';
import { useState, useEffect, useRef, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
@@ -39,7 +40,7 @@ import {
IconCheck,
IconX,
} from '@tabler/icons-react';
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import {
@@ -57,16 +58,20 @@ 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" };
const FORM_LABEL: Record<string, string> = {
ESSAY: "Essay",
CHOICE: "Choice",
BOTH: "Both",
};
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
const ADMIN_LABEL: Record<string, string> = {
OFFLINE: "Offline",
@@ -171,6 +176,11 @@ export function ExamDetailPage() {
notify.error(
key.startsWith('insufficient_approved_questions')
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
max: key.split(':')[1]?.split('/')[0] ?? '',
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
})
: key,
);
}
@@ -187,18 +197,35 @@ export function ExamDetailPage() {
notify.error(
key.startsWith('question_not_approved')
? t('question.qc.onlyApprovedUsable')
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
max: key.split(':')[1]?.split('/')[0] ?? '',
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
})
: key,
);
}
};
const handlePrint = async () => {
const total = (exam.questions ?? []).reduce(
(s, q) => s + Number(q.points),
0,
);
if (total < Number(exam.cuttingPoint)) {
// The reachable max depends on the evaluation method, not the raw point
// sum — mirrors RecordResultModal's grading math so "can this paper pass"
// means the same thing here as it does at marking time. Cutting point can
// be raised after the paper was assembled (edit modal, no re-check on
// save), so this still needs to run even though assignment now enforces
// it too.
const questions = exam.questions ?? [];
const total = questions.reduce((s, q) => s + Number(q.points), 0);
const reachableMax =
exam.evaluationMethod === 'AVERAGE'
? questions.length
? total / questions.length
: 0
: exam.evaluationMethod === 'PERCENTAGE'
? 100
: total;
if (reachableMax < Number(exam.cuttingPoint)) {
notify.error(
`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
`This paper cannot reach the passing mark under its ${EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} evaluation (max ${reachableMax}, pass mark ${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
);
return;
}
@@ -232,7 +259,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 +290,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 +314,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 +346,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 +369,13 @@ export function ExamDetailPage() {
</Group>
{/* Status badge */}
<Badge
<StatusBadge
tone={STATUS_TONE[exam.status]}
label={t(`exam.status.${exam.status}`)}
size="lg"
variant="light"
color={STATUS_COLOR[exam.status]}
style={{ width: "fit-content" }}
>
{t(`exam.status.${exam.status}`)}
</Badge>
/>
{/* Exam Info */}
<Paper withBorder radius="lg" p="lg">

View File

@@ -1,5 +1,11 @@
import { ActionIcon, Group } from "@mantine/core";
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
import { ActionIcon, Menu } from "@mantine/core";
import {
IconDetails,
IconDotsVertical,
IconEdit,
IconTrash,
IconToggleRight,
} from "@tabler/icons-react";
import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
@@ -11,40 +17,55 @@ export function examActionsColumn(
onEdit: (exam: Exam) => void;
onDelete: (exam: Exam) => void;
onDetails: (exam: Exam) => void;
onOpenStatusChange: (exam: Exam) => void;
changingStatusId?: string | null;
},
): AdvancedColumn<Exam> {
return {
header: t("exam.columns.actions"),
header: t("exam.columns.actions", "Actions"),
align: "right",
cell: ({ row }) => (
<Group gap="xs">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="blue"
color="gray"
size="sm"
onClick={() => handlers.onEdit(row.original)}
loading={handlers.changingStatusId === row.original.id}
>
<IconEdit size={14} />
<IconDotsVertical size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDelete(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
</RequirePermission>
<ActionIcon
variant="subtle"
color="red"
size="sm"
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconDetails size={14} />}
onClick={() => handlers.onDetails(row.original)}
>
<IconDetails size={14} />
</ActionIcon>
</Group>
{t("exam.action.details", "Details")}
</Menu.Item>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={() => handlers.onEdit(row.original)}
>
{t("exam.action.edit", "Edit")}
</Menu.Item>
<Menu.Item
leftSection={<IconToggleRight size={14} />}
onClick={() => handlers.onOpenStatusChange(row.original)}
>
{t("exam.form.status")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => handlers.onDelete(row.original)}
>
{t("exam.action.delete", "Delete")}
</Menu.Item>
</RequirePermission>
</Menu.Dropdown>
</Menu>
),
};
}

View File

@@ -1,15 +1,17 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Text } from "@mantine/core";
import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui";
import { StatusBadge } from '@ema-platform/ui';
import type { Exam } from "../../types/exam";
const STATUS_COLOR: Record<string, string> = {
PENDING: "gray",
ACTIVE: "blue",
COMPLETED: "teal",
CANCELLED: "red",
POSTPONED: "orange",
PUBLISHED: "green",
const STATUS_TONE: Record<string, StatusTone> = {
PENDING: 'neutral',
ACTIVE: 'info',
COMPLETED: 'success',
CANCELLED: 'danger',
POSTPONED: 'pending',
PUBLISHED: 'success',
};
export function examColumns(
@@ -72,9 +74,12 @@ export function examColumns(
{
header: t("exam.columns.status"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
{t(`exam.status.${row.original.status}`)}
</Badge>
<StatusBadge
tone={STATUS_TONE[row.original.status]}
label={t(`exam.status.${row.original.status}`)}
size="sm"
variant="light"
/>
),
},
];

View File

@@ -2,7 +2,6 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Stack,
Title,
Group,
Button,
Modal,
@@ -10,7 +9,6 @@ import {
TextInput,
Textarea,
Card,
Alert,
Select,
NumberInput,
Tabs,
@@ -26,6 +24,7 @@ import {
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
import { RANK_KEY_OPTIONS } from "../../../certification/types/certification";
import {
useGetExamsQuery,
useCreateExamMutation,
@@ -35,6 +34,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 +77,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 +123,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")}
@@ -247,11 +249,18 @@ function ExamForm({
data={[
{ value: "ESSAY", label: t("exam.form.essay") },
{ value: "CHOICE", label: t("exam.form.choice") },
{ value: "BOTH", label: t("exam.form.both") },
]}
value={form}
onChange={setForm}
size="sm"
required
disabled={adminMethod === "ONLINE"}
description={
adminMethod === "ONLINE"
? t("exam.form.onlineChoiceOnlyHint")
: undefined
}
/>
<Select
label={t("exam.detail.administration")}
@@ -261,7 +270,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 +307,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,10 +379,24 @@ 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);
// Rank in the label: an exam inherits its STCW rank from the certification
// it is created under (Certification.rankKey), so the officer sees which
// rank a sitting will serve at the moment they pick the subject.
const certOptions = certifications
.filter((c) => c.isActive)
.map((c) => ({ value: c.id, label: c.name[locale] }));
.map((c) => {
const rank = RANK_KEY_OPTIONS.find((r) => r.value === c.rankKey)?.label;
return {
value: c.id,
label: rank ? `${c.name[locale]}${rank}` : c.name[locale],
};
});
const getCertName = (id: string) =>
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
@@ -403,6 +443,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 +471,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 +485,12 @@ export function ExamPage() {
openDelete();
},
onDetails: (exam) => navigate(`/exams/${exam.id}`),
onOpenStatusChange: (exam) => {
setStatusTarget(exam);
setPendingStatus(exam.status);
openStatus();
},
changingStatusId,
}),
];
@@ -443,14 +498,12 @@ export function ExamPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t("exam.title")}</Title>
<Text fz="sm" c="dimmed">
{t("exam.subtitle")}
</Text>
</div>
{!showForm && (
<PageHeader
title={t("exam.title")}
subtitle={t("exam.subtitle")}
noMargin
action={
!showForm && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
@@ -461,8 +514,9 @@ export function ExamPage() {
{t("exam.add")}
</Button>
</RequirePermission>
)}
</Group>
)
}
/>
{showForm && (
<ExamForm
@@ -511,6 +565,47 @@ export function ExamPage() {
</Button>
</ModalFooter>
</Modal>
{/* Quick status change — not the full edit form */}
<Modal
opened={statusOpened}
onClose={closeStatus}
title={t("exam.form.status")}
size="sm"
>
<Stack gap="md">
<Text fz="sm" c="dimmed">
{statusTarget?.title?.[locale]}
</Text>
<Select
label={t("exam.form.status")}
data={[
{ value: "PENDING", label: t("exam.form.pending") },
{ value: "ACTIVE", label: t("exam.form.active") },
{ value: "COMPLETED", label: t("exam.form.completed") },
{ value: "CANCELLED", label: t("exam.form.cancelled") },
{ value: "POSTPONED", label: t("exam.form.postponed") },
{ value: "PUBLISHED", label: t("exam.form.published") },
]}
value={pendingStatus}
onChange={(value) => setPendingStatus(value as Exam["status"])}
size="sm"
/>
<ModalFooter>
<Button variant="default" onClick={closeStatus} size="sm">
{t("exam.cancel")}
</Button>
<Button
onClick={handleChangeStatus}
size="sm"
loading={changingStatusId === statusTarget?.id}
disabled={pendingStatus === statusTarget?.status}
>
{t("exam.update")}
</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -3,6 +3,8 @@ import type { EstimatedTime } from "../../question/types/question";
import type { QuestionForm } from "../../question/types/question";
export type { QuestionForm };
export type ExamForm = QuestionForm | "BOTH";
export type ExamType = "WRITTEN" | "ORAL";
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
@@ -15,11 +17,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 {
@@ -31,7 +41,7 @@ export interface Exam {
date: string;
givenTime: EstimatedTime | null;
type: ExamType;
form: QuestionForm;
form: ExamForm;
venue: string;
administrationMethod: ExamAdministrationMethod;
evaluationMethod: ExamEvaluationMethod;
@@ -55,7 +65,7 @@ export interface CreateExamPayload {
date: string;
givenTime: EstimatedTime;
type: ExamType;
form: QuestionForm;
form: ExamForm;
venue: string;
administrationMethod: ExamAdministrationMethod;
evaluationMethod: ExamEvaluationMethod;
@@ -71,7 +81,7 @@ export interface UpdateExamPayload {
date?: string;
givenTime?: EstimatedTime;
type?: ExamType;
form?: QuestionForm;
form?: ExamForm;
venue?: string;
administrationMethod?: ExamAdministrationMethod;
evaluationMethod?: ExamEvaluationMethod;
@@ -118,8 +128,14 @@ export interface ExamRegistration {
lastName: string | null;
seafarerNumber: string | null;
};
/** The candidate's online sitting, when one has been started. */
attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
}
export type RegradeOutcome =
| { graded: true; resultId: string }
| { graded: false; reason: string };
export interface RecordAttendancePayload {
registrationId: string;
status: AttendanceStatus;

View File

@@ -1,11 +1,12 @@
import { Badge } from '@mantine/core';
import { type StatusTone } from '@ema-platform/shared';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Item } from '../../api/item-api';
const STATUS_COLORS: Record<Item['status'], string> = {
DRAFT: 'gray',
ACTIVE: 'green',
ARCHIVED: 'orange',
const STATUS_TONES: Record<Item['status'], StatusTone> = {
DRAFT: 'neutral',
ACTIVE: 'success',
ARCHIVED: 'pending',
};
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
@@ -14,7 +15,7 @@ export function itemColumns(showDate: (date: string) => string): AdvancedColumn<
{
header: 'Status',
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]}>{row.original.status}</Badge>
<StatusBadge tone={STATUS_TONES[row.original.status]} label={row.original.status} />
),
},
{

View File

@@ -1,10 +1,11 @@
import { Stack, Title, Paper } from '@mantine/core';
import {Stack, Paper} from '@mantine/core';
import { ItemTable } from '../components/ItemTable';
import { PageHeader } from '@ema-platform/ui';
export function ItemPage() {
return (
<Stack gap="lg">
<Title order={2}>Items</Title>
<PageHeader title="Items" noMargin />
<Paper p="md" shadow="sm" radius="md" withBorder>
<ItemTable />
</Paper>

View File

@@ -1,15 +1,17 @@
import { Badge, Button, Text, Tooltip } from '@mantine/core';
import { type StatusTone } from '@ema-platform/shared';
import { Button, Text, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const LICENSE_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
EXPIRED: 'yellow',
SUSPENDED: 'orange',
CANCELLED: 'red',
SUPERSEDED: 'gray',
const LICENSE_STATUS_TONES: Record<string, StatusTone> = {
ACTIVE: 'success',
EXPIRED: 'warning',
SUSPENDED: 'pending',
CANCELLED: 'danger',
SUPERSEDED: 'neutral',
};
export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
@@ -70,13 +72,12 @@ export function licenseRegisterColumns(
{
header: 'Status',
cell: ({ row }) => (
<Badge
<StatusBadge
tone={LICENSE_STATUS_TONES[row.original.status] ?? 'neutral'}
label={row.original.status}
size="sm"
variant="light"
color={LICENSE_STATUS_COLORS[row.original.status] ?? 'gray'}
>
{row.original.status}
</Badge>
/>
),
},
{

View File

@@ -1,19 +1,7 @@
import { useState } from 'react';
import {
Button,
Card,
Container,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import {Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Textarea} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -151,13 +139,10 @@ export function LicenseRegisterPage() {
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>Licence register</Title>
<Text size="sm" c="dimmed">
{data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'}
</Text>
</div>
<PageHeader
title="Licence register"
subtitle={`${data?.total ?? 0} issued licence${(data?.total ?? 0) === 1 ? '' : 's'}`}
action={
<TextInput
placeholder="Certificate № or company"
leftSection={<IconSearch size={14} />}
@@ -165,7 +150,8 @@ export function LicenseRegisterPage() {
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
}
/>
<Card withBorder padding={0}>
<AdvancedTable<IssuedLicense>

View File

@@ -1,20 +1,17 @@
import { useState } from 'react';
import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Modal, Select, Stack, Text } from '@mantine/core';
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;
onConfirm: (payload: {
examId: string;
admissionNumber?: string;
examDate?: string;
}) => void;
onConfirm: (payload: { examId: string; examDate?: string }) => void;
}
/**
@@ -22,33 +19,34 @@ 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;
onConfirm({
examId,
admissionNumber: admissionNumber.trim() || undefined,
examDate: selected?.date ? String(selected.date) : undefined,
});
}
@@ -72,7 +70,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>
) : (
@@ -88,15 +86,12 @@ export function ScheduleExamModal({
/>
)}
<TextInput
label={t('review.scheduleExam.admissionNumber', 'Admission number')}
description={t(
<Text size="xs" c="dimmed">
{t(
'review.scheduleExam.admissionHint',
'Leave blank to let the system issue one.',
'An admission number is issued automatically when the candidate is seated.',
)}
value={admissionNumber}
onChange={(e) => setAdmissionNumber(e.currentTarget.value)}
/>
</Text>
<ModalFooter>
<Button variant="default" onClick={onClose}>

View File

@@ -17,7 +17,6 @@ import {
Tabs,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
@@ -72,6 +71,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;
@@ -501,15 +501,10 @@ export function LicenseQueuePage() {
return (
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{queueTitle}</Title>
{typeCode && (
<Text size="sm" c="dimmed">
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
</Text>
)}
</div>
<PageHeader
title={queueTitle}
subtitle={typeCode ? t(`nav.type${typeCode}`, { defaultValue: typeCode }) : undefined}
action={
<Group gap="xs">
<SegmentedControl
size="xs"
@@ -535,7 +530,8 @@ export function LicenseQueuePage() {
{t("queue.export", "Export CSV")}
</Button>
</Group>
</Group>
}
/>
{/* Saved views, counted. */}
<Tabs

View File

@@ -39,6 +39,7 @@ import {
useLocalized,
useApproveDocumentsMutation,
useAssignApplicationMutation,
useClaimApplicationMutation,
useCompleteReviewMutation,
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
@@ -198,6 +199,7 @@ export function LicenseReviewPage() {
[requirements],
);
const [claimApplication] = useClaimApplicationMutation();
const [completeReview] = useCompleteReviewMutation();
const [requestAdjustment] = useRequestAdjustmentMutation();
const [approveDocuments] = useApproveDocumentsMutation();
@@ -531,8 +533,12 @@ export function LicenseReviewPage() {
try {
switch (action.id) {
case "claim":
// Claim is fired from the queue in practice; kept here for the case
// where an officer opens an unclaimed application directly.
// Usually fired from the queue, but an officer can also open an
// unclaimed application directly and claim it from here.
await run(
() => claimApplication(id).unwrap(),
t("review.done.claim", "Application claimed"),
);
break;
case "complete-review":
await run(
@@ -724,7 +730,7 @@ export function LicenseReviewPage() {
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{headerName}</Title>
<Title order={2}>{headerName}</Title>
<Group gap="xs">
<Text size="sm" c="dimmed">
{app.applicationNumber}
@@ -1156,6 +1162,7 @@ export function LicenseReviewPage() {
<ScheduleExamModal
opened={scheduleExamOpen}
applicationId={id}
applicantName={
app.companyName ||
applicantFullName ||

View File

@@ -1,23 +1,9 @@
import { useState, useCallback } from 'react';
import {
Stack,
Title,
Group,
Button,
Paper,
Text,
Grid,
Modal,
ActionIcon,
Tooltip,
Loader,
Center,
Alert,
} from '@mantine/core';
import {Stack, Group, Button, Paper, Text, Grid, Modal, ActionIcon, Tooltip, Loader, Center, Alert} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui';
import { ModalFooter, notify, PageHeader, PageLoader, useErrorHandler } from '@ema-platform/ui';
import { LocationTree } from '../components/LocationTree';
import { LocationDetail } from '../components/LocationDetail';
import { LocationForm } from '../components/LocationForm';
@@ -108,8 +94,10 @@ export function LocationPage() {
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={2}>{t('location.title')}</Title>
<PageHeader
title={t('location.title')}
noMargin
action={
<Group gap="sm">
{locationTypes.length > 0 && (
<Button
@@ -136,7 +124,8 @@ export function LocationPage() {
</ActionIcon>
</Tooltip>
</Group>
</Group>
}
/>
{locationTypes.length === 0 && (
<Alert

View File

@@ -1,19 +1,7 @@
import { useNavigate } from 'react-router-dom';
import {
Badge,
Card,
Center,
Container,
Grid,
Group,
Loader,
SimpleGrid,
Stack,
Text,
Title,
} from '@mantine/core';
import {Badge, Card, Center, Container, Grid, Group, Loader, SimpleGrid, Stack, Text} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
@@ -74,12 +62,10 @@ export function LogisticsHeadDashboardPage() {
return (
<Container size="xl" py="md">
<Title order={3} mb="xs">
Logistics overview
</Title>
<Text size="sm" c="dimmed" mb="lg">
Licence applications currently in the department.
</Text>
<PageHeader
title="Logistics overview"
subtitle="Licence applications currently in the department."
/>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
{stats.map((stat) => (

View File

@@ -1,6 +1,8 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import {
seaServiceDays,
type MedicalCertificate,
@@ -18,10 +20,10 @@ export function ownerName(profile?: SeafarerProfileSummary): string {
);
}
const STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
SUBMITTED: 'yellow',
VERIFIED: 'teal',
REJECTED: 'red',
const STATUS_TONE: Record<SeafarerRecordStatus, StatusTone> = {
SUBMITTED: 'warning',
VERIFIED: 'success',
REJECTED: 'danger',
};
/** Only meaningful now the queue can show ruled records too. */
@@ -33,9 +35,12 @@ function statusColumn<T extends { status: SeafarerRecordStatus }>(
label: t('recordVerification.columns.status', 'Status'),
accessorKey: 'status',
cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
{t(`recordVerification.status.${row.original.status}`, row.original.status)}
</Badge>
<StatusBadge
tone={STATUS_TONE[row.original.status]}
label={t(`recordVerification.status.${row.original.status}`, row.original.status)}
size="sm"
variant="light"
/>
),
};
}

View File

@@ -1,27 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Center,
Container,
Group,
Loader,
Modal,
Paper,
SegmentedControl,
Stack,
Text,
Textarea,
Title,
} from '@mantine/core';
import {Badge, Button, Center, Container, Group, Loader, Modal, Paper, SegmentedControl, Stack, Text, Textarea} from '@mantine/core';
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
import {
AdvancedTable,
notify,
PdfPreviewModal,
type AdvancedColumn,
} from '@ema-platform/ui';
import { AdvancedTable, notify, PageHeader, PdfPreviewModal, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -198,20 +179,21 @@ export type VerificationKind = 'medical' | 'sea-service';
*/
export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) {
const { t } = useTranslation();
const isMedical = kind === 'medical';
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
const {
data: pendingMedical,
isLoading: loadingMedical,
isFetching: fetchingMedical,
refetch: refetchMedical,
} = useGetPendingMedicalQuery(filter);
} = useGetPendingMedicalQuery(filter, { skip: !isMedical });
const {
data: pendingSeaService,
isLoading: loadingSeaService,
isFetching: fetchingSeaService,
refetch: refetchSeaService,
} = useGetPendingSeaServiceQuery(filter);
} = useGetPendingSeaServiceQuery(filter, { skip: isMedical });
const [verifyMedical, { isLoading: rulingMedical }] =
useVerifyMedicalCertificateMutation();
@@ -372,17 +354,16 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
[rulingSeaService, rule, verifySeaService, showDate, t],
);
const isMedical = kind === 'medical';
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
{isMedical
<PageHeader
title={
isMedical
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
</Title>
<Text size="sm" c="dimmed" mb="md">
{isMedical
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')
}
subtitle={
isMedical
? t(
'recordVerification.medicalSubtitle',
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
@@ -390,8 +371,11 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
: t(
'recordVerification.seaServiceSubtitle',
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
)}
</Text>
)
}
/>
{statusFilter}
{isMedical ? (
<AdvancedTable
@@ -408,7 +392,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
onPageSizeChange={handleMedicalPageSizeChange}
refresh={refetchMedical}
isLoading={loadingMedical || fetchingMedical}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
emptyText={emptyText}
/>
) : (
<AdvancedTable
@@ -425,7 +409,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
onPageSizeChange={handleSeaServicePageSizeChange}
refresh={refetchSeaService}
isLoading={loadingSeaService || fetchingSeaService}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
emptyText={emptyText}
/>
)}

View File

@@ -1,37 +1,13 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Button,
Center,
Group,
Loader,
Modal,
NumberInput,
Paper,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
Title,
Tooltip,
} from '@mantine/core';
import {Alert, Badge, Button, Center, Group, Loader, Modal, NumberInput, Paper, Stack, Switch, Text, TextInput, ThemeIcon, Tooltip} from '@mantine/core';
import {
IconAlertTriangle,
IconCreditCard,
IconInfoCircle,
IconLock,
} from '@tabler/icons-react';
import {
notify,
ModalFooter,
AdvancedTable,
useServerTable,
type AdvancedColumn,
PageLoader,
} from '@ema-platform/ui';
import { AdvancedTable, ModalFooter, notify, PageHeader, PageLoader, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import {
extractErrorMessage,
useLocalized,
@@ -92,20 +68,19 @@ export function PaymentConfigPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<div>
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
<Text size="sm" c="dimmed" mt={4}>
{t(
<PageHeader
title={t('paymentConfig.title', 'Payment configuration')}
subtitle={t(
'paymentConfig.subtitle',
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
)}
</Text>
</div>
noMargin
action={
<ThemeIcon size="xl" radius="md" variant="light">
<IconCreditCard size={22} />
</ThemeIcon>
</Group>
}
/>
<Alert
variant="light"

View File

@@ -31,7 +31,6 @@ import {
IconLock,
IconMail,
IconMoon,
IconPhone,
IconSettings,
IconShieldLock,
IconSun,
@@ -42,7 +41,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber } from '@ema-platform/ui';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { ActiveSessions, setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
@@ -136,6 +135,9 @@ export function ProfilePage() {
register: registerProfile,
handleSubmit: handleProfileSubmit,
reset: resetProfile,
watch: watchProfile,
setValue: setValueProfile,
trigger: triggerProfile,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
@@ -247,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>
@@ -372,11 +374,12 @@ export function ProfilePage() {
error={profileErrors.email?.message}
{...registerProfile('email')}
/>
<TextInput
<PhoneInput
label={t('profile.fields.phone')}
leftSection={<IconPhone size={18} />}
value={watchProfile('phoneNumber') || ''}
onChange={(val) => setValueProfile('phoneNumber', val, { shouldValidate: !!profileErrors.phoneNumber })}
onBlur={() => triggerProfile('phoneNumber')}
error={profileErrors.phoneNumber?.message}
{...registerProfile('phoneNumber')}
/>
</SimpleGrid>
</div>

View File

@@ -1,10 +1,12 @@
import { baseApi } from '@ema-platform/api';
import type {
Question,
QuestionOption,
ListResponse,
CreateQuestionPayload,
UpdateQuestionPayload,
ReviewQuestionPayload,
SetQuestionOptionsPayload,
} from '../types/question';
const questionApi = baseApi.injectEndpoints({
@@ -17,6 +19,11 @@ const questionApi = baseApi.injectEndpoints({
query: (id) => `/questions/${id}`,
providesTags: ['Api'],
}),
/** Same question, with `options` populated — the MCQ authoring editor. */
getQuestionWithOptions: builder.query<Question, string>({
query: (id) => `/questions/${id}?i=options`,
providesTags: ['Api'],
}),
createQuestion: builder.mutation<Question, CreateQuestionPayload>({
query: (body) => ({ url: '/questions', method: 'POST', body }),
invalidatesTags: ['Api'],
@@ -47,6 +54,15 @@ const questionApi = baseApi.injectEndpoints({
}),
invalidatesTags: ['Api'],
}),
/** Full replace of a CHOICE question's options + correct-answer set (Phase 2). */
setQuestionOptions: builder.mutation<QuestionOption[], SetQuestionOptionsPayload>({
query: ({ id, ...body }) => ({
url: `/questions/${id}/options`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
@@ -54,9 +70,11 @@ const questionApi = baseApi.injectEndpoints({
export const {
useGetQuestionsQuery,
useGetQuestionQuery,
useGetQuestionWithOptionsQuery,
useCreateQuestionMutation,
useUpdateQuestionMutation,
useDeleteQuestionMutation,
useSubmitQuestionMutation,
useReviewQuestionMutation,
useSetQuestionOptionsMutation,
} = questionApi;

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react';
import { notify, useErrorHandler } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import {
useGetQuestionWithOptionsQuery,
useSetQuestionOptionsMutation,
} from '../api/question-api';
interface DraftOption {
text: BilingualValue;
isCorrect: boolean;
}
/**
* MCQ options + correct-answer editor for a CHOICE-form question (Phase 2).
*
* Only reachable while editing an already-created question — options attach
* to a question id, matching the backend's `PUT /questions/:id/options`
* full-replace endpoint. Nothing here is ever shown to a candidate; this is
* the authoring side only.
*/
export function QuestionOptionsEditor({ questionId }: { questionId: string }) {
const { t } = useTranslation();
const { handleError } = useErrorHandler();
const { data: question, isFetching } = useGetQuestionWithOptionsQuery(questionId);
const [setOptions, { isLoading: isSaving }] = useSetQuestionOptionsMutation();
const [draft, setDraft] = useState<DraftOption[]>([]);
useEffect(() => {
if (!question) return;
const existing = question.options ?? [];
setDraft(
existing.length
? existing
.slice()
.sort((a, b) => a.order - b.order)
.map((o) => ({ text: o.text, isCorrect: false }))
: [
{ text: { en: '', am: '' }, isCorrect: false },
{ text: { en: '', am: '' }, isCorrect: false },
],
);
// isCorrect never comes back from the API by design — an examiner
// re-editing options re-marks the correct one(s) rather than us
// pretending to know what they were.
}, [question]);
const updateField = (index: number, lang: keyof BilingualValue, value: string) => {
setDraft((prev) =>
prev.map((o, i) => (i === index ? { ...o, text: { ...o.text, [lang]: value } } : o)),
);
};
const toggleCorrect = (index: number) => {
setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, isCorrect: !o.isCorrect } : o)));
};
const addOption = () => {
setDraft((prev) => [...prev, { text: { en: '', am: '' }, isCorrect: false }]);
};
const removeOption = (index: number) => {
setDraft((prev) => prev.filter((_, i) => i !== index));
};
const handleSave = async () => {
if (draft.length < 2) {
notify.error(t('question.options.needAtLeastTwo'));
return;
}
if (!draft.some((o) => o.isCorrect)) {
notify.error(t('question.options.needOneCorrect'));
return;
}
if (draft.some((o) => !o.text.en.trim() || !o.text.am.trim())) {
notify.error(t('question.options.textRequired'));
return;
}
try {
await setOptions({ id: questionId, options: draft }).unwrap();
notify.success(t('question.options.saved'));
} catch (e) {
handleError(e);
}
};
if (isFetching) return <Loader size="sm" />;
return (
<Stack gap="sm">
<Alert icon={<IconInfoCircle size={15} />} color="blue" variant="light">
{t('question.options.hint')}
</Alert>
{draft.map((option, index) => (
<Group key={index} gap="xs" wrap="nowrap" align="center">
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
<Stack gap={6} style={{ flex: 1 }}>
<TextInput
label={t('question.options.optionEn', { number: index + 1 })}
value={option.text.en}
onChange={(e) => updateField(index, 'en', e.currentTarget.value)}
size="sm"
required
/>
<TextInput
label={t('question.options.optionAm', { number: index + 1 })}
value={option.text.am}
onChange={(e) => updateField(index, 'am', e.currentTarget.value)}
size="sm"
required
/>
</Stack>
<Checkbox
label={t('question.options.correct')}
checked={option.isCorrect}
onChange={() => toggleCorrect(index)}
/>
<ActionIcon
variant="subtle"
color="red"
size="sm"
disabled={draft.length <= 2}
onClick={() => removeOption(index)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
<Group justify="space-between">
<Button variant="subtle" size="xs" leftSection={<IconPlus size={14} />} onClick={addOption}>
{t('question.options.addOption')}
</Button>
<Button size="sm" loading={isSaving} onClick={handleSave}>
{t('question.options.save')}
</Button>
</Group>
<Text fz="xs" c="dimmed">{t('question.options.replaceNotice')}</Text>
</Stack>
);
}

View File

@@ -1,5 +1,11 @@
import { ActionIcon, Button, Group } from '@mantine/core';
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
import { ActionIcon, Menu } from '@mantine/core';
import {
IconDotsVertical,
IconEdit,
IconGavel,
IconSend,
IconTrash,
} from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -21,52 +27,64 @@ export function questionActionsColumn(
cell: ({ row }) => {
const q = row.original;
return (
<Group gap="xs">
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
loading={handlers.isSubmittingReview}
>
<IconDotsVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<Button
size="compact-xs"
variant="light"
leftSection={<IconSend size={12} />}
loading={handlers.isSubmittingReview}
<Menu.Item
leftSection={<IconSend size={14} />}
onClick={() => handlers.onSubmitForApproval(q)}
>
{t('question.qc.submit')}
</Button>
</Menu.Item>
</RequirePermission>
)}
{q.status === 'PENDING_APPROVAL' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
<Menu.Item color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
{t('question.qc.approve')}
</Button>
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
</Menu.Item>
<Menu.Item color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
{t('question.qc.reject')}
</Button>
</Menu.Item>
</RequirePermission>
)}
{q.status === 'APPROVED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Button
size="compact-xs"
variant="subtle"
<Menu.Item
color="dark"
leftSection={<IconGavel size={12} />}
leftSection={<IconGavel size={14} />}
onClick={() => handlers.onReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Button>
</Menu.Item>
</RequirePermission>
)}
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
<IconTrash size={14} />
</ActionIcon>
<Menu.Divider />
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => handlers.onEdit(q)}>
{t('question.action.edit', 'Edit')}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => handlers.onDelete(q)}
>
{t('question.action.delete', 'Delete')}
</Menu.Item>
</RequirePermission>
</Group>
</Menu.Dropdown>
</Menu>
);
},
};

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from "react";
import {
Stack,
Title,
@@ -13,14 +13,30 @@ import {
Select,
NumberInput,
Textarea,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
Checkbox,
ActionIcon,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import {
IconPlus,
IconInfoCircle,
IconTrash,
IconGripVertical,
} from "@tabler/icons-react";
import {
AdvancedColumn,
AdvancedTable,
ErrorState,
ModalFooter,
notify,
PageHeader,
useErrorHandler,
useServerTable,
} from "@ema-platform/ui";
import { extractErrorMessage } from "@ema-platform/api";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
import {
useGetQuestionsQuery,
useCreateQuestionMutation,
@@ -28,10 +44,92 @@ import {
useDeleteQuestionMutation,
useSubmitQuestionMutation,
useReviewQuestionMutation,
} from '../../api/question-api';
import type { Question, QuestionForm } from '../../types/question';
import { questionColumns } from './columns';
import { questionActionsColumn } from './actions';
useSetQuestionOptionsMutation,
} from "../../api/question-api";
import type {
Question,
QuestionForm,
QuestionOptionInput,
} from "../../types/question";
import { QuestionOptionsEditor } from "../../components/QuestionOptionsEditor";
import { questionColumns } from "./columns";
import { questionActionsColumn } from "./actions";
type DraftOption = { textEn: string; textAm: string; isCorrect: boolean };
const BLANK_DRAFT_OPTIONS: DraftOption[] = [
{ textEn: "", textAm: "", isCorrect: false },
{ textEn: "", textAm: "", isCorrect: false },
];
/**
* Options for a brand-new CHOICE question, entered inline in the same
* modal — no question id exists yet, so this is pure local state, only
* turned into a real setOptions() call once the question itself is
* created (see QuestionPage.handleSubmit).
*/
function InlineOptionsEditor({
options,
onChange,
}: {
options: DraftOption[];
onChange: (options: DraftOption[]) => void;
}) {
const { t } = useTranslation();
const update = (index: number, patch: Partial<DraftOption>) =>
onChange(options.map((o, i) => (i === index ? { ...o, ...patch } : o)));
return (
<Stack gap="xs">
{options.map((option, index) => (
<Group key={index} gap="xs" wrap="nowrap" align="center">
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
<Stack gap={6} style={{ flex: 1 }}>
<TextInput
label={t("question.options.optionEn", { number: index + 1 })}
value={option.textEn}
onChange={(e) => update(index, { textEn: e.currentTarget.value })}
size="sm"
required
/>
<TextInput
label={t("question.options.optionAm", { number: index + 1 })}
value={option.textAm}
onChange={(e) => update(index, { textAm: e.currentTarget.value })}
size="sm"
required
/>
</Stack>
<Checkbox
label={t("question.options.correct")}
checked={option.isCorrect}
onChange={() => update(index, { isCorrect: !option.isCorrect })}
/>
<ActionIcon
variant="subtle"
color="red"
size="sm"
disabled={options.length <= 2}
onClick={() => onChange(options.filter((_, i) => i !== index))}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
<Button
variant="subtle"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
onChange([...options, { textEn: "", textAm: "", isCorrect: false }])
}
>
{t("question.options.addOption")}
</Button>
</Stack>
);
}
function QuestionForm({
editing,
@@ -43,7 +141,8 @@ function QuestionForm({
editing: Question | null;
certOptions: { value: string; label: string }[];
isSubmitting: boolean;
onSubmit: (values: {
onSubmit: (
values: {
certificationId: string;
titleEn: string;
titleAm: string;
@@ -52,49 +151,171 @@ function QuestionForm({
days: number;
hours: number;
minutes: number;
}, isEdit: boolean) => void;
draftOptions: DraftOption[];
},
isEdit: boolean,
) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
const [certificationId, setCertificationId] = useState<string | null>(
editing?.certificationId ?? null,
);
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
const [form, setForm] = useState<string | null>(editing?.form ?? null);
const [points, setPoints] = useState<number>(editing?.points ?? 0);
const [days, setDays] = useState(editing?.time?.days ?? 0);
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
const [draftOptions, setDraftOptions] =
useState<DraftOption[]>(BLANK_DRAFT_OPTIONS);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!certificationId || !titleEn || !titleAm || !form) {
notify.error('Please fill all required fields');
notify.error("Please fill all required fields");
return;
}
onSubmit({
certificationId, titleEn, titleAm, form, points, days, hours, minutes
}, !!editing);
if (!editing && form === "CHOICE") {
if (draftOptions.length < 2) {
notify.error(t("question.options.needAtLeastTwo"));
return;
}
if (!draftOptions.some((o) => o.isCorrect)) {
notify.error(t("question.options.needOneCorrect"));
return;
}
if (draftOptions.some((o) => !o.textEn.trim() || !o.textAm.trim())) {
notify.error(t("question.options.textRequired"));
return;
}
}
onSubmit(
{
certificationId,
titleEn,
titleAm,
form,
points,
days,
hours,
minutes,
draftOptions: !editing && form === "CHOICE" ? draftOptions : [],
},
!!editing,
);
};
return (
<Modal opened onClose={onCancel} title={editing ? t('question.update') : t('question.addQuestion')} size="lg">
<Modal
opened
onClose={onCancel}
title={editing ? t("question.update") : t("question.addQuestion")}
size="lg"
>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
<TextInput label={t('question.form.titleEn')} placeholder={t('question.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
<TextInput label={t('question.form.titleAm')} placeholder={t('question.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
<Select label={t('question.form.form')} placeholder={t('question.form.selectForm')} data={[{ value: 'ESSAY', label: t('question.form.essay') }, { value: 'CHOICE', label: t('question.form.choice') }]} value={form} onChange={setForm} size="sm" required />
<NumberInput label={t('question.form.points')} placeholder={t('question.form.pointsPlaceholder')} value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required />
<Text fz="sm" fw={500}>{t('question.form.timeAllowed')}</Text>
<Select
label={t("question.form.certification")}
placeholder={t("question.form.selectCertification")}
data={certOptions}
value={certificationId}
onChange={setCertificationId}
size="sm"
searchable
required
/>
<TextInput
label={t("question.form.titleEn")}
placeholder={t("question.form.titleEnPlaceholder")}
value={titleEn}
onChange={(e) => setTitleEn(e.currentTarget.value)}
size="sm"
required
/>
<TextInput
label={t("question.form.titleAm")}
placeholder={t("question.form.titleAmPlaceholder")}
value={titleAm}
onChange={(e) => setTitleAm(e.currentTarget.value)}
size="sm"
required
/>
<Select
label={t("question.form.form")}
placeholder={t("question.form.selectForm")}
data={[
{ value: "ESSAY", label: t("question.form.essay") },
{ value: "CHOICE", label: t("question.form.choice") },
]}
value={form}
onChange={setForm}
size="sm"
required
/>
<NumberInput
label={t("question.form.points")}
placeholder={t("question.form.pointsPlaceholder")}
value={points}
onChange={(v) => setPoints(Number(v))}
min={0}
size="sm"
required
/>
<Text fz="sm" fw={500}>
{t("question.form.timeAllowed")}
</Text>
<Group gap="sm" grow>
<NumberInput label={t('question.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
<NumberInput
label={t("question.form.days")}
value={days}
onChange={(v) => setDays(Number(v))}
min={0}
size="sm"
/>
<NumberInput
label={t("question.form.hours")}
value={hours}
onChange={(v) => setHours(Number(v))}
min={0}
size="sm"
/>
<NumberInput
label={t("question.form.minutes")}
value={minutes}
onChange={(v) => setMinutes(Number(v))}
min={0}
size="sm"
/>
</Group>
{editing && form === "CHOICE" && (
<>
<Text fz="sm" fw={500} mt="sm">
{t("question.options.title")}
</Text>
<QuestionOptionsEditor questionId={editing.id} />
</>
)}
{!editing && form === "CHOICE" && (
<>
<Text fz="sm" fw={500} mt="sm">
{t("question.options.title")}
</Text>
<InlineOptionsEditor
options={draftOptions}
onChange={setDraftOptions}
/>
</>
)}
<ModalFooter>
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
<Button variant="default" onClick={onCancel} size="sm">
{t("question.cancel")}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editing ? t("question.update") : t("question.create")}
</Button>
</ModalFooter>
</Stack>
</form>
@@ -104,7 +325,7 @@ function QuestionForm({
export function QuestionPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const locale = i18n.language as "en" | "am";
const { handleError } = useErrorHandler();
const { data: certRes } = useGetCertificationsQuery();
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
@@ -112,7 +333,10 @@ export function QuestionPage() {
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
const [deleteQ] = useDeleteQuestionMutation();
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
const [setOptions, { isLoading: isSavingOptions }] =
useSetQuestionOptionsMutation();
const [submitQ, { isLoading: isSubmittingReview }] =
useSubmitQuestionMutation();
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
const certifications = certRes?.items ?? [];
@@ -121,60 +345,115 @@ export function QuestionPage() {
const [editing, setEditing] = useState<Question | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [deleteOpened, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [certFilter, setCertFilter] = useState<string | null>(null);
const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED');
const [reviewRemark, setReviewRemark] = useState('');
const [reviewOutcome, setReviewOutcome] = useState<
"APPROVED" | "REJECTED" | "RETIRED"
>("APPROVED");
const [reviewRemark, setReviewRemark] = useState("");
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
const certOptions = certifications
.filter((c) => c.isActive)
.map((c) => ({ value: c.id, label: c.name[locale] }));
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
const filtered = questions.filter(
(q) => !certFilter || q.certificationId === certFilter,
);
const page = paginate(filtered);
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
const getCertName = (id: string) =>
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
const resetForm = () => { setEditing(null); setShowForm(false); };
const resetForm = () => {
setEditing(null);
setShowForm(false);
};
const handleSubmit = async (values: {
certificationId: string; titleEn: string; titleAm: string;
form: string; points: number; days: number; hours: number; minutes: number;
}, isEdit: boolean) => {
const handleSubmit = async (
values: {
certificationId: string;
titleEn: string;
titleAm: string;
form: string;
points: number;
days: number;
hours: number;
minutes: number;
draftOptions: DraftOption[];
},
isEdit: boolean,
) => {
const title = { en: values.titleEn, am: values.titleAm };
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
const time = {
days: values.days,
hours: values.hours,
minutes: values.minutes,
};
try {
if (isEdit && editing) {
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success(t('question.updated'));
await updateQ({
id: editing.id,
certificationId: values.certificationId,
title,
form: values.form as QuestionForm,
points: values.points,
time,
}).unwrap();
notify.success(t("question.updated"));
} else {
await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success(t('question.created'));
const created = await createQ({
certificationId: values.certificationId,
title,
description: { en: "", am: "" },
form: values.form as QuestionForm,
points: values.points,
time,
}).unwrap();
// The question needs an id to attach options to — this is the second
// half of one "create" action from the user's point of view, not a
// separate edit step, so it happens right here rather than waiting
// for them to reopen the question later.
if (values.form === "CHOICE" && values.draftOptions.length) {
const options: QuestionOptionInput[] = values.draftOptions.map(
(o) => ({
text: { en: o.textEn, am: o.textAm },
isCorrect: o.isCorrect,
}),
);
await setOptions({ id: created.id, options }).unwrap();
}
notify.success(t("question.created"));
}
resetForm();
} catch {
notify.error(t('question.error'));
notify.error(t("question.error"));
}
};
const handleSubmitForApproval = async (question: Question) => {
try {
await submitQ(question.id).unwrap();
notify.success(t('question.qc.submitted'));
notify.success(t("question.qc.submitted"));
} catch (error) {
notify.error(extractErrorMessage(error, t('question.qc.error')));
notify.error(extractErrorMessage(error, t("question.qc.error")));
}
};
const openReview = (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => {
const openReview = (
question: Question,
outcome: "APPROVED" | "REJECTED" | "RETIRED",
) => {
setReviewTarget(question);
setReviewOutcome(outcome);
setReviewRemark('');
setReviewRemark("");
};
const handleReview = async () => {
if (!reviewTarget) return;
if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) {
notify.error(t('question.qc.remarkRequired'));
if (reviewOutcome !== "APPROVED" && !reviewRemark.trim()) {
notify.error(t("question.qc.remarkRequired"));
return;
}
try {
@@ -183,10 +462,10 @@ export function QuestionPage() {
outcome: reviewOutcome,
remark: reviewRemark.trim() || undefined,
}).unwrap();
notify.success(t('question.qc.reviewed'));
notify.success(t("question.qc.reviewed"));
setReviewTarget(null);
} catch (error) {
notify.error(extractErrorMessage(error, t('question.qc.error')));
notify.error(extractErrorMessage(error, t("question.qc.error")));
}
};
@@ -194,7 +473,7 @@ export function QuestionPage() {
if (!deleteTarget) return;
try {
await deleteQ(deleteTarget.id).unwrap();
notify.success(t('question.deleted'));
notify.success(t("question.deleted"));
closeDelete();
setDeleteTarget(null);
} catch (e) {
@@ -202,7 +481,8 @@ export function QuestionPage() {
}
};
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
if (isError)
return <ErrorState title={t("question.loadError")} onRetry={refetch} />;
const columns: AdvancedColumn<Question>[] = [
...questionColumns(t, { locale, getCertName }),
@@ -210,51 +490,70 @@ export function QuestionPage() {
isSubmittingReview,
onSubmitForApproval: handleSubmitForApproval,
onReview: openReview,
onEdit: (q) => { setEditing(q); setShowForm(true); },
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
onEdit: (q) => {
setEditing(q);
setShowForm(true);
},
onDelete: (q) => {
setDeleteTarget(q);
openDelete();
},
}),
];
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<Title order={2}>{t('question.title')}</Title>
{!showForm && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('question.addQuestion')}
<PageHeader
title={t("question.title")}
noMargin
action={
!showForm && (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]}
hideOnly
>
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
size="sm"
>
{t("question.addQuestion")}
</Button>
</RequirePermission>
)}
</Group>
)
}
/>
{showForm && (
<QuestionForm
editing={editing}
certOptions={certOptions}
isSubmitting={isCreating || isUpdating}
isSubmitting={isCreating || isUpdating || isSavingOptions}
onSubmit={handleSubmit}
onCancel={resetForm}
/>
)}
<Card withBorder padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>{t('question.pool')}</Text>
<AdvancedTable
columns={columns}
data={page.rows}
title={t("question.pool")}
tableName={t("question.title")}
toolbar={
<Select
placeholder={t('question.filterByCertification')}
data={[{ value: '', label: 'All' }, ...certOptions]}
placeholder={t("question.filterByCertification")}
data={[{ value: "", label: "All" }, ...certOptions]}
value={certFilter}
onChange={(v) => { setCertFilter(v ?? null); setPageIndex(0); }}
onChange={(v) => {
setCertFilter(v ?? null);
setPageIndex(0);
}}
size="sm"
style={{ width: 280 }}
clearable
/>
</Group>
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('question.title')}
}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
@@ -262,47 +561,75 @@ export function QuestionPage() {
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('question.noQuestions')}
emptyText={t("question.noQuestions")}
/>
</Card>
<Modal
opened={Boolean(reviewTarget)}
onClose={() => setReviewTarget(null)}
title={t('question.qc.reviewTitle')}
title={t("question.qc.reviewTitle")}
size="md"
radius="lg"
>
<Stack gap="sm">
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text>
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text>
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content">
<Text fz="sm" fw={500}>
{reviewTarget?.title?.[locale]}
</Text>
<Text fz="xs" c="dimmed">
{t("question.qc.onlyApprovedUsable")}
</Text>
<Badge
variant="light"
color={
reviewOutcome === "APPROVED"
? "teal"
: reviewOutcome === "REJECTED"
? "red"
: "dark"
}
w="fit-content"
>
{t(`question.qc.${reviewOutcome}`)}
</Badge>
<Textarea
label={t('question.qc.remark')}
label={t("question.qc.remark")}
minRows={3}
autosize
value={reviewRemark}
onChange={(e) => setReviewRemark(e.currentTarget.value)}
required={reviewOutcome !== 'APPROVED'}
required={reviewOutcome !== "APPROVED"}
/>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setReviewTarget(null)}>
{t('question.cancel')}
<Button
variant="default"
size="sm"
onClick={() => setReviewTarget(null)}
>
{t("question.cancel")}
</Button>
<Button size="sm" loading={isReviewing} onClick={handleReview}>
{t(`question.qc.${reviewOutcome === 'APPROVED' ? 'approve' : reviewOutcome === 'REJECTED' ? 'reject' : 'retire'}`)}
{t(
`question.qc.${reviewOutcome === "APPROVED" ? "approve" : reviewOutcome === "REJECTED" ? "reject" : "retire"}`,
)}
</Button>
</Group>
</Stack>
</Modal>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
<Text mb="md">{t('question.deleteConfirmText')}</Text>
<Modal
opened={deleteOpened}
onClose={closeDelete}
title={t("question.confirmDelete")}
size="sm"
>
<Text mb="md">{t("question.deleteConfirmText")}</Text>
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
<Button variant="default" onClick={closeDelete} size="sm">
{t("question.cancel")}
</Button>
<Button color="red" onClick={handleDelete} size="sm">
{t("question.delete")}
</Button>
</ModalFooter>
</Modal>
</Stack>

View File

@@ -16,6 +16,17 @@ export type QuestionStatus =
| 'REJECTED'
| 'RETIRED';
/**
* A CHOICE option, as returned by the authoring/QC endpoints. Never carries
* a correctness flag — the API's own answer-key table is never joined into
* this response either, so there's nothing to accidentally serialize here.
*/
export interface QuestionOption {
id: string;
text: LocalePair;
order: number;
}
export interface Question {
id: string;
certificationId: string;
@@ -32,6 +43,8 @@ export interface Question {
submittedAt: string | null;
createdAt: string;
updatedAt: string;
/** Only populated when explicitly requested (`?i=options`). */
options?: QuestionOption[];
}
export interface ReviewQuestionPayload {
@@ -64,3 +77,13 @@ export interface UpdateQuestionPayload {
points?: number;
isActive?: boolean;
}
export interface QuestionOptionInput {
text: LocalePair;
isCorrect: boolean;
}
export interface SetQuestionOptionsPayload {
id: string;
options: QuestionOptionInput[];
}

View File

@@ -1,21 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Center,
Group,
Loader,
Modal,
Paper,
Select,
Stack,
Text,
Textarea,
Title,
} from '@mantine/core';
import {Alert, Button, Center, Group, Loader, Modal, Paper, Select, Stack, Text, Textarea} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable, PageLoader } from '@ema-platform/ui';
import { AdvancedTable, notify, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { extractErrorMessage } from '@ema-platform/api';
import {
@@ -75,12 +62,11 @@ export function ExamAppealsPage() {
return (
<Stack gap="lg">
<div>
<Title order={2}>{t('result.appeals.title')}</Title>
<Text fz="sm" c="dimmed">
{t('result.appeals.subtitle')}
</Text>
</div>
<PageHeader
title={t('result.appeals.title')}
subtitle={t('result.appeals.subtitle')}
noMargin
/>
<Paper withBorder radius="md">
<AdvancedTable<ExamAppeal>

View File

@@ -1,5 +1,5 @@
import { Button, Group } from '@mantine/core';
import { IconEye, IconTrash } from '@tabler/icons-react';
import { ActionIcon, Menu } from '@mantine/core';
import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -13,6 +13,7 @@ export function resultActionsColumn(
onQc: (result: Result, action: QcAction) => void;
onViewDetail: (result: Result) => void;
onDelete: (result: Result) => void;
onPublish: (result: Result) => void;
},
): AdvancedColumn<Result> {
return {
@@ -21,18 +22,27 @@ export function resultActionsColumn(
cell: ({ row }) => {
const r = row.original;
return (
<Group gap="xs">
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconDotsVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => handlers.onViewDetail(r)}>
{t('result.action.viewEdit')}
</Menu.Item>
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
<>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
<Menu.Item color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
{t('result.review.moderate')}
</Button>
</Menu.Item>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
<Menu.Item color="blue" onClick={() => handlers.onQc(r, 'approve')}>
{t('result.review.approve')}
</Button>
</Menu.Item>
</RequirePermission>
</>
)}
@@ -44,26 +54,34 @@ export function resultActionsColumn(
]}
hideOnly
>
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
<Menu.Item color="orange" onClick={() => handlers.onQc(r, 'return')}>
{t('result.review.return')}
</Button>
</Menu.Item>
</RequirePermission>
)}
{r.reviewStatus === 'APPROVED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
<Menu.Item
color="teal"
leftSection={<IconSend size={14} />}
onClick={() => handlers.onPublish(r)}
>
{t('result.review.publish')}
</Menu.Item>
</RequirePermission>
)}
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
{t('result.action.viewEdit')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Button
size="xs"
variant="subtle"
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash size={13} />}
leftSection={<IconTrash size={14} />}
onClick={() => handlers.onDelete(r)}
>
{t('result.action.delete')}
</Button>
</Menu.Item>
</RequirePermission>
</Group>
</Menu.Dropdown>
</Menu>
);
},
};

View File

@@ -1,11 +1,13 @@
import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
import { Badge, Box, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Result, ResultReviewStatus } from '../../types/result';
export const STATUS_COLOR: Record<string, string> = {
PASSED: 'teal',
FAILED: 'red',
export const STATUS_TONE: Record<string, StatusTone> = {
PASSED: 'success',
FAILED: 'danger',
};
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
@@ -46,20 +48,22 @@ export function resultColumns(
{
header: t('result.columns.status'),
cell: ({ row }) => (
<Badge
<StatusBadge
tone={STATUS_TONE[row.original.status]}
label={t(`result.status.${row.original.status}`)}
size="sm"
variant="light"
color={STATUS_COLOR[row.original.status]}
leftSection={
<Box
w={6}
h={6}
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
style={{
borderRadius: 999,
background: `var(--mantine-color-${STATUS_TONE_COLOR[STATUS_TONE[row.original.status]]}-6)`,
}}
/>
}
>
{t(`result.status.${row.original.status}`)}
</Badge>
/>
),
},
{

View File

@@ -1,40 +1,9 @@
import { useState, useCallback, type ElementType } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Title,
Group,
Table,
Badge,
Modal,
Text,
Paper,
Card,
Loader,
Center,
Alert,
Select,
SimpleGrid,
Divider,
Button,
ThemeIcon,
TextInput,
} from '@mantine/core';
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconInfoCircle,
IconUser,
IconCertificate,
IconDeviceFloppy,
IconPlus,
IconClipboardList,
IconCircleCheck,
IconCircleX,
IconChartBar,
IconSearch,
IconSend,
} from '@tabler/icons-react';
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import type { BilingualValue } from '@ema-platform/ui';
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
@@ -53,7 +22,7 @@ import { useGetExamsQuery } from '../../../exam/api/exam-api';
import { RecordResultModal } from '../../components/RecordResultModal';
import type { Result, ResultBreakdown } from '../../types/result';
import type { Exam } from '../../../exam/types/exam';
import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns';
import { resultColumns, STATUS_TONE, REVIEW_COLOR } from './columns';
import { resultActionsColumn, type QcAction } from './actions';
function ResultStat({
@@ -126,6 +95,8 @@ export function ResultPage() {
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [publishTarget, setPublishTarget] = useState<Result | null>(null);
const [publishOpened, { open: openPublish, close: closePublish }] = useDisclosure(false);
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
const [detailSaving, setDetailSaving] = useState(false);
@@ -262,6 +233,19 @@ export function ResultPage() {
}
};
/** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */
const handleConfirmPublish = async () => {
if (!publishTarget) return;
try {
const outcome = await publishResults(publishTarget.examId).unwrap();
notify.success(t('result.review.publishedCount', outcome));
closePublish();
setPublishTarget(null);
} catch (error) {
notify.error(extractErrorMessage(error, t('result.review.error')));
}
};
const handleDetailClose = () => {
closeDetail();
setDetailBreakdowns([]);
@@ -280,7 +264,7 @@ export function ResultPage() {
}
};
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
if (isError) return <ErrorState title={t('result.loadError')} onRetry={refetch} />;
const columns = [
...resultColumns(t, locale, showDate, getExamTitle),
@@ -288,6 +272,7 @@ export function ResultPage() {
onQc: openQc,
onViewDetail: viewDetail,
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
onPublish: (r) => { setPublishTarget(r); openPublish(); },
}),
];
@@ -295,11 +280,11 @@ export function ResultPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t('result.title')}</Title>
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
</div>
<PageHeader
title={t('result.title')}
subtitle={t('result.subtitle')}
noMargin
action={
<Group gap="sm">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
<Button
@@ -314,11 +299,14 @@ export function ResultPage() {
{t('result.review.publish')}
</Button>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')}
</Button>
</RequirePermission>
</Group>
</Group>
}
/>
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
@@ -327,10 +315,13 @@ export function ResultPage() {
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
</SimpleGrid>
<Card withBorder padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>{t('result.section')}</Text>
<Group gap="sm" wrap="wrap">
<AdvancedTable
columns={columns}
data={page.rows}
title={t('result.section')}
tableName={t('result.title')}
toolbar={
<>
<TextInput
placeholder={t('result.search.seafarer')}
leftSection={<IconSearch size={15} />}
@@ -348,13 +339,8 @@ export function ResultPage() {
style={{ width: 280 }}
clearable
/>
</Group>
</Group>
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('result.title')}
</>
}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
@@ -364,7 +350,6 @@ export function ResultPage() {
isLoading={isFetching}
emptyText={t('result.noItems')}
/>
</Card>
<Modal
opened={detailOpened}
@@ -421,9 +406,10 @@ export function ResultPage() {
{t('result.review.derivedStatus')}
</Text>
<Group gap="xs" mt={4}>
<Badge variant="light" color={STATUS_COLOR[detailResult.status]}>
{t(`result.status.${detailResult.status}`)}
</Badge>
<StatusBadge
tone={STATUS_TONE[detailResult.status]}
label={t(`result.status.${detailResult.status}`)}
/>
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
{t(`result.review.${detailResult.reviewStatus}`)}
</Badge>
@@ -504,6 +490,13 @@ export function ResultPage() {
<ModalFooter>
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
<RequirePermission
anyOf={[
LICENSE_PERMISSIONS.RECORD_EXAM_RESULT,
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
]}
hideOnly
>
<Button
onClick={handleDetailSave}
size="sm"
@@ -512,6 +505,7 @@ export function ResultPage() {
>
{t('result.save')}
</Button>
</RequirePermission>
</ModalFooter>
</Stack>
) : (
@@ -574,6 +568,20 @@ export function ResultPage() {
</ModalFooter>
</Modal>
<Modal opened={publishOpened} onClose={closePublish} title={t('result.review.publish')} size="sm">
<Text mb="md">
{t('result.review.publishConfirmText', {
exam: publishTarget ? getExamTitle(publishTarget.examId) : '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={closePublish} size="sm">{t('result.cancel')}</Button>
<Button color="teal" loading={isPublishing} onClick={handleConfirmPublish} size="sm">
{t('result.review.publish')}
</Button>
</ModalFooter>
</Modal>
{/* Choose exam, then record */}
<Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg">
<Stack gap="md">

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
import {Badge, Container, Select, Text, TextInput} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
@@ -12,7 +12,7 @@ import {
type SeafarerDocumentRow,
type SeafarerDocumentStatus,
} from '@ema-platform/api';
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
import { AdvancedTable, PageHeader, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const PAGE_SIZE = 10;
@@ -91,6 +91,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
),
},
{
header: 'Waiting',
accessorKey: 'submittedAt',
size: 90,
cell: ({ row }) => (
<WaitingFor
since={row.original.submittedAt}
done={row.original.status === 'ISSUED' || row.original.status === 'REJECTED'}
/>
),
},
{
header: 'Status',
accessorKey: 'status',
@@ -106,14 +117,16 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
{SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue
</Title>
<Text size="sm" c="dimmed" mb="md">
Requests released by an approved seafarer registration: confirm payment, schedule the
collection date, then issue.
</Text>
<Group mb="md" gap="sm">
<PageHeader
title={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue`}
subtitle="Requests released by an approved seafarer registration: confirm payment, schedule the collection date, then issue."
/>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
toolbar={
<>
<TextInput
placeholder="Search number, name or seafarer №…"
leftSection={<IconSearch size={14} />}
@@ -122,7 +135,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
setSearch(e.currentTarget.value);
setPage(0);
}}
w={280}
w={260}
/>
<Select
placeholder="All statuses"
@@ -133,13 +146,10 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
setPage(0);
}}
clearable
w={220}
w={200}
/>
</Group>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
</>
}
itemCount={data?.total ?? 0}
pageIndex={page}
onPageChange={setPage}

View File

@@ -1,22 +1,7 @@
import { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Center,
Container,
Group,
Loader,
Modal,
Paper,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
import {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, SimpleGrid, Stack, Text, Textarea, ThemeIcon, rem} from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
@@ -29,26 +14,41 @@ import {
useRejectSeafarerDocumentMutation,
useScheduleSeafarerDocumentMutation,
} from '@ema-platform/api';
import { AmharicDatePicker, notify } from '@ema-platform/ui';
import { AmharicDatePicker, notify, PageHeader } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const;
function Row({ label, value }: { label: string; value: React.ReactNode }) {
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Table.Tr>
<Table.Td w="40%">
<Text size="xs" c="dimmed">
{label}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" component="div">
{value ?? '—'}
</Text>
</Table.Td>
</Table.Tr>
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
<Text fz="sm" fw={600} component="div">{value ?? '—'}</Text>
</div>
);
}
function SectionCard({
title,
icon,
color,
children,
}: {
title: string;
icon: React.ReactNode;
color: string;
children: React.ReactNode;
}) {
return (
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<ThemeIcon variant="light" color={color} size={26} radius="md">{icon}</ThemeIcon>
<Text fw={700} fz="sm">{title}</Text>
</Group>
<Divider mb="md" />
{children}
</Paper>
);
}
@@ -123,12 +123,10 @@ export function SeafarerDocumentReviewPage() {
>
Back to queue
</Button>
<Group justify="space-between" align="flex-start" mb="md">
<div>
<Title order={3}>
{kindLabel} {applicant?.name ?? '—'}
</Title>
<Group gap="xs" mt={4}>
<PageHeader
title={`${kindLabel}${applicant?.name ?? '—'}`}
meta={
<>
<Text size="sm" c="dimmed" ff="monospace">
{document.requestNumber}
</Text>
@@ -140,8 +138,9 @@ export function SeafarerDocumentReviewPage() {
{document.documentNumber}
</Badge>
)}
</Group>
</div>
</>
}
action={
<Group gap="xs">
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
@@ -175,7 +174,8 @@ export function SeafarerDocumentReviewPage() {
</RequirePermission>
)}
</Group>
</Group>
}
/>
{document.status === 'REJECTED' && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
@@ -183,61 +183,49 @@ export function SeafarerDocumentReviewPage() {
</Alert>
)}
<Paper withBorder p="lg" radius="md">
<Stack gap="md">
<Paper withBorder radius="md" p="lg" mb="md">
<Group gap="md" wrap="nowrap">
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>
{(applicant?.name ?? '??').split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase()}
</Avatar>
<div>
<Text fw={600} size="sm" mb={4}>
Seafarer
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
<Row label="Name" value={applicant?.name} />
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
<Row
label="Registration"
value={
applicant?.registrationId ? (
<Text fw={700} fz="lg" lh={1.2}>{applicant?.name ?? '—'}</Text>
<Group gap={6} mt={4}>
<Text fz="xs" c="dimmed" ff="monospace">{applicant?.seafarerNumber ?? '—'}</Text>
{applicant?.registrationId && (
<>
<Text fz="xs" c="dimmed">·</Text>
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
{applicant.registrationNumber}
<Text fz="xs" c="blue.6">{applicant.registrationNumber}</Text>
</Link>
) : (
applicant?.registrationNumber
)
}
/>
</Table.Tbody>
</Table>
</>
)}
</Group>
</div>
<div>
<Text fw={600} size="sm" mb={4}>
Payment
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
<Row label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
<Row label="Provider" value={payment?.provider} />
<Row label="Reference" value={document.paymentReference ?? payment?.providerRef} />
</Table.Tbody>
</Table>
</div>
<div>
<Text fw={600} size="sm" mb={4}>
Issuance
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
<Row label="Document №" value={document.documentNumber} />
<Row label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
<Row label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
</Table.Tbody>
</Table>
</div>
</Stack>
</Group>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<SectionCard title="Payment" icon={<IconCash size={14} />} color="teal">
<SimpleGrid cols={2} spacing="md">
<Stat label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
<Stat label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
<Stat label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
<Stat label="Provider" value={payment?.provider} />
<Stat label="Reference" value={document.paymentReference ?? payment?.providerRef} />
</SimpleGrid>
</SectionCard>
<SectionCard title="Issuance" icon={<IconFileCertificate size={14} />} color="violet">
<SimpleGrid cols={2} spacing="md">
<Stat label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
<Stat label="Document №" value={document.documentNumber} />
<Stat label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
<Stat label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
</SimpleGrid>
</SectionCard>
</SimpleGrid>
<Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
<Stack>
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />

View File

@@ -1,17 +1,17 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
import {Container, Select, Text, TextInput} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
SEAFARER_REGISTRATION_STATUS_COLORS,
SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS,
displaySeafarerAnswer,
useListSeafarerRegistrationsQuery,
type SeafarerRegistration,
type SeafarerRegistrationStatus,
} from '@ema-platform/api';
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
import { AdvancedTable, PageHeader, StatusBadge, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const PAGE_SIZE = 10;
@@ -78,13 +78,25 @@ export function SeafarerRegistrationQueuePage() {
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
),
},
{
header: 'Waiting',
accessorKey: 'submittedAt',
size: 90,
cell: ({ row }) => (
<WaitingFor
since={row.original.submittedAt}
done={row.original.status === 'APPROVED' || row.original.status === 'REJECTED'}
/>
),
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => (
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[row.original.status]}>
{SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
</Badge>
<StatusBadge
tone={SEAFARER_REGISTRATION_STATUS_TONES[row.original.status]}
label={SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
/>
),
},
],
@@ -93,14 +105,16 @@ export function SeafarerRegistrationQueuePage() {
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
Seafarer Registration Queue
</Title>
<Text size="sm" c="dimmed" mb="md">
Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and
BTC applications.
</Text>
<Group mb="md" gap="sm">
<PageHeader
title="Seafarer Registration Queue"
subtitle="Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and BTC applications."
/>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName="Seafarer registrations"
toolbar={
<>
<TextInput
placeholder="Search number, name or ID…"
leftSection={<IconSearch size={14} />}
@@ -109,7 +123,7 @@ export function SeafarerRegistrationQueuePage() {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={280}
w={260}
/>
<Select
placeholder="All statuses"
@@ -120,13 +134,10 @@ export function SeafarerRegistrationQueuePage() {
setPage(0);
}}
clearable
w={220}
w={200}
/>
</Group>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName="Seafarer registrations"
</>
}
itemCount={data?.total ?? 0}
pageIndex={page}
onPageChange={setPage}

View File

@@ -1,28 +1,12 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Center,
Container,
Divider,
Group,
Loader,
Modal,
Paper,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import {Alert, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react';
import {
SEAFARER_REGISTRATION_DOCUMENTS,
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_SECTIONS,
SEAFARER_REGISTRATION_STATUS_COLORS,
SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS,
displaySeafarerAnswer,
extractErrorMessage,
@@ -31,7 +15,7 @@ import {
useRejectSeafarerRegistrationMutation,
useRequestSeafarerRegistrationChangesMutation,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { notify, PageHeader, StatusBadge } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { applicantName } from './SeafarerRegistrationQueuePage';
@@ -109,23 +93,25 @@ export function SeafarerRegistrationReviewPage() {
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/seafarer-registrations')} mb="xs">
Back to queue
</Button>
<Group justify="space-between" align="flex-start" mb="md">
<div>
<Title order={3}>{applicantName(registration)}</Title>
<Group gap="xs" mt={4}>
<PageHeader
title={applicantName(registration)}
meta={
<>
<Text size="sm" c="dimmed" ff="monospace">
{registration.registrationNumber}
</Text>
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
</Badge>
<StatusBadge
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
/>
{registration.seafarerNumber && (
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
{registration.seafarerNumber}
</Badge>
)}
</Group>
</div>
</>
}
action={
<Group gap="xs">
{canDecide && (
<>
@@ -147,7 +133,8 @@ export function SeafarerRegistrationReviewPage() {
</>
)}
</Group>
</Group>
}
/>
{registration.status === 'RESUBMIT_REQUIRED' && (
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
@@ -167,7 +154,7 @@ export function SeafarerRegistrationReviewPage() {
<Text fw={600} size="sm" mb={4}>
{section.title}
</Text>
<Table withTableBorder withColumnBorders>
<Table variant="vertical">
<Table.Tbody>
{section.fields
.filter((f) => f !== 'passportExpiry' || registration.passportNumber)
@@ -192,7 +179,7 @@ export function SeafarerRegistrationReviewPage() {
<Text fw={600} size="sm" mb={4}>
Documents
</Text>
<Table withTableBorder withColumnBorders>
<Table variant="vertical">
<Table.Tbody>
{slots.map((slot) => {
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];

View File

@@ -1,25 +1,8 @@
import { PageHeader, StatusBadge } from '@ema-platform/ui';
import { type StatusTone } from '@ema-platform/shared';
import { useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Collapse,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {ActionIcon, Avatar, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
import {
IconBook2,
IconCertificate,
@@ -32,6 +15,7 @@ import {
IconShieldCheck,
IconUser,
IconUsers,
IconX,
} from '@tabler/icons-react';
// ---------------------------------------------------------------------------
@@ -59,72 +43,78 @@ const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
const STATUS_COLOR: Record<string, string> = { Active: 'teal', Inactive: 'gray', Suspended: 'red' };
const STATUS_TONE: Record<string, StatusTone> = { Active: 'success', Inactive: 'neutral', Suspended: 'danger' };
// ---------------------------------------------------------------------------
// Detail modal
// ---------------------------------------------------------------------------
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
<Text fz="sm" fw={600}>{value ?? '—'}</Text>
</div>
);
}
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
if (!sf) return null;
const initials = sf.name.split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase();
return (
<Modal
opened={opened}
onClose={onClose}
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} {sf.id}</Text></Group>}
size="xl"
radius="lg"
>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Paper withBorder radius="md" p="md">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
<Stack gap={4}>
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></Group>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
<Stack gap={4}>
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Medical Expiry</Text>
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
<Modal opened={opened} onClose={onClose} size="xl" radius="lg" padding={0} withCloseButton={false}>
<Stack gap={0}>
{/* Header */}
<Group justify="space-between" wrap="nowrap" p="lg" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<Group gap="md" wrap="nowrap">
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>{initials}</Avatar>
<div>
<Text fw={700} fz="lg" lh={1.2}>{sf.name}</Text>
<Group gap={6} mt={4}>
<Text fz="xs" c="dimmed" ff="monospace">{sf.id}</Text>
<Text fz="xs" c="dimmed">·</Text>
<Text fz="xs" c="dimmed">{sf.rank}</Text>
</Group>
</Stack>
</Paper>
</div>
</Group>
<Group gap="xs">
<StatusBadge tone={STATUS_TONE[sf.status]} label={sf.status} variant="light" />
<ActionIcon variant="subtle" color="gray" onClick={onClose}><IconX size={16} /></ActionIcon>
</Group>
</Group>
<Stack gap="lg" p="lg">
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="lg">
<Stat label="Nationality" value={sf.nationality} />
<Stat label="Date of Birth" value={sf.dob} />
<Stat label="Seaman Book №" value={sf.seamanBookNo} />
<Stat label="SB Expiry" value={sf.seamanBookExpiry} />
<Stat label="BTC №" value={sf.btcNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
<Stat label="BSID №" value={sf.bsidNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
<Stat label="Medical" value={<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="sm">{sf.medicalStatus}</Badge>} />
<Stat label="Medical Expiry" value={sf.medicalExpiry} />
</SimpleGrid>
{sf.cocCerts.length > 0 && (
<Paper withBorder radius="md" p="md">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
<Table fz="xs" verticalSpacing="xs">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Text fz="xs" fw={700} c="dimmed" mb="sm" tt="uppercase">CoC / CoP Certificates</Text>
<Stack gap="xs">
{sf.cocCerts.map((c) => (
<Table.Tr key={c.no}>
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
</Table.Tr>
<Group key={c.no} justify="space-between" wrap="nowrap" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="violet" size={30} radius="md"><IconCertificate size={15} /></ThemeIcon>
<div>
<Text fz="sm" fw={600}>{c.type}</Text>
<Text fz="xs" c="dimmed" ff="monospace">{c.no}</Text>
</div>
</Group>
<Text fz="xs" c="dimmed">Expires {c.expiry}</Text>
</Group>
))}
</Table.Tbody>
</Table>
</Stack>
</Paper>
)}
</Stack>
</Stack>
</Modal>
);
}
@@ -169,10 +159,11 @@ export function SeafarerRegistryPage() {
return (
<Stack gap="md">
<div>
<Title order={3}>Seafarer Registry</Title>
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
</div>
<PageHeader
title="Seafarer Registry"
subtitle="Search and view all registered seafarers, their documents, and certificate status"
noMargin
/>
{/* KPIs */}
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
@@ -264,7 +255,12 @@ export function SeafarerRegistryPage() {
: <Text fz="xs" c="dimmed"></Text>}
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
<StatusBadge
tone={STATUS_TONE[sf.status]}
label={sf.status}
variant="light"
size="xs"
/>
</Table.Td>
<Table.Td>
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>

View File

@@ -336,7 +336,7 @@ export function VesselRegistrationFormBuilderPage() {
<IconSettings size={24} />
</ThemeIcon>
<div>
<Title order={3}>Vessel Registration Form Builder</Title>
<Title order={2}>Vessel Registration Form Builder</Title>
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
</div>
</Group>

View File

@@ -1,13 +1,15 @@
import { Badge, Button, Text, Tooltip } from '@mantine/core';
import { type StatusTone } from '@ema-platform/shared';
import { Button, Text, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Vessel } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
REGISTERED: 'success',
SUSPENDED: 'pending',
DEREGISTERED: 'neutral',
};
export const CATEGORY_LABELS: Record<string, string> = {
@@ -63,13 +65,12 @@ export function vesselRegistrationQueueColumns(
{
header: 'Status',
cell: ({ row }) => (
<Badge
<StatusBadge
tone={VESSEL_STATUS_TONES[row.original.status]}
label={row.original.status}
size="sm"
variant="light"
color={VESSEL_STATUS_COLORS[row.original.status]}
>
{row.original.status}
</Badge>
/>
),
},
{

View File

@@ -1,29 +1,12 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Container,
Drawer,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import {Alert, Badge, Button, Card, Container, Drawer, Group, Loader, Modal, Select, Stack, Table, Text, TextInput, Textarea} from '@mantine/core';
import {
IconAlertTriangle,
IconInfoCircle,
IconSearch,
} from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -238,18 +221,19 @@ export function VesselRegistrationQueuePage() {
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>Vessel register</Title>
<Text size="sm" c="dimmed">
<PageHeader
title="Vessel register"
subtitle={
<>
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'}
pending registrations are reviewed in the{' '}
<Text component={Link} to="/licence-review" inherit c="blue">
licence queue
</Text>
.
</Text>
</div>
</>
}
action={
<TextInput
placeholder="Name, registration № or IMO"
leftSection={<IconSearch size={14} />}
@@ -257,7 +241,8 @@ export function VesselRegistrationQueuePage() {
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
}
/>
<AdvancedTable
tableName="Vessel register"

View File

@@ -1,13 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Alert, Container, Group, Text, Title } from '@mantine/core';
import {Alert, Container, Group, Text} from '@mantine/core';
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
import {
ApiErrorAlert,
EmptyState,
PageLoader,
notify,
} from '@ema-platform/ui';
import { ApiErrorAlert, EmptyState, notify, PageHeader, PageLoader } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
downloadAuthedFile,
@@ -85,16 +80,14 @@ export function VesselRegistrationReportPage() {
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md" align="flex-start">
<div>
<Title order={3}>Vessel registration report</Title>
<Text size="sm" c="dimmed">
{report
<PageHeader
title="Vessel registration report"
subtitle={
report
? `Register-wide totals with a ${showDate(report.filters.from)} ${showDate(report.filters.to)} window on the trends.`
: 'The national vessel register at a glance.'}
</Text>
</div>
</Group>
: 'The national vessel register at a glance.'
}
/>
<ReportFilters
query={query}

View File

@@ -11,6 +11,10 @@ export const am: Translations = {
tagline: "የቁጥጥር ማዕከል",
},
a11y: {
skipToContent: "ወደ ዋናው ይዘት ዝለል",
},
msg: {
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
@@ -253,8 +257,10 @@ export const am: Translations = {
oral: "ቃል",
essay: "ኢሴይ",
choice: "ምርጫ",
both: "ሁለቱም",
offline: "ከመስመር ውጪ",
online: "በመስመር",
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
sum: "ድምር",
average: "አማካይ",
percentage: "መቶኛ",
@@ -262,6 +268,11 @@ export const am: Translations = {
random: "በዘፈቀደ",
cuttingPoint: "የማለፊያ ነጥብ",
cuttingPointPlaceholder: "ለማለፍ ዝቅተኛ ነጥብ",
cuttingPointPercentagePlaceholder: "ለማለፍ ዝቅተኛ መቶኛ (0-100)",
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
status: "ሁኔታ",
statusPlaceholder: "የፈተና ሁኔታ",
pending: "በመጠባበቅ ላይ",
@@ -299,6 +310,7 @@ export const am: Translations = {
formType: {
ESSAY: "ኢሴይ",
CHOICE: "ምርጫ",
BOTH: "ሁለቱም",
},
admin: {
OFFLINE: "ከመስመር ውጪ",
@@ -326,6 +338,10 @@ export const am: Translations = {
retake: "ድጋሚ {{n}}",
firstSitting: "የመጀመሪያ ሙከራ",
remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።",
regrade: "እንደገና ደረጃ ስጥ",
regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።",
regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።",
regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።",
},
attendance: {
REGISTERED: "አልተጠራም",
@@ -370,6 +386,8 @@ export const am: Translations = {
randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል",
randomError: "ጥያቄዎችን መምረጥ አልተቻለም",
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
cannotReachCuttingPoint:
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
},
country: {
@@ -687,6 +705,8 @@ export const am: Translations = {
returned: "ውጤት ወደ ፈታኙ ተመልሷል",
publishedCount: "{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።",
publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።",
publishConfirmText:
"ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?",
lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።",
originalScore: "የፈታኙ ጠቅላላ",
derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",
@@ -778,6 +798,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: {
@@ -973,7 +1009,10 @@ export const am: Translations = {
requestAdjustment: "ማስተካከያ ጠይቅ",
reject: "አትቀበል",
scheduleExam: "የፈተና ቀጠሮ ስጥ",
recordExamOutcome: "የፈተና ውጤት መዝግብ",
confirmPayment: "ክፍያ አረጋግጥ",
scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ",
issueCertificate: "ሰርተፍኬት ስጥ",
print: "ሰነድ አትም",
copyLink: "አገናኝ ቅዳ",
downloadDocuments: "ሁሉንም ሰነዶች አውርድ",

View File

@@ -10,6 +10,11 @@ export const en = {
tagline: 'Control Center',
},
// Strings only assistive technology encounters.
a11y: {
skipToContent: 'Skip to main content',
},
msg: {
genericError: 'Something went wrong. Please try again.',
serverError: 'Server error. Please try again later.',
@@ -251,8 +256,10 @@ export const en = {
oral: 'Oral',
essay: 'Essay',
choice: 'Choice',
both: 'Both',
offline: 'Offline',
online: 'Online',
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
sum: 'Sum',
average: 'Average',
percentage: 'Percentage',
@@ -260,6 +267,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',
@@ -296,6 +308,7 @@ export const en = {
formType: {
ESSAY: 'Essay',
CHOICE: 'Choice',
BOTH: 'Both',
},
admin: {
OFFLINE: 'Offline',
@@ -323,6 +336,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 +385,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 +706,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 +801,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: {
@@ -980,7 +1018,10 @@ export const en = {
requestAdjustment: 'Request adjustment',
reject: 'Reject',
scheduleExam: 'Schedule exam',
recordExamOutcome: 'Record exam outcome',
confirmPayment: 'Confirm payment',
scheduleIssuance: 'Schedule pickup',
issueCertificate: 'Issue certificate',
print: 'Print dossier',
copyLink: 'Copy link',
downloadDocuments: 'Download all documents',

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { AppShell, Drawer } from '@mantine/core';
import { AppShell, Box, Drawer, Group, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -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';
@@ -26,6 +27,14 @@ const BADGE_POLL_MS = 60_000;
const HEADER_HEIGHT = 116;
/**
* Horizontal inset of the header chrome. `AppHeader` adds its own `px="lg"`
* inside this, so the nav strip below needs the sum to line up with the
* controls above it — it used to start 20px to their left.
*/
const CHROME_PAD_X = 32;
const NAV_STRIP_PAD_X = CHROME_PAD_X + 20;
/**
* A desk left unlocked with a license-review or medical-record screen open is
* the actual threat model here, not a slow token. 15 minutes of no mouse,
@@ -143,8 +152,14 @@ 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
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
// 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.
header={{ height: isSidebar ? 74 : { base: 74, sm: HEADER_HEIGHT } }}
navbar={
isSidebar
? {
@@ -162,13 +177,28 @@ export function BackofficeLayout() {
<AppShell.Header
style={{
background: "var(--mantine-color-body)",
borderBottom: "1px solid var(--mantine-color-gray-2)",
borderBottom: "1px solid var(--mantine-color-default-border)",
display: "flex",
flexDirection: "column",
}}
>
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}>
<div
style={{ height: 74, flexShrink: 0, padding: `0 ${CHROME_PAD_X}px` }}
>
<AppHeader
brand={
isSidebar ? undefined : (
<Group gap="xs" wrap="nowrap">
<BrandMark size={28} />
<Text fw={700} size="sm" lh={1.1} visibleFrom="xs">
{t('app.name')}
</Text>
</Group>
)
}
// Nothing to toggle on a desktop top bar; on mobile it opens the
// drawer below.
burgerHiddenFrom={isSidebar ? undefined : 'sm'}
onToggleNav={toggleNav}
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
navOpened={opened}
@@ -182,13 +212,14 @@ export function BackofficeLayout() {
</div>
{!isSidebar && (
<div
<Box
visibleFrom="sm"
style={{
display: 'flex',
alignItems: 'center',
padding: '0 32px',
padding: `0 ${NAV_STRIP_PAD_X}px`,
height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)',
borderTop: '1px solid var(--mantine-color-default-border)',
flexShrink: 0,
}}
>
@@ -199,7 +230,7 @@ export function BackofficeLayout() {
activePath={location.pathname}
onNavigate={go}
/>
</div>
</Box>
)}
</AppShell.Header>
@@ -210,7 +241,7 @@ export function BackofficeLayout() {
overflow: "hidden",
transition: "width 200ms ease",
background: "var(--mantine-color-body)",
borderRight: "1px solid var(--mantine-color-gray-2)",
borderRight: "1px solid var(--mantine-color-default-border)",
}}
>
<AppSidebar
@@ -226,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>
@@ -238,7 +269,6 @@ export function BackofficeLayout() {
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
click) instead of AppShell's full-width mobile navbar. Mirrors the
landing page's mobile menu. */}
{isSidebar && (
<Drawer
opened={opened}
onClose={closeNav}
@@ -261,7 +291,7 @@ export function BackofficeLayout() {
brandLogo={<BrandMark size={32} />}
/>
</Drawer>
)}
</AppShell>
</>
);
}

View File

@@ -12,6 +12,7 @@ import {
RequirePermission,
LICENSE_PERMISSIONS as P,
} from '@ema-platform/auth';
import { ThemeGallery } from '@ema-platform/ui';
import { AuthLayout } from '../layouts/AuthLayout';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { ProtectedRoute } from './ProtectedRoute';
@@ -68,6 +69,9 @@ const router = createBrowserRouter([
],
},
{ path: '/um/*', element: <UserManagementPage /> },
// Theme visual-regression surface. Unauthenticated by design — it renders
// only static primitives, so it needs no API and cannot flake.
{ path: '/__gallery', element: <ThemeGallery /> },
{ path: '/', element: <LandingRoute /> },
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
{

View File

@@ -4,6 +4,10 @@ import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/spotlight/styles.css';
// After Mantine's CSS (it defines the variables these tokens resolve to),
// before the app's own, which may override them. Relative because the
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
import '../../../libs/shared/src/lib/theme/semantic.css';
import './styles.css';
import './app/i18n/config';
import { App } from './app/app';

View File

@@ -1,7 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
Without it every Amharic string in the app renders in whatever the OS
happens to substitute — different on Windows, macOS and Android. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
*, *::before, *::after { box-sizing: border-box; }

View File

@@ -347,7 +347,7 @@ test.describe('seafarer registration', () => {
await expect(page.getByLabel('National ID (Fayda) Number')).toHaveValue('FYD1234567890');
await page.getByRole('button', { name: /^continue$/i }).click();
// Step 2 — Applicant Details.
// Step 2 — Details: address and physical characteristics.
await page.getByLabel('Place of Birth').fill('Addis Ababa');
await pick(page, 'Department', /deck/i);
await pick(page, 'City', /addis ababa/i);
@@ -356,15 +356,15 @@ test.describe('seafarer registration', () => {
await pick(page, 'Eye Colour', /brown/i);
await page.getByLabel('Height (cm)').fill('172');
await page.getByLabel('Weight (kg)').fill('68');
await page.getByLabel('Certificate Number').fill('MED-2026-001');
await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic');
await pickDate(page, 'Issue Date', '2026-01-15');
await page.getByRole('button', { name: /^continue$/i }).click();
// Step 3 — Emergency Contact.
// Step 3 — Contact & Medical.
await page.getByLabel('Full Name').fill('Almaz Tesfaye');
await page.getByLabel('Relationship').fill('Sister');
await page.getByLabel('Phone Number').fill('+251911222333');
await page.getByLabel('Certificate Number').fill('MED-2026-001');
await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic');
await pickDate(page, 'Issue Date', '2026-01-15');
await page.getByRole('button', { name: /^continue$/i }).click();
// Step 4 — Documents: all four required slots show as uploaded.

65
apps/e2e/visual.config.ts Normal file
View File

@@ -0,0 +1,65 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Visual-regression suite for theme work.
*
* Deliberately separate from `playwright.config.ts`. That suite drives real
* cross-app workflows and therefore needs the API, a database and migrations;
* this one only needs to know what the theme renders. Loading the same
* dependencies here would make a screenshot diff fail for reasons that have
* nothing to do with the theme — a migration, a seeded row, an expired token.
*
* So: static routes only, `vite preview` over an already-built bundle, no
* backend. Run `vite build` for both apps first.
*/
const PORTAL_PORT = Number(process.env.VISUAL_PORTAL_PORT ?? 4312);
const BACKOFFICE_PORT = Number(process.env.VISUAL_BACKOFFICE_PORT ?? 4313);
export const VISUAL = {
portalUrl: `http://localhost:${PORTAL_PORT}`,
backofficeUrl: `http://localhost:${BACKOFFICE_PORT}`,
};
export default defineConfig({
testDir: './visual',
workers: 1,
fullyParallel: false,
forbidOnly: !!process.env.CI,
// A visual diff that passes on a retry is a flake, and a flake here would
// mask exactly the regressions this suite exists to catch.
retries: 0,
timeout: 60_000,
expect: {
// Anti-aliasing differs slightly between runs; a handful of pixels is not
// a regression. Anything the theme actually changed is far larger.
toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled' },
},
reporter: [['list'], ['html', { outputFolder: '../../dist/visual-report', open: 'never' }]],
use: {
trace: 'retain-on-failure',
actionTimeout: 15_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: [
{
name: 'portal',
command: `npx vite preview --config apps/portal/vite.config.mts --port ${PORTAL_PORT} --strictPort`,
cwd: '../..',
url: VISUAL.portalUrl,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
{
name: 'backoffice',
command: `npx vite preview --config apps/backoffice/vite.config.mts --port ${BACKOFFICE_PORT} --strictPort`,
cwd: '../..',
url: VISUAL.backofficeUrl,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
],
});

View File

@@ -0,0 +1,139 @@
import { test, expect, type Page } from '@playwright/test';
import { VISUAL } from '../visual.config';
/**
* Theme baselines.
*
* These exist so a change to the shared theme can be reviewed as a diff rather
* than trusted. The gallery route renders every primitive the theme controls,
* so one screenshot per app per scheme per width covers the whole surface.
*
* Update baselines deliberately, never reflexively:
* npx playwright test -c apps/e2e/visual.config.ts --update-snapshots
* A diff you did not intend is the entire point of the suite.
*/
const WIDTHS = [
{ name: 'desktop', width: 1440, height: 1200 },
{ name: 'tablet', width: 768, height: 1200 },
] as const;
const SCHEMES = ['light', 'dark'] as const;
const APPS = [
{ name: 'backoffice', url: VISUAL.backofficeUrl },
{ name: 'portal', url: VISUAL.portalUrl },
] as const;
/**
* Set the scheme the way the app itself does — the pre-paint script in
* index.html reads this key. Setting it before navigation means the very first
* paint is already correct, so no screenshot catches a flash of the wrong one.
*/
async function gotoGallery(page: Page, baseUrl: string, scheme: string) {
await page.addInitScript((value) => {
window.localStorage.setItem('mantine-color-scheme-value', value);
}, scheme);
await page.goto(`${baseUrl}/__gallery`, { waitUntil: 'networkidle' });
// The gallery is static, but web fonts are not: screenshotting before they
// settle bakes a fallback-font baseline that every later run then fails
// against.
await page.evaluate(() => document.fonts.ready);
await expect(page.getByRole('heading', { name: 'Theme Gallery' })).toBeVisible();
}
for (const app of APPS) {
for (const scheme of SCHEMES) {
for (const size of WIDTHS) {
test(`${app.name} gallery — ${scheme}${size.name}`, async ({ page }) => {
await page.setViewportSize({ width: size.width, height: size.height });
await gotoGallery(page, app.url, scheme);
await expect(page).toHaveScreenshot(
`${app.name}-gallery-${scheme}-${size.name}.png`,
{ fullPage: true },
);
});
}
}
/**
* The focus ring, captured while actually focused.
*
* The full-page shots above can't show this: nothing is focused in them, so
* a regression that removed the ring entirely would leave them all green.
* Keyboard focus specifically, because `:focus-visible` deliberately does
* not match a mouse click.
*/
test(`${app.name} — focus ring is visible`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await gotoGallery(page, app.url, 'light');
const section = page.locator('section, div').filter({ hasText: 'Focus states' }).last();
await section.scrollIntoViewIfNeeded();
const button = page.getByRole('button', { name: 'Button', exact: true });
await button.focus();
await expect(button).toBeFocused();
// Assert the ring in computed styles as well as pixels. A screenshot alone
// would still pass if the outline came from somewhere unintended, and a
// token that failed to resolve leaves an empty string rather than an error.
const ring = await button.evaluate((el) => {
const s = getComputedStyle(el);
return {
width: s.outlineWidth,
style: s.outlineStyle,
token: getComputedStyle(document.documentElement)
.getPropertyValue('--ema-focus-ring')
.trim(),
};
});
expect(ring.style).toBe('solid');
expect(ring.width).toBe('2px');
expect(ring.token).not.toBe('');
await expect(button).toHaveScreenshot(`${app.name}-focus-ring.png`);
});
}
/**
* The skip link, on a real app shell.
*
* Not on the gallery route: the point of a skip link is bypassing the nav, and
* the gallery has none. The login page is the shell-less public route both apps
* share, so this uses the landing route instead — it carries the chrome without
* needing a session.
*
* A skip link is invisible until focused, which means a broken one and a
* working one look identical in every screenshot. Only a focus test separates
* them.
*/
test.describe('skip link', () => {
for (const app of APPS) {
test(`${app.name} — reveals on focus and targets main`, async ({ page }) => {
await page.goto(`${app.url}/`, { waitUntil: 'networkidle' });
const link = page.locator('.ema-skip-link');
if ((await link.count()) === 0) {
// The public landing route does not mount the app shell in every app;
// skipping is honest here, where asserting absence would be wrong.
test.skip(true, 'landing route does not mount the app shell');
return;
}
// Off-screen until focused...
await expect(link).not.toBeInViewport();
await page.keyboard.press('Tab');
await expect(link).toBeFocused();
await expect(link).toBeInViewport();
// ...and it must point at something that exists.
const href = await link.getAttribute('href');
expect(href).toBe('#ema-main-content');
});
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View File

@@ -1,3 +1,4 @@
import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
import { useRef, useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import {
@@ -30,7 +31,7 @@ import {
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { StatusBadge, notify } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
// ---------------------------------------------------------------------------
@@ -45,11 +46,11 @@ interface BSTRecord {
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
}
const STATUS_COLOR: Record<string, string> = {
Valid: 'teal',
Expiring: 'orange',
Expired: 'red',
'Pending Verification': 'yellow',
const STATUS_TONE: Record<string, StatusTone> = {
Valid: 'success',
Expiring: 'pending',
Expired: 'danger',
'Pending Verification': 'warning',
};
/** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */
@@ -289,14 +290,13 @@ export function BasicSafetyTrainingPage() {
</Text>
</div>
{record && (
<Badge
<StatusBadge
tone={STATUS_TONE[record.status]}
label={record.status}
size="lg"
variant="light"
color={STATUS_COLOR[record.status]}
leftSection={<IconShieldCheck size={14} />}
>
{record.status}
</Badge>
/>
)}
</Group>
@@ -321,7 +321,7 @@ export function BasicSafetyTrainingPage() {
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="sm">
<ThemeIcon size={48} radius="md" color={STATUS_COLOR[record.status]} variant="light">
<ThemeIcon size={48} radius="md" color={STATUS_TONE_COLOR[STATUS_TONE[record.status]]} variant="light">
<IconShieldCheck size={24} />
</ThemeIcon>
<div>
@@ -329,9 +329,7 @@ export function BasicSafetyTrainingPage() {
<Text fz="xs" c="dimmed">Combined certificate all 5 STCW components</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[record.status]} variant="light">
{record.status}
</Badge>
<StatusBadge tone={STATUS_TONE[record.status]} label={record.status} variant="light" />
</Group>
<Stack gap="xs" mb="md">

View File

@@ -0,0 +1,50 @@
import { Button, Card, Stack, Text, ThemeIcon, Title } from '@mantine/core';
import { IconCircleCheck, IconClockPause } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import type { AttemptStatus } from '../types/exam-attempt';
/**
* No score, no pass/fail, nothing evaluation-shaped — grading hasn't run.
* This only confirms what actually happened: the candidate submitted, or
* the deadline closed the attempt out first.
*/
export function ExamCompletion({
status,
submittedAt,
}: {
status: AttemptStatus;
submittedAt: string | null;
}) {
const navigate = useNavigate();
const expired = status === 'EXPIRED';
return (
<Stack maw={520} mx="auto" align="center" py="xl">
<Card withBorder radius="lg" p="xl" w="100%">
<Stack align="center" gap="md">
<ThemeIcon size={64} radius="xl" variant="light" color={expired ? 'orange' : 'teal'}>
{expired ? <IconClockPause size={32} /> : <IconCircleCheck size={32} />}
</ThemeIcon>
<Title order={3} ta="center">
{expired ? 'Time expired' : 'Exam submitted'}
</Title>
<Text ta="center" c="dimmed">
{expired
? 'The scheduled time ran out. Your saved answers were recorded as your final submission.'
: 'Your answers have been recorded.'}
{' '}Your result will appear on the Examinations page once marking, moderation and
approval are complete it is not available yet.
</Text>
{submittedAt && (
<Text fz="xs" c="dimmed">
{expired ? 'Closed' : 'Submitted'} at {new Date(submittedAt).toLocaleString()}
</Text>
)}
<Button variant="light" onClick={() => navigate('/exams')}>
Back to Examinations
</Button>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,94 @@
import { Alert, Badge, Button, Card, Group, Stack, Text, Title } from '@mantine/core';
import { IconAlertCircle, IconClock, IconInfoCircle, IconPlayerPlay } from '@tabler/icons-react';
import type { Bilingual } from '@ema-platform/api';
import type { EstimatedTime, RegistrationWithExam } from '../types/exam-attempt';
function formatDuration(time: EstimatedTime | null | undefined): string {
if (!time) return 'Not configured';
const parts = [
time.days ? `${time.days}d` : null,
time.hours ? `${time.hours}h` : null,
time.minutes ? `${time.minutes}m` : null,
].filter(Boolean);
return parts.length ? parts.join(' ') : '0m';
}
export function ExamInstructions({
registration,
localized,
showDate,
starting,
onStart,
}: {
registration: RegistrationWithExam;
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
starting: boolean;
onStart: () => void;
}) {
const exam = registration.exam;
const canStart = exam?.status === 'ACTIVE';
return (
<Stack maw={720} mx="auto" gap="md">
<Title order={2}>{localized(exam?.title) || 'Examination'}</Title>
<Text c="dimmed">{localized(exam?.certification?.name)}</Text>
<Card withBorder radius="md" p="lg">
<Stack gap="sm">
<Group justify="space-between">
<Text fz="sm" c="dimmed">Admission number</Text>
<Text fz="sm" fw={600} ff="monospace">{registration.admissionNumber}</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Session date</Text>
<Text fz="sm">{showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''}</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Duration</Text>
<Badge variant="light" leftSection={<IconClock size={12} />}>
{formatDuration(exam?.givenTime)}
</Badge>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Attempt</Text>
<Badge variant="light" color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}>
{registration.kind === 'RETAKE'
? `Retake · sitting ${registration.attemptNumber}`
: 'First sitting'}
</Badge>
</Group>
</Stack>
</Card>
{exam?.direction && localized(exam.direction) && (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light" title="Instructions">
{localized(exam.direction)}
</Alert>
)}
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light">
Once started, the timer cannot be paused. Answers are saved automatically as you go, but
the exam ends the moment the deadline passes, whether or not you have submitted.
</Alert>
{!canStart && (
<Alert color="gray" variant="light">
This session is not currently open for candidates to begin.
</Alert>
)}
<Group justify="flex-end">
<Button
size="md"
leftSection={<IconPlayerPlay size={16} />}
loading={starting}
disabled={!canStart}
onClick={onStart}
>
Start exam
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,118 @@
import { Badge, Button, Group, Paper, Radio, Stack, Text, Textarea } from '@mantine/core';
import { IconAlertCircle, IconCheck, IconRefresh } from '@tabler/icons-react';
import type { Bilingual } from '@ema-platform/api';
import type { CandidateQuestion, SaveState } from '../types/exam-attempt';
function SaveIndicator({ state, onRetry }: { state: SaveState; onRetry: () => void }) {
if (state === 'saving') {
return <Text fz="xs" c="dimmed">Saving</Text>;
}
if (state === 'saved') {
return (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal">Saved</Text>
</Group>
);
}
if (state === 'error') {
return (
<Group gap={6}>
<IconAlertCircle size={13} color="var(--mantine-color-red-6)" />
<Text fz="xs" c="red">Not saved</Text>
<Button
size="compact-xs"
variant="light"
color="red"
leftSection={<IconRefresh size={12} />}
onClick={onRetry}
>
Retry
</Button>
</Group>
);
}
return null;
}
/**
* Renders one question — never the answer key, because the API response
* this reads from (`CandidateQuestion`/`CandidateOption`) has no such field
* to render even by mistake.
*/
export function ExamQuestionDisplay({
question,
index,
total,
localized,
selectedOptionId,
answerText,
saveState,
disabled,
onSelectOption,
onChangeText,
onRetry,
}: {
question: CandidateQuestion;
index: number;
total: number;
localized: (value: Bilingual | undefined) => string;
selectedOptionId: string | null | undefined;
answerText: string | null | undefined;
saveState: SaveState;
disabled: boolean;
onSelectOption: (optionId: string) => void;
onChangeText: (text: string) => void;
onRetry: () => void;
}) {
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" mb="sm">
<Badge variant="light" color="gray">
Question {index + 1} of {total} · {question.points} pts
</Badge>
<SaveIndicator state={saveState} onRetry={onRetry} />
</Group>
<Text fz="md" fw={500} mb="lg">
{localized(question.title)}
</Text>
{question.form === 'CHOICE' ? (
<Radio.Group
value={selectedOptionId ?? ''}
onChange={onSelectOption}
>
<Stack gap="sm">
{question.options
.slice()
.sort((a, b) => a.order - b.order)
.map((option) => (
<Radio.Card
key={option.id}
value={option.id}
disabled={disabled}
p="sm"
radius="md"
>
<Group wrap="nowrap" gap="sm">
<Radio.Indicator disabled={disabled} />
<Text fz="sm">{localized(option.text)}</Text>
</Group>
</Radio.Card>
))}
</Stack>
</Radio.Group>
) : (
<Textarea
placeholder="Write your answer"
minRows={8}
autosize
disabled={disabled}
value={answerText ?? ''}
onChange={(event) => onChangeText(event.currentTarget.value)}
/>
)}
</Paper>
);
}

View File

@@ -0,0 +1,57 @@
import { Paper, SimpleGrid, Text, UnstyledButton } from '@mantine/core';
import type { CandidateQuestion } from '../types/exam-attempt';
export function ExamQuestionNav({
questions,
currentIndex,
answeredIds,
disabled,
onJump,
}: {
questions: CandidateQuestion[];
currentIndex: number;
answeredIds: Set<string>;
disabled: boolean;
onJump: (index: number) => void;
}) {
return (
<Paper withBorder radius="md" p="sm">
<Text fz="xs" fw={600} c="dimmed" mb="xs" tt="uppercase">
Questions
</Text>
<SimpleGrid cols={5} spacing={6}>
{questions.map((q, index) => {
const answered = answeredIds.has(q.id);
const current = index === currentIndex;
return (
<UnstyledButton
key={q.id}
disabled={disabled}
onClick={() => onJump(index)}
style={{
height: 34,
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
fontSize: 13,
border: current ? '2px solid var(--mantine-color-blue-6)' : '1px solid var(--mantine-color-gray-4)',
background: answered
? 'var(--mantine-color-teal-1)'
: 'var(--mantine-color-body)',
color: answered ? 'var(--mantine-color-teal-8)' : undefined,
opacity: disabled ? 0.5 : 1,
}}
>
{index + 1}
</UnstyledButton>
);
})}
</SimpleGrid>
<Text fz="xs" c="dimmed" mt="sm">
{answeredIds.size} of {questions.length} answered
</Text>
</Paper>
);
}

View File

@@ -0,0 +1,34 @@
import { Badge, Group } from '@mantine/core';
import { IconClock } from '@tabler/icons-react';
function format(totalSeconds: number): string {
const s = Math.max(0, totalSeconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
}
/**
* Display only. `remainingSeconds` is a local countdown seeded once from the
* server's own clock (`AttemptSession.remainingSeconds`/`serverTime`) and
* ticked down client-side — the deadline it represents is enforced by the
* backend on every save/submit regardless of whether this number is right.
*/
export function ExamTimer({ remainingSeconds }: { remainingSeconds: number }) {
const low = remainingSeconds <= 300; // 5 minutes
return (
<Group gap={6}>
<Badge
size="lg"
variant="light"
color={low ? 'red' : 'blue'}
leftSection={<IconClock size={14} />}
ff="monospace"
>
{format(remainingSeconds)}
</Badge>
</Group>
);
}

View File

@@ -0,0 +1,286 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useApiMutation, useApiQuery, extractErrorMessage } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import type {
AttemptSession,
CandidateAnswer,
ExamAttempt,
RegistrationWithExam,
SaveState,
} from '../types/exam-attempt';
const ESSAY_DEBOUNCE_MS = 1500;
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
type LocalAnswer = { selectedOptionId?: string | null; answerText?: string | null };
/**
* All state and API orchestration for taking one exam. Kept out of the page
* component so the component tree stays about rendering, not about save
* timers and expiry races.
*
* Nothing here is a security boundary — every write still goes through the
* backend's own ownership + `applyExpiry()` checks on every call. This hook
* only decides what to show; a client that skipped straight to calling the
* API directly would hit exactly the same server-side rules.
*/
export function useExamAttempt(examId: string | undefined) {
const [session, setSession] = useState<AttemptSession | null>(null);
const [viewState, setViewState] = useState<ViewState>('loading');
const [errorMessage, setErrorMessage] = useState('');
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, LocalAnswer>>({});
const [saveStates, setSaveStates] = useState<Record<string, SaveState>>({});
const [remainingSeconds, setRemainingSeconds] = useState(0);
const answersRef = useRef(answers);
answersRef.current = answers;
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const seeded = useRef(false);
const {
data: registrations,
isLoading: loadingRegistrations,
} = useApiQuery<RegistrationWithExam[]>({ url: '/exams/registrations/mine' });
const registration = registrations?.find((r) => r.exam?.id === examId);
const {
data: mineData,
isLoading: loadingMine,
isError: mineIsError,
error: mineError,
refetch: refetchMine,
} = useApiQuery<AttemptSession>(
{ url: `/exam-attempts/mine/${examId}` },
{ skip: !examId },
);
const [startTrigger, { isLoading: starting }] = useApiMutation<AttemptSession>();
const [answerTrigger] = useApiMutation<CandidateAnswer>();
const [submitTrigger, { isLoading: submitting }] = useApiMutation<ExamAttempt>();
const seedFrom = useCallback((data: AttemptSession) => {
setSession(data);
const map: Record<string, LocalAnswer> = {};
for (const a of data.answers) {
map[a.questionId] = { selectedOptionId: a.selectedOptionId, answerText: a.answerText };
}
setAnswers(map);
setRemainingSeconds(data.remainingSeconds);
setViewState(data.attempt.status === 'IN_PROGRESS' ? 'taking' : 'completed');
}, []);
// Seed once from the initial load — after that, local state (ticking
// timer, in-flight edits) is the source of truth, not this query.
useEffect(() => {
if (seeded.current) return;
if (loadingRegistrations || loadingMine) return;
seeded.current = true;
if (!registration) {
setViewState('error');
setErrorMessage('You are not registered for this examination.');
return;
}
if (mineData) {
seedFrom(mineData);
return;
}
if (mineIsError) {
const key = extractErrorMessage(mineError, '');
if (key === 'attempt_not_found') {
setViewState('not-started');
return;
}
setViewState('error');
setErrorMessage(extractErrorMessage(mineError, 'Could not load the exam.'));
}
}, [loadingRegistrations, loadingMine, registration, mineData, mineIsError, mineError, seedFrom]);
/** Authoritative resync — used after any write is refused as expired/submitted. */
const syncFromServer = useCallback(async () => {
const result = await refetchMine();
if (result.data) {
seedFrom(result.data as AttemptSession);
} else {
setViewState('error');
setErrorMessage(extractErrorMessage(result.error, 'The exam session ended.'));
}
}, [refetchMine, seedFrom]);
const persistAnswer = useCallback(
async (questionId: string, payload: LocalAnswer) => {
if (!session) return;
setSaveStates((s) => ({ ...s, [questionId]: 'saving' }));
try {
const saved = await answerTrigger({
url: `/exam-attempts/${session.attempt.id}/answers`,
method: 'POST',
body: { questionId, ...payload },
}).unwrap();
setAnswers((a) => ({
...a,
[questionId]: { selectedOptionId: saved.selectedOptionId, answerText: saved.answerText },
}));
setSaveStates((s) => ({ ...s, [questionId]: 'saved' }));
} catch (error) {
setSaveStates((s) => ({ ...s, [questionId]: 'error' }));
const key = extractErrorMessage(error, '');
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
notify.error(
key === 'attempt_expired'
? 'Time is up — this answer was not saved.'
: 'This attempt has already been submitted.',
);
syncFromServer();
}
}
},
[session, answerTrigger, syncFromServer],
);
const flush = useCallback(
(questionId: string) => {
const timer = debounceTimers.current[questionId];
if (!timer) return;
clearTimeout(timer);
delete debounceTimers.current[questionId];
const current = answersRef.current[questionId];
if (current) persistAnswer(questionId, current);
},
[persistAnswer],
);
const selectOption = useCallback(
(questionId: string, optionId: string) => {
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], selectedOptionId: optionId } }));
persistAnswer(questionId, { selectedOptionId: optionId });
},
[persistAnswer],
);
const changeText = useCallback(
(questionId: string, text: string) => {
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], answerText: text } }));
setSaveStates((s) => ({ ...s, [questionId]: 'idle' }));
clearTimeout(debounceTimers.current[questionId]);
debounceTimers.current[questionId] = setTimeout(() => {
delete debounceTimers.current[questionId];
persistAnswer(questionId, { answerText: text });
}, ESSAY_DEBOUNCE_MS);
},
[persistAnswer],
);
const goTo = useCallback(
(index: number) => {
const current = session?.questions[currentIndex];
if (current) flush(current.id);
setCurrentIndex(index);
},
[session, currentIndex, flush],
);
const retry = useCallback(
(questionId: string) => {
const current = answersRef.current[questionId];
if (current) persistAnswer(questionId, current);
},
[persistAnswer],
);
const start = useCallback(async () => {
if (!examId) return;
try {
const result = await startTrigger({
url: '/exam-attempts/start',
method: 'POST',
body: { examId },
}).unwrap();
seedFrom(result);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
}
}, [examId, startTrigger, seedFrom]);
const submit = useCallback(async () => {
if (!session) return;
const current = session.questions[currentIndex];
if (current) flush(current.id);
try {
const attempt = await submitTrigger({
url: `/exam-attempts/${session.attempt.id}/submit`,
method: 'POST',
}).unwrap();
setSession((s) => (s ? { ...s, attempt } : s));
setViewState('completed');
} catch (error) {
const key = extractErrorMessage(error, '');
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
syncFromServer();
} else {
notify.error(extractErrorMessage(error, 'Could not submit the exam.'));
}
}
}, [session, currentIndex, flush, submitTrigger, syncFromServer]);
/** Local countdown only — every write is still checked server-side regardless. */
useEffect(() => {
if (viewState !== 'taking') return;
const id = setInterval(() => {
setRemainingSeconds((s) => {
if (s <= 1) {
clearInterval(id);
return 0;
}
return s - 1;
});
}, 1000);
return () => clearInterval(id);
}, [viewState]);
// Time reaching zero locally: stop taking input, tell the server, then
// trust whatever it reports back over anything computed in the browser.
const timedOutRef = useRef(false);
useEffect(() => {
if (viewState !== 'taking' || remainingSeconds > 0 || timedOutRef.current) return;
timedOutRef.current = true;
notify.error("Time's up.");
// submit() itself resyncs from the server if this loses the race against
// applyExpiry() — either way the final state comes from the backend, not
// from this timer having reached zero.
submit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remainingSeconds, viewState]);
const answeredIds = useMemo(
() =>
new Set(
Object.entries(answers)
.filter(([, v]) => v.selectedOptionId || v.answerText?.trim())
.map(([id]) => id),
),
[answers],
);
return {
viewState,
errorMessage,
registration,
session,
currentIndex,
answers,
saveStates,
answeredIds,
remainingSeconds,
starting,
submitting,
start,
selectOption,
changeText,
goTo,
retry,
submit,
};
}

View File

@@ -0,0 +1,187 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Alert, Button, Center, Group, Loader, Modal, Stack, Text } from '@mantine/core';
import { IconAlertCircle, IconSend } from '@tabler/icons-react';
import { useLocalized } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
import { useExamAttempt } from '../../hooks/useExamAttempt';
import { ExamInstructions } from '../../components/ExamInstructions';
import { ExamTimer } from '../../components/ExamTimer';
import { ExamQuestionNav } from '../../components/ExamQuestionNav';
import { ExamQuestionDisplay } from '../../components/ExamQuestionDisplay';
import { ExamCompletion } from '../../components/ExamCompletion';
/**
* The candidate exam-taking screen (Phase 4). Route: `/exams/:examId/take`.
*
* All state/API orchestration lives in `useExamAttempt` — this component is
* the view: pick which of loading/not-started/taking/completed/error to
* render. Every write it triggers (start, save, submit) is re-checked by the
* backend regardless of what this screen currently shows; nothing here is
* the actual security boundary.
*/
export function ExamAttemptPage() {
const { examId } = useParams<{ examId: string }>();
const localized = useLocalized();
const showDate = useDateDisplayer();
const [confirmOpened, setConfirmOpened] = useState(false);
const {
viewState,
errorMessage,
registration,
session,
currentIndex,
answers,
saveStates,
answeredIds,
remainingSeconds,
starting,
submitting,
start,
selectOption,
changeText,
goTo,
retry,
submit,
} = useExamAttempt(examId);
if (viewState === 'loading') {
return (
<Center py="xl">
<Loader />
</Center>
);
}
if (viewState === 'error') {
return (
<Alert icon={<IconAlertCircle size={16} />} color="red" maw={600} mx="auto" mt="xl">
{errorMessage}
</Alert>
);
}
if (viewState === 'not-started') {
if (!registration) return null; // guarded by 'error' above, appeases TS
return (
<ExamInstructions
registration={registration}
localized={localized}
showDate={showDate}
starting={starting}
onStart={start}
/>
);
}
if (viewState === 'completed' && session) {
return (
<ExamCompletion status={session.attempt.status} submittedAt={session.attempt.submittedAt} />
);
}
if (!session) return null; // 'taking' always has a session by construction
const question = session.questions[currentIndex];
const answer = answers[question.id];
return (
<Stack maw={1000} mx="auto" gap="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>{localized(registration?.exam?.title) || 'Examination in progress'}</Text>
<ExamTimer remainingSeconds={remainingSeconds} />
</Group>
<Group align="flex-start" gap="md" wrap="wrap-reverse">
<div style={{ flex: 1, minWidth: 280 }}>
<ExamQuestionDisplay
question={question}
index={currentIndex}
total={session.questions.length}
localized={localized}
selectedOptionId={answer?.selectedOptionId}
answerText={answer?.answerText}
saveState={saveStates[question.id] ?? 'idle'}
disabled={remainingSeconds <= 0}
onSelectOption={(optionId) => selectOption(question.id, optionId)}
onChangeText={(text) => changeText(question.id, text)}
onRetry={() => retry(question.id)}
/>
<Group justify="space-between" mt="md">
<Button
variant="default"
disabled={currentIndex === 0}
onClick={() => goTo(currentIndex - 1)}
>
Previous
</Button>
{currentIndex < session.questions.length - 1 ? (
<Button onClick={() => goTo(currentIndex + 1)}>Next</Button>
) : (
<Button
color="teal"
leftSection={<IconSend size={15} />}
onClick={() => setConfirmOpened(true)}
>
Submit exam
</Button>
)}
</Group>
</div>
<div style={{ width: 220, flexShrink: 0 }}>
<ExamQuestionNav
questions={session.questions}
currentIndex={currentIndex}
answeredIds={answeredIds}
disabled={remainingSeconds <= 0}
onJump={goTo}
/>
<Button
fullWidth
mt="sm"
variant="light"
color="teal"
leftSection={<IconSend size={15} />}
onClick={() => setConfirmOpened(true)}
>
Submit exam
</Button>
</div>
</Group>
<Modal
opened={confirmOpened}
onClose={() => setConfirmOpened(false)}
title="Submit this exam?"
radius="lg"
>
<Stack>
<Text size="sm">
{answeredIds.size} of {session.questions.length} questions answered. Once submitted,
answers cannot be changed.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setConfirmOpened(false)}>
Keep working
</Button>
<Button
color="teal"
loading={submitting}
onClick={async () => {
await submit();
setConfirmOpened(false);
}}
>
Submit
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}
export default ExamAttemptPage;

View File

@@ -0,0 +1,78 @@
import type { Bilingual } from '@ema-platform/api';
export type AttemptStatus = 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
export type QuestionForm = 'ESSAY' | 'CHOICE';
export interface CandidateOption {
id: string;
text: Bilingual;
order: number;
}
/** Never carries a correct-answer flag — the API doesn't send one. */
export interface CandidateQuestion {
id: string;
title: Bilingual;
form: QuestionForm;
points: number;
options: CandidateOption[];
}
export interface ExamAttempt {
id: string;
examId: string;
registrationId: string;
status: AttemptStatus;
startedAt: string;
expiresAt: string;
submittedAt: string | null;
}
export interface CandidateAnswer {
id: string;
attemptId: string;
questionId: string;
selectedOptionId: string | null;
answerText: string | null;
}
/** Response shape shared by POST /exam-attempts/start and GET .../mine/:examId. */
export interface AttemptSession {
attempt: ExamAttempt;
questions: CandidateQuestion[];
answers: CandidateAnswer[];
serverTime: string;
remainingSeconds: number;
}
export type SaveState = 'idle' | 'saving' | 'saved' | 'error';
export interface EstimatedTime {
days: number;
hours: number;
minutes: number;
}
/**
* The subset of `GET /exams/registrations/mine`'s response this feature
* reads — the endpoint returns the full raw exam/registration, this is
* just this feature's own narrow view of it (matches the sibling `exams`
* feature's pattern of each screen typing only what it uses).
*/
export interface RegistrationWithExam {
id: string;
admissionNumber: string;
kind: 'NEW' | 'RETAKE';
attemptNumber: number;
attendanceStatus: string;
exam?: {
id: string;
title: Bilingual;
direction?: Bilingual;
date: string;
venue: string | null;
status: string;
givenTime: EstimatedTime | null;
certification?: { name?: Bilingual };
};
}

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconFileText, IconGavel } from '@tabler/icons-react';
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual } from '@ema-platform/api';
@@ -20,6 +20,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
DISQUALIFIED: 'red',
};
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
export function registrationColumns(
t: TFunction,
deps: {
@@ -28,6 +30,7 @@ export function registrationColumns(
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
onDownloadSlip: (registration: MyRegistration) => void;
onStartExam: (registration: MyRegistration) => void;
},
): AdvancedColumn<MyRegistration>[] {
return [
@@ -91,6 +94,44 @@ export function registrationColumns(
</Button>
) : null,
},
{
header: t('exams.columns.exam'),
cell: ({ row }) => {
const exam = row.original.exam;
const attemptStatus = row.original.attempt?.status;
// Already finished — no restart, no more room for "Take exam" to
// invite a click that the backend would just refuse.
if (attemptStatus === 'SUBMITTED') {
return (
<Badge size="sm" variant="light" color="teal">
{t('exams.columns.completed')}
</Badge>
);
}
if (attemptStatus === 'EXPIRED') {
return (
<Badge size="sm" variant="light" color="red">
{t('exams.columns.timeExpired')}
</Badge>
);
}
const eligible =
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
return (
<Button
size="compact-xs"
color="teal"
leftSection={<IconPlayerPlay size={13} />}
onClick={() => deps.onStartExam(row.original)}
>
{attemptStatus === 'IN_PROGRESS'
? t('exams.columns.resumeExam')
: t('exams.columns.takeExam')}
</Button>
);
},
},
];
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Badge,
Button,
@@ -53,6 +54,8 @@ export interface MyRegistration {
attemptNumber: number;
attendanceStatus: AttendanceStatus;
exam?: OpenExam;
/** The candidate's online sitting, when one has been started. */
attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
}
export interface MyResult {
@@ -80,6 +83,7 @@ export interface MyAppeal {
*/
export function ExamsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const showDate = useDateDisplayer();
const localized = useLocalized();
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
@@ -245,6 +249,7 @@ export function ExamsPage() {
localized,
showDate,
onDownloadSlip: downloadSlip,
onStartExam: (registration) => navigate(`/exams/${registration.exam?.id}/take`),
})}
data={pagedRegistrations.rows}
itemCount={pagedRegistrations.itemCount}

View File

@@ -72,7 +72,7 @@ export function IdentityDetailsStep(
);
}
/** Step 2 — Identity, Address, Physical Characteristics, Medical Certificate. */
/** Step 2 — Identity, Address and Physical Characteristics. */
export function ApplicantDetailsStep(p: StepProps) {
return (
<Stack gap="lg">
@@ -138,6 +138,29 @@ export function ApplicantDetailsStep(p: StepProps) {
/>
</Grid>
</Stack>
);
}
/**
* Step 4 — Emergency Contact and Medical Certificate.
*
* The medical certificate used to sit at the bottom of Applicant Details,
* which made that step 17 fields across four sections while this one held
* three. Both are short, unrelated-to-identity, and copied off a document in
* hand rather than recalled — so they pair here and the wizard's longest step
* drops by a third.
*/
export function EmergencyContactStep(p: StepProps) {
return (
<Stack gap="lg">
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
<Grid>
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
</Grid>
<Divider />
<SectionTitle
title="Medical Certificate"
@@ -157,17 +180,3 @@ export function ApplicantDetailsStep(p: StepProps) {
</Stack>
);
}
/** Step 3 — Emergency Contact. */
export function EmergencyContactStep(p: StepProps) {
return (
<Stack gap="lg">
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
<Grid>
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
</Grid>
</Stack>
);
}

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
Button,
Center,
Container,
@@ -20,7 +19,7 @@ import { notifications } from '@mantine/notifications';
import {
PHYSICAL_BOUNDS,
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_STATUS_COLORS,
SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS,
extractErrorMessage,
extractValidationIssues,
@@ -33,7 +32,7 @@ import {
type SeafarerRegistration,
type ValidationIssue,
} from '@ema-platform/api';
import { splitPersonName } from '@ema-platform/ui';
import { splitPersonName, StatusBadge } from '@ema-platform/ui';
import { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
import { CheckboxField, type AnswerKey } from '../components/fields';
@@ -41,16 +40,19 @@ import { ApplicantDetailsStep, EmergencyContactStep, IdentityDetailsStep } from
import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments';
import { RegistrationSummary } from '../components/RegistrationSummary';
const STEPS = ['Identity Details', 'Applicant Details', 'Emergency Contact', 'Documents', 'Review'];
const STEPS = [
{ label: 'Identity', description: 'Who you are' },
{ label: 'Details', description: 'Address & physical' },
{ label: 'Contact & Medical', description: 'Emergency & fitness' },
{ label: 'Documents', description: 'Upload evidence' },
{ label: 'Review', description: 'Check & submit' },
];
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
const REQUIRED_BY_STEP: AnswerKey[][] = [
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
[
'placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg',
'medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate',
],
[],
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
[],
['declarationAccepted'],
];
@@ -123,7 +125,7 @@ export function SeafarerRegistrationPage() {
const registration = data?.registration ?? null;
const [start] = useStartSeafarerRegistrationMutation();
const [save] = useSaveSeafarerRegistrationMutation();
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
const [startError, setStartError] = useState<string | null>(null);
const started = useRef(false);
@@ -215,12 +217,16 @@ export function SeafarerRegistrationPage() {
}
}
setErrors(found);
const count = Object.keys(found).length;
if (count) {
const missingKeys = Object.keys(found) as AnswerKey[];
if (missingKeys.length) {
// Name the fields rather than counting them. "Complete 3 required fields"
// sends the applicant hunting up a step they have already scrolled past;
// the labels are what let them go straight to it.
const names = missingKeys.map((k) => SEAFARER_REGISTRATION_FIELD_LABELS[k]);
notifications.show({
color: 'red',
title: 'Incomplete',
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
title: missingKeys.length > 1 ? 'Some details are missing' : 'One detail is missing',
message: `${names.join(', ')}.`,
});
return false;
}
@@ -307,9 +313,10 @@ export function SeafarerRegistrationPage() {
<Text size="sm" c="dimmed">
{registration.registrationNumber}
</Text>
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
</Badge>
<StatusBadge
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
/>
</Group>
</div>
{showSummary && !readOnly && (
@@ -362,8 +369,8 @@ export function SeafarerRegistrationPage() {
{!showSummary && (
<Paper withBorder p="lg" radius="md">
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
{STEPS.map((label) => (
<Stepper.Step key={label} label={label} />
{STEPS.map((step) => (
<Stepper.Step key={step.label} label={step.label} description={step.description} />
))}
</Stepper>
@@ -408,9 +415,11 @@ export function SeafarerRegistrationPage() {
Back
</Button>
{active < STEPS.length - 1 ? (
<Button onClick={() => goToStep(active + 1)}>Continue</Button>
<Button loading={saving} onClick={() => goToStep(active + 1)}>
Continue
</Button>
) : (
<Button color="teal" loading={submitting} disabled={readOnly} onClick={handleSubmit}>
<Button color="teal" loading={saving || submitting} disabled={readOnly} onClick={handleSubmit}>
{isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
</Button>
)}

View File

@@ -1,12 +1,14 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import { seaServiceDays, type MedicalCertificate, type SeaServiceRecord } from '@ema-platform/api';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
const RECORD_STATUS_TONES: Record<string, StatusTone> = {
SUBMITTED: 'info',
VERIFIED: 'success',
REJECTED: 'danger',
};
export function fitnessOptions(t: TFunction) {
@@ -78,11 +80,12 @@ export function seaServiceColumns(
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
<StatusBadge
tone={RECORD_STATUS_TONES[row.original.status]}
label={t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge>
/>
</Tooltip>
),
},
@@ -140,11 +143,12 @@ export function medicalColumns(
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
<StatusBadge
tone={RECORD_STATUS_TONES[row.original.status]}
label={t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge>
/>
</Tooltip>
),
},

View File

@@ -164,6 +164,13 @@ function EvidenceField({
// ---------------------------------------------------------------- sea service
/** Today as a `yyyy-mm-dd` key — same shape the pickers emit, so plain
* string comparison is a valid date comparison. Taken in the authority's
* timezone, matching the server's check, so a seafarer logging in from a
* zone ahead of Addis isn't offered a day the server then rejects. */
const todayKey = () =>
new Date().toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
const EMPTY_SEA_SERVICE = {
vesselName: '',
imoNumber: '',
@@ -276,12 +283,27 @@ function SeaServiceTab() {
}
};
// Service already served — neither end of an engagement can be in the future.
const today = todayKey();
const dateError =
form.engagementDate > today || form.dischargeDate > today
? t('seaRecords.seaService.dateFuture', {
defaultValue: 'Engagement and discharge dates cannot be in the future.',
})
: form.engagementDate &&
form.dischargeDate &&
form.engagementDate >= form.dischargeDate
? t('seaRecords.seaService.dateOrder', {
defaultValue: 'Discharge date must be after the engagement date.',
})
: null;
const valid =
form.vesselName.trim().length > 1 &&
form.rank.trim().length > 1 &&
form.engagementDate &&
form.dischargeDate &&
form.engagementDate < form.dischargeDate;
!dateError;
// Shown under the date pickers as they are filled: the seafarer sees what
// the engagement is worth before saving it.
@@ -408,6 +430,7 @@ function SeaServiceTab() {
onChange={(val) =>
setForm({ ...form, engagementDate: val })
}
maxDate={form.dischargeDate || today}
dateFormat="date"
/>
<AmharicDatePicker
@@ -417,21 +440,20 @@ function SeaServiceTab() {
onChange={(val) =>
setForm({ ...form, dischargeDate: val })
}
minDate={form.engagementDate || undefined}
maxDate={today}
dateFormat="date"
/>
</Group>
{form.engagementDate && form.dischargeDate && (
{(dateError || (form.engagementDate && form.dischargeDate)) && (
<Alert
variant="light"
color={formDays === null ? 'red' : 'teal'}
color={dateError ? 'red' : 'teal'}
icon={<IconInfoCircle size={16} />}
py={6}
>
{formDays === null
? t('seaRecords.seaService.dateOrder', {
defaultValue: 'Discharge date must be after the engagement date.',
})
: t('seaRecords.seaService.daysServed', {
{dateError ??
t('seaRecords.seaService.daysServed', {
days: formDays,
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
})}
@@ -581,10 +603,12 @@ function MedicalTab() {
}
};
const today = todayKey();
const valid =
form.issuerName.trim().length > 1 &&
form.issueDate &&
form.expiryDate &&
form.issueDate <= today &&
form.issueDate < form.expiryDate;
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
@@ -664,6 +688,7 @@ function MedicalTab() {
required
value={form.issueDate}
onChange={(val) => setForm({ ...form, issueDate: val })}
maxDate={today}
dateFormat="date"
/>
<AmharicDatePicker
@@ -671,6 +696,7 @@ function MedicalTab() {
required
value={form.expiryDate}
onChange={(val) => setForm({ ...form, expiryDate: val })}
minDate={form.issueDate || undefined}
dateFormat="date"
/>
</Group>

View File

@@ -1,3 +1,4 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useState } from 'react';
import {
ActionIcon,
@@ -45,7 +46,7 @@ import {
IconX,
} from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { StatusBadge, notify } from '@ema-platform/ui';
import type { Seafarer } from './SeafarerRegistryPage';
// ---------------------------------------------------------------------------
@@ -170,14 +171,14 @@ async function updateSeafarerStatus(_id: string, _status: string): Promise<void>
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal', Pending: 'yellow', Suspended: 'red',
Approved: 'teal', Expired: 'red', Valid: 'teal',
Fit: 'teal', Unfit: 'red', Conditional: 'orange',
const STATUS_TONE: Record<string, StatusTone> = {
Active: 'success', Pending: 'warning', Suspended: 'danger',
Approved: 'success', Expired: 'danger', Valid: 'success',
Fit: 'success', Unfit: 'danger', Conditional: 'pending',
};
function Chip({ value }: { value: string }) {
return <Badge color={STATUS_COLOR[value] ?? 'gray'} variant="light" radius="sm" size="sm">{value}</Badge>;
return <StatusBadge tone={STATUS_TONE[value] ?? 'neutral'} label={value} variant="light" radius="sm" size="sm" />;
}
function InfoField({ label, value }: { label: string; value: string }) {
@@ -639,7 +640,12 @@ export function SeafarerProfilePage() {
</div>
</Group>
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
<Badge color={STATUS_COLOR[profile.status] ?? 'gray'} variant="filled" radius="sm">{profile.status}</Badge>
<StatusBadge
tone={STATUS_TONE[profile.status] ?? 'neutral'}
label={profile.status}
variant="filled"
radius="sm"
/>
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
Edit Profile
</Button>

View File

@@ -1,7 +1,7 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
@@ -35,7 +35,7 @@ import {
IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { StatusBadge, notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
@@ -163,26 +163,17 @@ function StatCard({
// ---------------------------------------------------------------------------
// Status badges
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal',
Pending: 'yellow',
Suspended: 'red',
Expired: 'orange',
Fit: 'teal',
Unfit: 'red',
const STATUS_TONE: Record<string, StatusTone> = {
Active: 'success',
Pending: 'warning',
Suspended: 'danger',
Expired: 'pending',
Fit: 'success',
Unfit: 'danger',
};
function StatusBadge({ value }: { value: string }) {
return (
<Badge
color={STATUS_COLOR[value] ?? 'gray'}
variant="light"
radius="sm"
size="sm"
>
{value}
</Badge>
);
function RegistryStatus({ value }: { value: string }) {
return <StatusBadge tone={STATUS_TONE[value] ?? 'neutral'} label={value} />;
}
// ---------------------------------------------------------------------------
@@ -239,9 +230,9 @@ export function SeafarerRegistryPage() {
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.status} /></Table.Td>
<Table.Td><RegistryStatus value={s.medicalStatus} /></Table.Td>
<Table.Td><RegistryStatus value={s.bookStatus} /></Table.Td>
<Table.Td><RegistryStatus value={s.status} /></Table.Td>
<Table.Td>
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
<Menu.Target>

View File

@@ -1,9 +1,9 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
@@ -31,7 +31,7 @@ import {
IconTransferIn,
IconUser,
} from '@tabler/icons-react';
import { notify, PhoneInput } from '@ema-platform/ui';
import { StatusBadge, notify, PhoneInput } from '@ema-platform/ui';
import { isValidPhoneNumber } from 'libphonenumber-js';
// Minimal vessel type for the approved vessel list
@@ -117,11 +117,11 @@ const TRANSFER_REASONS = [
'Other',
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
const STATUS_TONE: Record<string, StatusTone> = {
Pending: 'neutral',
'Under Review': 'warning',
Approved: 'success',
Rejected: 'danger',
};
// ---------------------------------------------------------------------------
@@ -140,7 +140,11 @@ function TransferCard({ req }: { req: OwnershipTransferRequest }) {
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
<StatusBadge
tone={STATUS_TONE[req.status] ?? 'neutral'}
label={req.status}
variant="light"
/>
</Group>
<Divider my="xs" />
<SimpleGrid cols={2} spacing="xs">

View File

@@ -1,9 +1,9 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Button,
Card,
Divider,
@@ -27,7 +27,7 @@ import {
IconClockHour4,
IconTransferIn,
} from '@tabler/icons-react';
import { AdvancedTable } from '@ema-platform/ui';
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
import { inFlightColumns } from '../inFlightColumns';
import {
TERMINAL_STATUSES,
@@ -68,12 +68,12 @@ interface VesselRegistration {
expiryDate: string | null;
}
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
const STATUS_TONE: Record<string, StatusTone> = {
Pending: 'neutral',
'Under Review': 'warning',
Approved: 'success',
Rejected: 'danger',
'Correction Required': 'pending',
};
// Inland vessel certificates (1)
@@ -281,9 +281,12 @@ export function VesselRegistrationPage() {
<Text fz="xs" c="dimmed">{registration.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[registration.status] ?? 'gray'} size="lg" variant="light">
{registration.status}
</Badge>
<StatusBadge
tone={STATUS_TONE[registration.status] ?? 'neutral'}
label={registration.status}
size="lg"
variant="light"
/>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">

View File

@@ -1,3 +1,5 @@
import { StatusBadge } from '@ema-platform/ui';
import { type StatusTone } from '@ema-platform/shared';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@@ -30,10 +32,10 @@ import {
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
REGISTERED: 'success',
SUSPENDED: 'pending',
DEREGISTERED: 'neutral',
};
/**
@@ -194,12 +196,11 @@ export function VesselTransferPage() {
{categoryLabels[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
<Badge
<StatusBadge
tone={VESSEL_STATUS_TONES[vessel.status]}
label={vessel.status}
size="sm"
color={VESSEL_STATUS_COLORS[vessel.status]}
>
{vessel.status}
</Badge>
/>
</Table.Td>
<Table.Td>
<Group justify="flex-end">

View File

@@ -10,6 +10,10 @@ export const am: Translations = {
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
},
a11y: {
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
},
msg: {
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
@@ -969,6 +973,11 @@ export const am: Translations = {
appeal: 'ይግባኝ',
retake: 'ድጋሚ · {{n}}',
firstSitting: 'የመጀመሪያ ሙከራ',
exam: 'ፈተና',
completed: 'ተጠናቋል',
timeExpired: 'ጊዜው አልቋል',
resumeExam: 'ፈተና ይቀጥሉ',
takeExam: 'ፈተና ይውሰዱ',
attendanceStatus: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',

View File

@@ -9,6 +9,11 @@ export const en = {
tagline: 'Maritime licensing & certification services',
},
// Strings only assistive technology encounters.
a11y: {
skipToContent: 'Skip to main content',
},
msg: {
genericError: 'Something went wrong. Please try again.',
serverError: 'Server error. Please try again later.',
@@ -970,6 +975,11 @@ export const en = {
appeal: 'Appeal',
retake: 'Retake · {{n}}',
firstSitting: 'First sitting',
exam: 'Exam',
completed: 'Completed',
timeExpired: 'Time expired',
resumeExam: 'Resume exam',
takeExam: 'Take exam',
attendanceStatus: {
REGISTERED: 'Not called',
PRESENT: 'Present',

View File

@@ -26,6 +26,8 @@ import {
AppHeader,
AppSidebar,
filterByPermissions,
SkipLink,
MAIN_CONTENT_ID,
} from "@ema-platform/ui";
import type { NavItem } from "@ema-platform/ui";
import {
@@ -281,6 +283,10 @@ export function PortalLayout() {
: "?";
return (
<>
{/* First focusable element on the page, so a keyboard user can bypass
the nav instead of tabbing through it on every navigation. */}
<SkipLink />
<AppShell
header={{ height: 74 }}
navbar={{
@@ -335,7 +341,7 @@ export function PortalLayout() {
/>
</AppShell.Navbar>
<AppShell.Main>
<AppShell.Main id={MAIN_CONTENT_ID}>
<div key={location.pathname} className="ema-page-enter">
<Outlet />
</div>
@@ -364,5 +370,6 @@ export function PortalLayout() {
/>
</Drawer>
</AppShell>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { createBrowserRouter, Navigate } from "react-router-dom";
import { ThemeGallery } from "@ema-platform/ui";
import { PortalLayout } from "./layouts/PortalLayout";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { LandingRoute } from "./components/LandingRoute";
@@ -29,6 +30,7 @@ import { SupportPage } from "./features/support/pages/SupportPage";
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
import { ExamsPage } from "./features/exams/pages/ExamsPage";
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
// Phase 1 pages
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
@@ -57,6 +59,10 @@ export const router = createBrowserRouter([
// Public landing page — institutional overview + role-based entry points.
{ path: "/", element: <LandingRoute /> },
// Theme visual-regression surface. Unauthenticated by design — it renders
// only static primitives, so it needs no API and cannot flake.
{ path: "/__gallery", element: <ThemeGallery /> },
// Public auth pages
{ path: "/login", element: <LoginPage /> },
{ path: "/signup", element: <SignupPage /> },
@@ -208,6 +214,14 @@ export const router = createBrowserRouter([
</RequirePermission>
),
},
{
path: "/exams/:examId/take",
element: (
<RequirePermission anyOf={[P.APPLY_EXAM, P.VIEW_OWN_EXAM]}>
<ExamAttemptPage />
</RequirePermission>
),
},
// The public-facing registry was a hardcoded mock and does not belong in
// the applicant portal; officers browse seafarers in the backoffice.
{

View File

@@ -1,12 +1,57 @@
/* Portal global styles — loaded after Mantine's CSS, no Tailwind preflight so
it never fights Mantine's base styles. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
Without it every Amharic string in the app renders in whatever the OS
happens to substitute — different on Windows, macOS and Android. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
:root {
--ema-surface-light: #f5f8fc;
--ema-surface-dark: #0e1521;
}
/* ---------------------------------------------------------------------------
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
come from Mantine's dark palette so they track the active color scheme
instead of a fixed gray.
These lived in `apps/portal/src/styles.css`, which nothing ever imported —
so the portal has been running with unthemed scrollbars while the backoffice
had these. Moved here, where they load.
--------------------------------------------------------------------------- */
* {
scrollbar-width: thin;
scrollbar-color: var(--mantine-color-gray-5) transparent;
}
[data-mantine-color-scheme='dark'] * {
scrollbar-color: var(--mantine-color-dark-3) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-gray-5);
border-radius: 8px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-6);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-dark-3);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-dark-2);
}
body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;

View File

@@ -1,116 +1,13 @@
import {
createTheme,
rem,
type MantineColorsTuple,
} from '@mantine/core';
// ---- Coastal Modern palette ----------------------------------------------
// Portal-only theme. Lives here (not in @ema-platform/shared) so the backoffice
// is unaffected.
const emaPrimary: MantineColorsTuple = [
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
];
// Teal accent — the "coastal" half of the palette.
const emaTeal: MantineColorsTuple = [
'#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
'#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
];
// Cool neutral grays (slightly blue-tinted) for surfaces & text.
const emaGray: MantineColorsTuple = [
'#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
'#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
];
export const portalTheme = createTheme({
primaryColor: 'emaPrimary',
primaryShade: { light: 6, dark: 5 },
colors: {
emaPrimary,
emaTeal,
gray: emaGray,
},
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
headings: {
fontFamily: 'Inter, sans-serif',
fontWeight: '700',
sizes: {
h1: { fontSize: rem(32), lineHeight: '1.25' },
h2: { fontSize: rem(25), lineHeight: '1.3' },
h3: { fontSize: rem(21), lineHeight: '1.35' },
h4: { fontSize: rem(17), lineHeight: '1.4' },
h5: { fontSize: rem(15), lineHeight: '1.45' },
},
},
defaultRadius: 'md',
radius: {
xs: rem(6),
sm: rem(8),
md: rem(12),
lg: rem(16),
xl: rem(22),
},
shadows: {
xs: '0 1px 2px rgba(15,23,42,0.06)',
sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
md: '0 8px 24px rgba(15,23,42,0.08)',
lg: '0 16px 40px rgba(15,23,42,0.12)',
xl: '0 24px 64px rgba(15,23,42,0.16)',
},
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
cursorType: 'pointer',
components: {
Paper: {
defaultProps: { radius: 'lg' },
},
Card: {
defaultProps: { radius: 'lg' },
},
Button: {
defaultProps: { radius: 'md' },
styles: { root: { fontWeight: 600 } },
},
Badge: {
defaultProps: { radius: 'sm' },
},
ThemeIcon: {
defaultProps: { radius: 'md' },
},
NavLink: {
styles: { root: { borderRadius: rem(10), fontWeight: 500 } },
},
TextInput: { defaultProps: { radius: 'md' } },
Textarea: { defaultProps: { radius: 'md' } },
Select: { defaultProps: { radius: 'md' } },
PasswordInput: { defaultProps: { radius: 'md' } },
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
// max-height it's handed unless scrollAreaComponent is set, so a modal
// taller than the viewport just gets clipped with no way to scroll it.
// Making the body the scrollport here fixes every Modal/Drawer at once.
Modal: {
styles: {
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
Drawer: {
styles: {
content: { display: 'flex', flexDirection: 'column' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
});
/**
* The portal theme now lives in `@ema-platform/shared`, alongside the
* backoffice theme and the base they share.
*
* It moved because the two themes had diverged into unrelated definitions —
* this one carried a full type scale, radius scale and component defaults that
* the backoffice simply lacked. Sharing the structure fixes the backoffice
* without changing the portal.
*
* This re-export is kept so the portal's MantineThemeProvider import stays
* valid. Prefer importing from `@ema-platform/shared` directly in new code.
*/
export { portalTheme } from '@ema-platform/shared';

View File

@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
// After Mantine's CSS (it defines the variables these tokens resolve to),
// before the app's own, which may override them. Relative because the
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
import '../../../libs/shared/src/lib/theme/semantic.css';
import './app/theme/portal.css';
import './app/i18n/config';

View File

@@ -1,42 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ---------------------------------------------------------------------------
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
come from Mantine's dark palette so they track the active color scheme
instead of a fixed gray.
--------------------------------------------------------------------------- */
* {
scrollbar-width: thin;
scrollbar-color: var(--mantine-color-gray-5) transparent;
}
[data-mantine-color-scheme='dark'] * {
scrollbar-color: var(--mantine-color-dark-3) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-gray-5);
border-radius: 8px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-6);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-dark-3);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-dark-2);
}

Some files were not shown because too many files have changed in this diff Show More