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/ apps/backoffice/public/tinymce/
local-packages/iamui-extracted/ 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 { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react'; import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; 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'; import { groupedTypeOptions } from '../config/designer';
interface Props { interface Props {
licenseTypes: LicenseType[]; licenseTypes: LicenseType[];
typeId: string | null; typeId: string | null;
onTypeChange: (id: string | null) => void; 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; validityMonths: number;
onValidityChange: (months: number) => void; onValidityChange: (months: number) => void;
currentValidityMonths?: number | null; currentValidityMonths?: number | null;
@@ -22,6 +26,9 @@ export function DesignerToolbar({
licenseTypes, licenseTypes,
typeId, typeId,
onTypeChange, onTypeChange,
ranks,
rankId,
onRankChange,
validityMonths, validityMonths,
onValidityChange, onValidityChange,
currentValidityMonths, currentValidityMonths,
@@ -49,6 +56,23 @@ export function DesignerToolbar({
w={340} 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 {/* Validity lives beside the design because it is the other half of
what a certificate promises. */} what a certificate promises. */}
<NumberInput <NumberInput

View File

@@ -26,6 +26,7 @@ import {
useGetBuiltInTemplateQuery, useGetBuiltInTemplateQuery,
useGetLicenseTemplatesQuery, useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery, useGetLicenseTypesQuery,
useGetRanksQuery,
useGetTemplateVariablesQuery, useGetTemplateVariablesQuery,
usePublishLicenseTemplateMutation, usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation, useUpdateLicenseValidityMutation,
@@ -66,14 +67,19 @@ export function CertificateDesignerPage() {
const { data: licenseTypes } = useGetLicenseTypesQuery(); const { data: licenseTypes } = useGetLicenseTypesQuery();
const [typeId, setTypeId] = useState<string | null>(null); const [typeId, setTypeId] = useState<string | null>(null);
const [rankId, setRankId] = useState<string | null>(null);
const { const {
data: templates = [], data: allTemplates = [],
isLoading, isLoading,
isError, isError,
error, error,
refetch, refetch,
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId }); } = 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: variables = [] } = useGetTemplateVariablesQuery();
const { data: builtIn } = useGetBuiltInTemplateQuery(); const { data: builtIn } = useGetBuiltInTemplateQuery();
@@ -95,6 +101,27 @@ export function CertificateDesignerPage() {
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId); 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. // Default to the first licence type so the page is never an empty shell.
useEffect(() => { useEffect(() => {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id); if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
@@ -104,6 +131,12 @@ export function CertificateDesignerPage() {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12); if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]); }, [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() { function startNewVersion() {
setNewName( setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`, `${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
@@ -130,6 +163,12 @@ export function CertificateDesignerPage() {
setTypeId(value); setTypeId(value);
draft.setSelectedId(null); draft.setSelectedId(null);
}} }}
ranks={ranks}
rankId={rankId}
onRankChange={(value) => {
setRankId(value);
draft.setSelectedId(null);
}}
validityMonths={validityMonths} validityMonths={validityMonths}
onValidityChange={setValidityMonths} onValidityChange={setValidityMonths}
currentValidityMonths={selectedType?.validityMonths} currentValidityMonths={selectedType?.validityMonths}
@@ -369,6 +408,7 @@ export function CertificateDesignerPage() {
run(async () => { run(async () => {
const created = await createTemplate({ const created = await createTemplate({
licenseTypeId: typeId as string, licenseTypeId: typeId as string,
rankId,
name: newName.trim(), name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource, hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap(); }).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 { useTranslation } from 'react-i18next';
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api'; import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
import { useLocalized } 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. */ /** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
export type ConditionValue = FieldCondition & { previousDocExpired?: string }; 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'; type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
function operatorOf(condition: ConditionValue | undefined): Operator | null { function operatorOf(condition: ConditionValue | undefined): Operator | null {
@@ -29,36 +33,46 @@ function coerce(raw: string, targetType: string | undefined): string | number |
return raw; 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 * The field/operator/value trio for one condition — a plain condition, or one
* `conditionExpression` on a document requirement). * arm of an `anyOf`. No enable switch of its own; the caller owns whether
* * this row exists at all.
* 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.
*/ */
export function ConditionBuilder({ function ConditionArmFields({
value, value,
onChange, onChange,
targets, targets,
palette, palette,
allowClear = true,
}: { }: {
value: ConditionValue | null; value: ConditionArm;
onChange: (value: ConditionValue | null) => void; onChange: (value: ConditionArm) => void;
targets: ConditionTarget[]; targets: ConditionTarget[];
palette: FormSchemaPalette | undefined; palette: FormSchemaPalette | undefined;
/** Hide the "no condition" toggle — used where a condition is mandatory (CONDITIONAL document mode). */
allowClear?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const localized = useLocalized(); const localized = useLocalized();
const active = value !== null; const operator = operatorOf(value) ?? 'equals';
const operator = operatorOf(value ?? undefined) ?? 'equals'; const target = targets.find((c) => c.path === value.field);
const target = targets.find((c) => c.path === value?.field);
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet']; const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
function setField(field: string) { function setField(field: string) {
@@ -66,8 +80,8 @@ export function ConditionBuilder({
} }
function setOperator(next: Operator) { function setOperator(next: Operator) {
if (!value?.field) return; if (!value.field) return;
const base: ConditionValue = { field: value.field }; const base: ConditionArm = { field: value.field };
if (next === 'isSet') base.isSet = true; if (next === 'isSet') base.isSet = true;
else if (next === 'in') base.in = []; else if (next === 'in') base.in = [];
else if (next === 'notEquals') base.notEquals = ''; else if (next === 'notEquals') base.notEquals = '';
@@ -76,14 +90,14 @@ export function ConditionBuilder({
} }
function setValueRaw(raw: string) { function setValueRaw(raw: string) {
if (!value?.field) return; if (!value.field) return;
const coerced = coerce(raw, target?.field.type); const coerced = coerce(raw, target?.field.type);
if (operator === 'equals') onChange({ field: value.field, equals: coerced }); if (operator === 'equals') onChange({ field: value.field, equals: coerced });
else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced }); else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced });
} }
function setInValues(raws: string[]) { function setInValues(raws: string[]) {
if (!value?.field) return; if (!value.field) return;
onChange({ onChange({
field: value.field, field: value.field,
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[], in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
@@ -92,16 +106,6 @@ export function ConditionBuilder({
return ( return (
<Stack gap="xs"> <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 <Autocomplete
label={t('certReq.condition.field', 'Field path')} label={t('certReq.condition.field', 'Field path')}
placeholder="certificate.rank" placeholder="certificate.rank"
@@ -110,7 +114,7 @@ export function ConditionBuilder({
'Dot path into the form, e.g. sectionKey.fieldKey', 'Dot path into the form, e.g. sectionKey.fieldKey',
)} )}
data={targets.map((c) => c.path)} data={targets.map((c) => c.path)}
value={value?.field ?? ''} value={value.field ?? ''}
onChange={setField} onChange={setField}
/> />
@@ -130,7 +134,7 @@ export function ConditionBuilder({
value: o.value, value: o.value,
label: localized(o.label) || 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)} onChange={(v) => v !== null && setValueRaw(v)}
/> />
)} )}
@@ -140,14 +144,14 @@ export function ConditionBuilder({
<Checkbox <Checkbox
mt="xl" mt="xl"
label={t('certReq.condition.value', 'Value')} 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))} onChange={(e) => setValueRaw(String(e.currentTarget.checked))}
/> />
) : ( ) : (
<TextInput <TextInput
label={t('certReq.condition.value', 'Value')} label={t('certReq.condition.value', 'Value')}
type={target?.field.type === 'NUMBER' || target?.field.type === 'MONEY' ? 'number' : 'text'} 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)} onChange={(e) => setValueRaw(e.currentTarget.value)}
/> />
) )
@@ -164,7 +168,7 @@ export function ConditionBuilder({
value={null} value={null}
onChange={(v) => { onChange={(v) => {
if (!v) return; if (!v) return;
const current = (value?.in ?? []) as string[]; const current = (value.in ?? []) as string[];
if (!current.includes(v)) setInValues([...current, v]); if (!current.includes(v)) setInValues([...current, v]);
}} }}
/> />
@@ -173,7 +177,7 @@ export function ConditionBuilder({
{operator === 'in' && target?.field.type !== 'SELECT' && ( {operator === 'in' && target?.field.type !== 'SELECT' && (
<TextInput <TextInput
label={t('certReq.condition.values', 'Any of (comma-separated)')} label={t('certReq.condition.values', 'Any of (comma-separated)')}
value={(value?.in ?? []).join(', ')} value={(value.in ?? []).join(', ')}
onChange={(e) => onChange={(e) =>
setInValues( setInValues(
e.currentTarget.value e.currentTarget.value
@@ -186,9 +190,9 @@ export function ConditionBuilder({
)} )}
</Group> </Group>
{operator === 'in' && (value?.in?.length ?? 0) > 0 && ( {operator === 'in' && (value.in?.length ?? 0) > 0 && (
<Group gap={4}> <Group gap={4}>
{(value?.in ?? []).map((v, i) => ( {(value.in ?? []).map((v, i) => (
<Text <Text
key={`${v}-${i}`} key={`${v}-${i}`}
fz="xs" fz="xs"
@@ -196,7 +200,7 @@ export function ConditionBuilder({
py={2} py={2}
bg="var(--mantine-color-gray-1)" bg="var(--mantine-color-gray-1)"
style={{ borderRadius: 4, cursor: 'pointer' }} 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')} title={t('certReq.condition.removeValue', 'Click to remove')}
> >
{String(v)} × {String(v)} ×
@@ -205,7 +209,7 @@ export function ConditionBuilder({
</Group> </Group>
)} )}
{!target && value?.field && ( {!target && value.field && (
<Text fz="xs" c="dimmed"> <Text fz="xs" c="dimmed">
{t( {t(
'certReq.condition.unknownField', 'certReq.condition.unknownField',
@@ -214,6 +218,138 @@ export function ConditionBuilder({
</Text> </Text>
)} )}
</Stack> </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> </Stack>
); );

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,15 @@ export function certificationColumns(
header: t('certification.columns.description'), header: t('certification.columns.description'),
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>, 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'), header: t('certification.columns.status'),
cell: ({ row }) => ( cell: ({ row }) => (

View File

@@ -1,27 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core';
Stack,
Title,
Group,
Button,
Modal,
Text,
TextInput,
Textarea,
Card,
Alert,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IconPlus, IconInfoCircle } from '@tabler/icons-react'; import {IconPlus} from '@tabler/icons-react';
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui'; import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { import {
useGetCertificationsQuery, useGetCertificationsQuery,
useCreateCertificationMutation, useCreateCertificationMutation,
useUpdateCertificationMutation, useUpdateCertificationMutation,
useDeleteCertificationMutation, useDeleteCertificationMutation,
} from '../../api/certification-api'; } from '../../api/certification-api';
import type { Certification } from '../../types/certification'; import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
import { certificationColumns } from './columns'; import { certificationColumns } from './columns';
import { certificationActionsColumn } from './actions'; import { certificationActionsColumn } from './actions';
@@ -33,7 +22,7 @@ function CertificationForm({
}: { }: {
editing: Certification | null; editing: Certification | null;
isSubmitting: boolean; 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; onCancel: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -41,6 +30,7 @@ function CertificationForm({
const [nameAm, setNameAm] = useState(editing?.name?.am ?? ''); const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
const [descEn, setDescEn] = useState(editing?.description?.en ?? ''); const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
const [descAm, setDescAm] = useState(editing?.description?.am ?? ''); const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -48,7 +38,7 @@ function CertificationForm({
notify.error('Name fields are required'); notify.error('Name fields are required');
return; return;
} }
onSubmit({ nameEn, nameAm, descEn, descAm }, !!editing); onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
}; };
return ( 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 /> <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.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} /> <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> <ModalFooter>
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button> <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> <Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
@@ -91,15 +92,17 @@ export function CertificationPage() {
setShowForm(false); 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 name = { en: values.nameEn, am: values.nameAm };
const description = { en: values.descEn, am: values.descAm }; const description = { en: values.descEn, am: values.descAm };
try { try {
if (isEdit && editing) { 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')); notify.success(t('certification.updated'));
} else { } else {
await createCert({ name, description }).unwrap(); await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
notify.success(t('certification.created')); notify.success(t('certification.created'));
} }
resetForm(); 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 = [ const columns = [
...certificationColumns(t, locale), ...certificationColumns(t, locale),
@@ -134,17 +138,18 @@ export function CertificationPage() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between" align="flex-end"> <PageHeader
<div> title={t('certification.title')}
<Title order={2}>{t('certification.title')}</Title> subtitle={t('certification.subtitle')}
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text> noMargin
</div> action={
{!showForm && ( !showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm"> <Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('certification.add')} {t('certification.add')}
</Button> </Button>
)} )
</Group> }
/>
{showForm && ( {showForm && (
<CertificationForm <CertificationForm

View File

@@ -3,11 +3,30 @@ export interface LocalePair {
am: string; 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 { export interface Certification {
id: string; id: string;
name: LocalePair; name: LocalePair;
description: LocalePair; description: LocalePair;
isActive: boolean; isActive: boolean;
rankKey: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -20,6 +39,7 @@ export interface ListResponse<T> {
export interface CreateCertificationPayload { export interface CreateCertificationPayload {
name: LocalePair; name: LocalePair;
description: LocalePair; description: LocalePair;
rankKey?: string;
} }
export interface UpdateCertificationPayload { export interface UpdateCertificationPayload {
@@ -27,4 +47,6 @@ export interface UpdateCertificationPayload {
name?: LocalePair; name?: LocalePair;
description?: LocalePair; description?: LocalePair;
isActive?: boolean; 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, IconCertificate,
IconHash, IconHash,
IconInfoCircle, IconInfoCircle,
IconAnchor,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -36,6 +37,7 @@ import {
import { LocationPage } from "../../../location/pages/LocationPage"; import { LocationPage } from "../../../location/pages/LocationPage";
import { CertificationPage } from "../../../certification/pages/CertificationPage"; import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab"; import { NumberFormatTab } from "../../components/NumberFormatTab";
import { RankDepartmentTab } from "./RankDepartmentTab";
import { import {
useGetOrganizationsQuery, useGetOrganizationsQuery,
useGetProfessionsQuery, useGetProfessionsQuery,
@@ -46,6 +48,7 @@ import {
import type { Profession } from "../../types/configuration"; import type { Profession } from "../../types/configuration";
import { professionColumns } from "./columns"; import { professionColumns } from "./columns";
import { professionActionsColumn } from "./actions"; import { professionActionsColumn } from "./actions";
import { PageHeader } from '@ema-platform/ui';
interface ProfFormValues { interface ProfFormValues {
nameEn: string; nameEn: string;
@@ -378,7 +381,7 @@ export function ConfigurationPage() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Title order={2}>{t("configuration.title")}</Title> <PageHeader title={t("configuration.title")} noMargin />
<Tabs defaultValue="professions"> <Tabs defaultValue="professions">
<Tabs.List> <Tabs.List>
@@ -400,6 +403,9 @@ export function ConfigurationPage() {
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}> <Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
{t("numberFormat.title", "Number Formats")} {t("numberFormat.title", "Number Formats")}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
{t("configuration.ranksTab", "Ranks & Departments")}
</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="professions" pt="md"> <Tabs.Panel value="professions" pt="md">
@@ -417,6 +423,10 @@ export function ConfigurationPage() {
<Tabs.Panel value="numberFormats" pt="md"> <Tabs.Panel value="numberFormats" pt="md">
<NumberFormatTab /> <NumberFormatTab />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="ranks" pt="md">
<RankDepartmentTab />
</Tabs.Panel>
</Tabs> </Tabs>
</Stack> </Stack>
); );

View File

@@ -1,25 +1,42 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import { import {
Card, IconAlertTriangle,
Center, IconCreditCard,
Container, IconFileText,
Group, IconInbox,
Loader, IconUserCheck,
SimpleGrid, } from '@tabler/icons-react';
Text, import {
Title, useGetAssignedToMeQuery,
} from '@mantine/core'; useGetQueueQuery,
import { IconChevronRight } from '@tabler/icons-react'; useListSeafarerDocumentsQuery,
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api'; useListSeafarerRegistrationsQuery,
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui'; type LicenseApplication,
} from '@ema-platform/api';
import {
AdvancedTable,
PageHeader,
PageLoader,
StatTile,
WaitingFor,
useServerTable,
} from '@ema-platform/ui';
import { dashboardQueueColumns } from './columns'; import { dashboardQueueColumns } from './columns';
/** /**
* Backoffice home. * Backoffice home.
* *
* Shows the licence pipeline, which is the part of the platform that has real * Every figure here is counted from a queue the officer can open, and each
* data behind it. The previous version charted invented registration volumes * tile navigates to the list it counted — a dashboard that cannot be drilled
* and a fictional breakdown of staff roles. * 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() { export function DashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -27,6 +44,13 @@ export function DashboardPage() {
const mine = useGetAssignedToMeQuery(); const mine = useGetAssignedToMeQuery();
const table = useServerTable(); 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) { if (queue.isLoading || mine.isLoading) {
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />; return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
} }
@@ -34,60 +58,88 @@ export function DashboardPage() {
const unclaimed = queue.data?.items ?? []; const unclaimed = queue.data?.items ?? [];
const inProgress = mine.data?.items ?? []; const inProgress = mine.data?.items ?? [];
const all = [...unclaimed, ...inProgress]; const all = [...unclaimed, ...inProgress];
const paged = table.paginate(unclaimed.slice(0, 8));
const stats = [ const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' }, const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
{ label: 'Assigned to me', value: inProgress.length, color: 'indigo' },
{ /** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
label: 'Needs applicant action', const byAge = [...unclaimed].sort((a, b) =>
value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length, (a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
color: 'orange', );
}, const paged = table.paginate(byAge.slice(0, 8));
{
label: 'Awaiting payment', /** `undefined` while loading or forbidden — rendered as "—", never as 0. */
value: all.filter((a) => a.status === 'PAYMENT_PENDING').length, const countOf = (q: { data?: { total: number }; isError: boolean }) =>
color: 'yellow', q.isError ? undefined : q.data?.total;
},
]; const show = (n: number | undefined) => (n === undefined ? '—' : n);
return ( return (
<Container size="xl" py="md"> <Stack gap="lg">
<Title order={3} mb="xs"> <PageHeader
Dashboard title="Dashboard"
</Title> subtitle="Work waiting across the Authority's review queues."
<Text size="sm" c="dimmed" mb="lg"> noMargin
Licence applications currently in the system. />
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
{stats.map((stat) => ( <StatTile
<Card withBorder key={stat.label} padding="md" radius="md"> label="Awaiting claim"
<Text size="xs" c="dimmed" tt="uppercase" fw={600}> value={unclaimed.length}
{stat.label} hint="Licence applications nobody has picked up"
</Text> icon={IconInbox}
<Text fz={32} fw={700} c={stat.color} lh={1.2}> tone="info"
{stat.value} onClick={() => navigate('/licence-review')}
</Text> />
</Card> <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> </SimpleGrid>
<Card withBorder padding={0} radius="md"> {/* Same 4-column track as the row above, so a two-tile row lines up with
<Group justify="space-between" p="md" pb="xs"> it instead of stretching each tile to half the page. */}
<Text fw={600} size="sm"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
Awaiting claim <StatTile
</Text> label="Seafarer registrations"
<Text value={show(countOf(registrations))}
size="xs" hint="Submitted, awaiting review"
c="blue" icon={IconUserCheck}
style={{ cursor: 'pointer' }} tone="info"
onClick={() => navigate('/licence-review')} onClick={() => navigate('/seafarer-registrations')}
> />
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} /> <StatTile
</Text> label="Seaman books"
</Group> value={show(countOf(seamanBooks))}
<AdvancedTable 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" tableName="Awaiting claim"
columns={dashboardQueueColumns} columns={dashboardQueueColumns}
data={paged.rows} data={paged.rows}
@@ -95,12 +147,54 @@ export function DashboardPage() {
pageIndex={paged.pageIndex} pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex} onPageChange={table.setPageIndex}
pageSize={table.pageSize} pageSize={table.pageSize}
onRowClick={() => navigate('/licence-review')} onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
refresh={queue.refetch} refresh={queue.refetch}
isLoading={queue.isFetching}
emptyText="Nothing waiting to be claimed." emptyText="Nothing waiting to be claimed."
toolbar={
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
Open queue
</Anchor>
}
/> />
</Card> </Grid.Col>
</Container>
<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, ExamIncident,
CreateIncidentPayload, CreateIncidentPayload,
ResolveIncidentPayload, ResolveIncidentPayload,
RegradeOutcome,
} from '../types/exam'; } from '../types/exam';
const examApi = baseApi.injectEndpoints({ const examApi = baseApi.injectEndpoints({
@@ -20,7 +21,9 @@ const examApi = baseApi.injectEndpoints({
providesTags: ['Api'], providesTags: ['Api'],
}), }),
getExam: builder.query<Exam, string>({ 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'], providesTags: ['Api'],
}), }),
createExam: builder.mutation<Exam, CreateExamPayload>({ createExam: builder.mutation<Exam, CreateExamPayload>({
@@ -90,6 +93,14 @@ const examApi = baseApi.injectEndpoints({
}), }),
invalidatesTags: ['Api'], 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, overrideExisting: false,
}); });
@@ -107,4 +118,5 @@ export const {
useGetExamIncidentsQuery, useGetExamIncidentsQuery,
useRecordIncidentMutation, useRecordIncidentMutation,
useResolveIncidentMutation, useResolveIncidentMutation,
useRegradeAttemptMutation,
} = examApi; } = examApi;

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Text } from '@mantine/core'; import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
import { IconUserCheck } from '@tabler/icons-react'; import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) =>
export function examCandidateColumns( export function examCandidateColumns(
t: TFunction, t: TFunction,
handlers: { onRecord: (registration: ExamRegistration) => void }, handlers: {
onRecord: (registration: ExamRegistration) => void;
onRegrade: (registration: ExamRegistration) => void;
regrading?: string | null;
},
): AdvancedColumn<ExamRegistration>[] { ): AdvancedColumn<ExamRegistration>[] {
return [ return [
{ {
@@ -78,21 +82,51 @@ export function examCandidateColumns(
header: '', header: '',
label: t('exam.candidates.record'), label: t('exam.candidates.record'),
align: 'right', 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 <RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]} anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
hideOnly hideOnly
> >
<Button <Menu.Item
size="compact-xs" leftSection={<IconUserCheck size={14} />}
variant="light"
leftSection={<IconUserCheck size={12} />}
onClick={() => handlers.onRecord(row.original)} onClick={() => handlers.onRecord(row.original)}
> >
{t('exam.candidates.record')} {t('exam.candidates.record')}
</Button> </Menu.Item>
</RequirePermission> </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 { import {
useGetExamRegistrationsQuery, useGetExamRegistrationsQuery,
useRecordAttendanceMutation, useRecordAttendanceMutation,
useRegradeAttemptMutation,
} from '../../api/exam-api'; } from '../../api/exam-api';
import type { AttendanceStatus, ExamRegistration } from '../../types/exam'; import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
import { candidateName, examCandidateColumns } from './columns'; import { candidateName, examCandidateColumns } from './columns';
@@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId); const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation(); const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
const [regradeAttempt] = useRegradeAttemptMutation();
const [regrading, setRegrading] = useState<string | null>(null);
const [target, setTarget] = useState<ExamRegistration | null>(null); const [target, setTarget] = useState<ExamRegistration | null>(null);
const [status, setStatus] = useState<AttendanceStatus>('PRESENT'); const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
const [remark, setRemark] = useState(''); const [remark, setRemark] = useState('');
const table = useServerTable(); 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) => { const startRecording = (registration: ExamRegistration) => {
setTarget(registration); setTarget(registration);
setStatus( setStatus(
@@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
) : ( ) : (
<AdvancedTable <AdvancedTable
tableName={t('exam.candidates.section')} tableName={t('exam.candidates.section')}
columns={examCandidateColumns(t, { onRecord: startRecording })} columns={examCandidateColumns(t, {
onRecord: startRecording,
onRegrade: regrade,
regrading,
})}
data={paged.rows} data={paged.rows}
itemCount={paged.itemCount} itemCount={paged.itemCount}
pageIndex={paged.pageIndex} pageIndex={paged.pageIndex}

View File

@@ -1,15 +1,17 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Button, Text } from '@mantine/core'; import { Badge, Button, Text } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react'; import { IconAlertTriangle } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam'; import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
const STATUS_COLOR: Record<ExamIncidentStatus, string> = { const STATUS_TONE: Record<ExamIncidentStatus, StatusTone> = {
OPEN: 'red', OPEN: 'danger',
UNDER_REVIEW: 'yellow', UNDER_REVIEW: 'warning',
RESOLVED: 'teal', RESOLVED: 'success',
DISMISSED: 'gray', DISMISSED: 'neutral',
}; };
export function examIncidentColumns( export function examIncidentColumns(
@@ -56,13 +58,12 @@ export function examIncidentColumns(
{ {
header: t('exam.incidents.status'), header: t('exam.incidents.status'),
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <StatusBadge
tone={STATUS_TONE[row.original.status] ?? 'neutral'}
label={t(`exam.incidentStatus.${row.original.status}`)}
size="sm" size="sm"
variant="light" 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 { useState, useEffect, useRef, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { import {
@@ -39,7 +40,7 @@ import {
IconCheck, IconCheck,
IconX, IconX,
} from '@tabler/icons-react'; } 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 { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { import {
@@ -57,16 +58,20 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
import { PageLoader } from '@ema-platform/ui'; import { PageLoader } from '@ema-platform/ui';
import type { ExamStatus, QuestionBrief } from '../types/exam'; import type { ExamStatus, QuestionBrief } from '../types/exam';
const STATUS_COLOR: Record<string, string> = { const STATUS_TONE: Record<string, StatusTone> = {
PENDING: "gray", PENDING: 'neutral',
ACTIVE: "blue", ACTIVE: 'info',
COMPLETED: "teal", COMPLETED: 'success',
CANCELLED: "red", CANCELLED: 'danger',
POSTPONED: "orange", POSTPONED: 'pending',
PUBLISHED: "green", 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 TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
const ADMIN_LABEL: Record<string, string> = { const ADMIN_LABEL: Record<string, string> = {
OFFLINE: "Offline", OFFLINE: "Offline",
@@ -171,6 +176,11 @@ export function ExamDetailPage() {
notify.error( notify.error(
key.startsWith('insufficient_approved_questions') key.startsWith('insufficient_approved_questions')
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})` ? `${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, : key,
); );
} }
@@ -187,18 +197,35 @@ export function ExamDetailPage() {
notify.error( notify.error(
key.startsWith('question_not_approved') key.startsWith('question_not_approved')
? t('question.qc.onlyApprovedUsable') ? 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, : key,
); );
} }
}; };
const handlePrint = async () => { const handlePrint = async () => {
const total = (exam.questions ?? []).reduce( // The reachable max depends on the evaluation method, not the raw point
(s, q) => s + Number(q.points), // sum — mirrors RecordResultModal's grading math so "can this paper pass"
0, // 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
if (total < Number(exam.cuttingPoint)) { // 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( 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; return;
} }
@@ -232,7 +259,23 @@ export function ExamDetailPage() {
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p> <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>` : ""} ${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 === "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>`; </div>`;
}) })
.join(""); .join("");
@@ -247,7 +290,20 @@ export function ExamDetailPage() {
.header p { margin: 2px 0; font-size: 13px; color: #555; } .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 { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; }
.directions strong { display: block; margin-bottom: 4px; } .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> </style></head><body>
<div class="header"> <div class="header">
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""} ${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
@@ -258,7 +314,7 @@ export function ExamDetailPage() {
</div> </div>
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""} ${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""}
${qHtml} ${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 Generated by EMA — Ethiopian Maritime Authority
</div> </div>
</body></html> </body></html>
@@ -290,7 +346,7 @@ export function ExamDetailPage() {
<IconArrowLeft size={18} /> <IconArrowLeft size={18} />
</ActionIcon> </ActionIcon>
<div> <div>
<Title order={3}>{exam.title[locale]}</Title> <Title order={2}>{exam.title[locale]}</Title>
</div> </div>
</Group> </Group>
<Group gap="sm"> <Group gap="sm">
@@ -313,14 +369,13 @@ export function ExamDetailPage() {
</Group> </Group>
{/* Status badge */} {/* Status badge */}
<Badge <StatusBadge
tone={STATUS_TONE[exam.status]}
label={t(`exam.status.${exam.status}`)}
size="lg" size="lg"
variant="light" variant="light"
color={STATUS_COLOR[exam.status]}
style={{ width: "fit-content" }} style={{ width: "fit-content" }}
> />
{t(`exam.status.${exam.status}`)}
</Badge>
{/* Exam Info */} {/* Exam Info */}
<Paper withBorder radius="lg" p="lg"> <Paper withBorder radius="lg" p="lg">

View File

@@ -1,5 +1,11 @@
import { ActionIcon, Group } from "@mantine/core"; import { ActionIcon, Menu } from "@mantine/core";
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react"; import {
IconDetails,
IconDotsVertical,
IconEdit,
IconTrash,
IconToggleRight,
} from "@tabler/icons-react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui"; import type { AdvancedColumn } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
@@ -11,40 +17,55 @@ export function examActionsColumn(
onEdit: (exam: Exam) => void; onEdit: (exam: Exam) => void;
onDelete: (exam: Exam) => void; onDelete: (exam: Exam) => void;
onDetails: (exam: Exam) => void; onDetails: (exam: Exam) => void;
onOpenStatusChange: (exam: Exam) => void;
changingStatusId?: string | null;
}, },
): AdvancedColumn<Exam> { ): AdvancedColumn<Exam> {
return { return {
header: t("exam.columns.actions"), header: t("exam.columns.actions", "Actions"),
align: "right", align: "right",
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap="xs"> <Menu shadow="md" width={180} position="bottom-end">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly> <Menu.Target>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="blue" color="gray"
size="sm" size="sm"
onClick={() => handlers.onEdit(row.original)} loading={handlers.changingStatusId === row.original.id}
> >
<IconEdit size={14} /> <IconDotsVertical size={16} />
</ActionIcon> </ActionIcon>
<ActionIcon </Menu.Target>
variant="subtle" <Menu.Dropdown>
color="red" <Menu.Item
size="sm" leftSection={<IconDetails size={14} />}
onClick={() => handlers.onDelete(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
</RequirePermission>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDetails(row.original)} onClick={() => handlers.onDetails(row.original)}
> >
<IconDetails size={14} /> {t("exam.action.details", "Details")}
</ActionIcon> </Menu.Item>
</Group> <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 { Badge, Text } from "@mantine/core";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui"; import type { AdvancedColumn } from "@ema-platform/ui";
import { StatusBadge } from '@ema-platform/ui';
import type { Exam } from "../../types/exam"; import type { Exam } from "../../types/exam";
const STATUS_COLOR: Record<string, string> = { const STATUS_TONE: Record<string, StatusTone> = {
PENDING: "gray", PENDING: 'neutral',
ACTIVE: "blue", ACTIVE: 'info',
COMPLETED: "teal", COMPLETED: 'success',
CANCELLED: "red", CANCELLED: 'danger',
POSTPONED: "orange", POSTPONED: 'pending',
PUBLISHED: "green", PUBLISHED: 'success',
}; };
export function examColumns( export function examColumns(
@@ -72,9 +74,12 @@ export function examColumns(
{ {
header: t("exam.columns.status"), header: t("exam.columns.status"),
cell: ({ row }) => ( cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}> <StatusBadge
{t(`exam.status.${row.original.status}`)} tone={STATUS_TONE[row.original.status]}
</Badge> 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 { useNavigate } from "react-router-dom";
import { import {
Stack, Stack,
Title,
Group, Group,
Button, Button,
Modal, Modal,
@@ -10,7 +9,6 @@ import {
TextInput, TextInput,
Textarea, Textarea,
Card, Card,
Alert,
Select, Select,
NumberInput, NumberInput,
Tabs, Tabs,
@@ -26,6 +24,7 @@ import {
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui"; import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import { useGetCertificationsQuery } from "../../../certification/api/certification-api"; import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
import { RANK_KEY_OPTIONS } from "../../../certification/types/certification";
import { import {
useGetExamsQuery, useGetExamsQuery,
useCreateExamMutation, useCreateExamMutation,
@@ -35,6 +34,7 @@ import {
import type { Exam } from "../../types/exam"; import type { Exam } from "../../types/exam";
import { examColumns } from "./columns"; import { examColumns } from "./columns";
import { examActionsColumn } from "./actions"; import { examActionsColumn } from "./actions";
import { ErrorState, PageHeader } from '@ema-platform/ui';
function ExamForm({ function ExamForm({
editing, editing,
@@ -77,21 +77,23 @@ function ExamForm({
editing?.cuttingPoint ?? 0, editing?.cuttingPoint ?? 0,
); );
const [status, setStatus] = useState<string | null>(editing?.status ?? null); const [status, setStatus] = useState<string | null>(editing?.status ?? null);
const [activeTab, setActiveTab] = useState<string | null>("basic");
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if ( if (!certificationId || !titleEn || !titleAm || !date || !venue) {
!certificationId || setActiveTab("basic");
!titleEn || notify.error(t("exam.form.fillRequiredBasic"));
!titleAm || return;
!date || }
!type || if ((directionEn || directionAm) && !(directionEn && directionAm)) {
!form || setActiveTab("basic");
!venue || notify.error(t("exam.form.directionBothLanguages"));
!adminMethod || return;
!evalMethod }
) { if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
notify.error("Please fill all required fields"); setActiveTab("settings");
notify.error(t("exam.form.fillRequiredSettings"));
return; return;
} }
onSubmit( onSubmit(
@@ -121,7 +123,7 @@ function ExamForm({
return ( return (
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl"> <Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Tabs defaultValue="basic" variant="outline" radius="md"> <Tabs value={activeTab} onChange={setActiveTab} variant="outline" radius="md">
<Tabs.List mb="md"> <Tabs.List mb="md">
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}> <Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
{t("exam.form.basicInfo")} {t("exam.form.basicInfo")}
@@ -247,11 +249,18 @@ function ExamForm({
data={[ data={[
{ value: "ESSAY", label: t("exam.form.essay") }, { value: "ESSAY", label: t("exam.form.essay") },
{ value: "CHOICE", label: t("exam.form.choice") }, { value: "CHOICE", label: t("exam.form.choice") },
{ value: "BOTH", label: t("exam.form.both") },
]} ]}
value={form} value={form}
onChange={setForm} onChange={setForm}
size="sm" size="sm"
required required
disabled={adminMethod === "ONLINE"}
description={
adminMethod === "ONLINE"
? t("exam.form.onlineChoiceOnlyHint")
: undefined
}
/> />
<Select <Select
label={t("exam.detail.administration")} label={t("exam.detail.administration")}
@@ -261,7 +270,14 @@ function ExamForm({
{ value: "ONLINE", label: t("exam.form.online") }, { value: "ONLINE", label: t("exam.form.online") },
]} ]}
value={adminMethod} 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" size="sm"
required required
/> />
@@ -291,12 +307,22 @@ function ExamForm({
/> />
<NumberInput <NumberInput
label={t("exam.form.cuttingPoint")} label={t("exam.form.cuttingPoint")}
placeholder={t("exam.form.cuttingPointPlaceholder")} placeholder={
evalMethod === "PERCENTAGE"
? t("exam.form.cuttingPointPercentagePlaceholder")
: t("exam.form.cuttingPointPlaceholder")
}
value={cuttingPoint} value={cuttingPoint}
onChange={(v) => setCuttingPoint(Number(v))} onChange={(v) => setCuttingPoint(Number(v))}
min={0} min={0}
max={evalMethod === "PERCENTAGE" ? 100 : undefined}
size="sm" size="sm"
required withAsterisk
description={
evalMethod === "PERCENTAGE"
? t("exam.form.cuttingPointPercentageHint")
: undefined
}
/> />
</SimpleGrid> </SimpleGrid>
{editing && ( {editing && (
@@ -353,10 +379,24 @@ export function ExamPage() {
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null); const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = const [deleteOpened, { open: openDelete, close: closeDelete }] =
useDisclosure(false); 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 const certOptions = certifications
.filter((c) => c.isActive) .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) => const getCertName = (id: string) =>
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-"; 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 () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
try { try {
@@ -416,13 +471,7 @@ export function ExamPage() {
}; };
if (isError) if (isError)
return ( return <ErrorState title={t("exam.loadError")} onRetry={refetch} />;
<Alert
icon={<IconInfoCircle size={16} />}
color="red"
title={t("exam.loadError")}
/>
);
const columns = [ const columns = [
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)), ...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
@@ -436,6 +485,12 @@ export function ExamPage() {
openDelete(); openDelete();
}, },
onDetails: (exam) => navigate(`/exams/${exam.id}`), onDetails: (exam) => navigate(`/exams/${exam.id}`),
onOpenStatusChange: (exam) => {
setStatusTarget(exam);
setPendingStatus(exam.status);
openStatus();
},
changingStatusId,
}), }),
]; ];
@@ -443,14 +498,12 @@ export function ExamPage() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between" align="flex-end"> <PageHeader
<div> title={t("exam.title")}
<Title order={2}>{t("exam.title")}</Title> subtitle={t("exam.subtitle")}
<Text fz="sm" c="dimmed"> noMargin
{t("exam.subtitle")} action={
</Text> !showForm && (
</div>
{!showForm && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly> <RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button <Button
variant="light" variant="light"
@@ -461,8 +514,9 @@ export function ExamPage() {
{t("exam.add")} {t("exam.add")}
</Button> </Button>
</RequirePermission> </RequirePermission>
)} )
</Group> }
/>
{showForm && ( {showForm && (
<ExamForm <ExamForm
@@ -511,6 +565,47 @@ export function ExamPage() {
</Button> </Button>
</ModalFooter> </ModalFooter>
</Modal> </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> </Stack>
); );
} }

View File

@@ -3,6 +3,8 @@ import type { EstimatedTime } from "../../question/types/question";
import type { QuestionForm } from "../../question/types/question"; import type { QuestionForm } from "../../question/types/question";
export type { QuestionForm }; export type { QuestionForm };
export type ExamForm = QuestionForm | "BOTH";
export type ExamType = "WRITTEN" | "ORAL"; export type ExamType = "WRITTEN" | "ORAL";
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE"; export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE"; export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
@@ -15,11 +17,19 @@ export type ExamStatus =
| "POSTPONED" | "POSTPONED"
| "PUBLISHED"; | "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 { export interface QuestionBrief {
id: string; id: string;
title: LocalePair; title: LocalePair;
form: QuestionForm; form: QuestionForm;
points: number; points: number;
options?: QuestionOptionBrief[];
} }
export interface Exam { export interface Exam {
@@ -31,7 +41,7 @@ export interface Exam {
date: string; date: string;
givenTime: EstimatedTime | null; givenTime: EstimatedTime | null;
type: ExamType; type: ExamType;
form: QuestionForm; form: ExamForm;
venue: string; venue: string;
administrationMethod: ExamAdministrationMethod; administrationMethod: ExamAdministrationMethod;
evaluationMethod: ExamEvaluationMethod; evaluationMethod: ExamEvaluationMethod;
@@ -55,7 +65,7 @@ export interface CreateExamPayload {
date: string; date: string;
givenTime: EstimatedTime; givenTime: EstimatedTime;
type: ExamType; type: ExamType;
form: QuestionForm; form: ExamForm;
venue: string; venue: string;
administrationMethod: ExamAdministrationMethod; administrationMethod: ExamAdministrationMethod;
evaluationMethod: ExamEvaluationMethod; evaluationMethod: ExamEvaluationMethod;
@@ -71,7 +81,7 @@ export interface UpdateExamPayload {
date?: string; date?: string;
givenTime?: EstimatedTime; givenTime?: EstimatedTime;
type?: ExamType; type?: ExamType;
form?: QuestionForm; form?: ExamForm;
venue?: string; venue?: string;
administrationMethod?: ExamAdministrationMethod; administrationMethod?: ExamAdministrationMethod;
evaluationMethod?: ExamEvaluationMethod; evaluationMethod?: ExamEvaluationMethod;
@@ -118,8 +128,14 @@ export interface ExamRegistration {
lastName: string | null; lastName: string | null;
seafarerNumber: 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 { export interface RecordAttendancePayload {
registrationId: string; registrationId: string;
status: AttendanceStatus; 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 type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Item } from '../../api/item-api'; import type { Item } from '../../api/item-api';
const STATUS_COLORS: Record<Item['status'], string> = { const STATUS_TONES: Record<Item['status'], StatusTone> = {
DRAFT: 'gray', DRAFT: 'neutral',
ACTIVE: 'green', ACTIVE: 'success',
ARCHIVED: 'orange', ARCHIVED: 'pending',
}; };
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] { export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
@@ -14,7 +15,7 @@ export function itemColumns(showDate: (date: string) => string): AdvancedColumn<
{ {
header: 'Status', header: 'Status',
cell: ({ row }) => ( 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 { ItemTable } from '../components/ItemTable';
import { PageHeader } from '@ema-platform/ui';
export function ItemPage() { export function ItemPage() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Title order={2}>Items</Title> <PageHeader title="Items" noMargin />
<Paper p="md" shadow="sm" radius="md" withBorder> <Paper p="md" shadow="sm" radius="md" withBorder>
<ItemTable /> <ItemTable />
</Paper> </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 { IconShieldCog } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api'; import type { Bilingual, IssuedLicense } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const LICENSE_STATUS_COLORS: Record<string, string> = { const LICENSE_STATUS_TONES: Record<string, StatusTone> = {
ACTIVE: 'green', ACTIVE: 'success',
EXPIRED: 'yellow', EXPIRED: 'warning',
SUSPENDED: 'orange', SUSPENDED: 'pending',
CANCELLED: 'red', CANCELLED: 'danger',
SUPERSEDED: 'gray', SUPERSEDED: 'neutral',
}; };
export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate'; export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
@@ -70,13 +72,12 @@ export function licenseRegisterColumns(
{ {
header: 'Status', header: 'Status',
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <StatusBadge
tone={LICENSE_STATUS_TONES[row.original.status] ?? 'neutral'}
label={row.original.status}
size="sm" size="sm"
variant="light" 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 { useState } from 'react';
import { import {Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Textarea} from '@mantine/core';
Button,
Card,
Container,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react'; 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 { useDateDisplayer } from '@ema-platform/shared';
import { import {
extractErrorMessage, extractErrorMessage,
@@ -151,13 +139,10 @@ export function LicenseRegisterPage() {
return ( return (
<Container size="xl" py="md"> <Container size="xl" py="md">
<Group justify="space-between" mb="md"> <PageHeader
<div> title="Licence register"
<Title order={3}>Licence register</Title> subtitle={`${data?.total ?? 0} issued licence${(data?.total ?? 0) === 1 ? '' : 's'}`}
<Text size="sm" c="dimmed"> action={
{data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'}
</Text>
</div>
<TextInput <TextInput
placeholder="Certificate № or company" placeholder="Certificate № or company"
leftSection={<IconSearch size={14} />} leftSection={<IconSearch size={14} />}
@@ -165,7 +150,8 @@ export function LicenseRegisterPage() {
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
w={280} w={280}
/> />
</Group> }
/>
<Card withBorder padding={0}> <Card withBorder padding={0}>
<AdvancedTable<IssuedLicense> <AdvancedTable<IssuedLicense>

View File

@@ -1,20 +1,17 @@
import { useState } from 'react'; 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 { IconCalendarEvent } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui'; import { ModalFooter } from '@ema-platform/ui';
import { useGetExamsQuery } from '../../exam/api/exam-api'; import { useGetEligibleExamsQuery } from '@ema-platform/api';
interface Props { interface Props {
opened: boolean; opened: boolean;
applicationId: string;
applicantName: string; applicantName: string;
loading: boolean; loading: boolean;
onClose: () => void; onClose: () => void;
onConfirm: (payload: { onConfirm: (payload: { examId: string; examDate?: string }) => void;
examId: string;
admissionNumber?: string;
examDate?: string;
}) => void;
} }
/** /**
@@ -22,33 +19,34 @@ interface Props {
* *
* Sessions are picked from the exam calendar rather than typed, because the * Sessions are picked from the exam calendar rather than typed, because the
* candidate joins a scheduled sitting — this is an assignment, not the creation * 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({ export function ScheduleExamModal({
opened, opened,
applicationId,
applicantName, applicantName,
loading, loading,
onClose, onClose,
onConfirm, onConfirm,
}: Props) { }: Props) {
const { t } = useTranslation(); 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 [examId, setExamId] = useState<string | null>(null);
const [admissionNumber, setAdmissionNumber] = useState('');
const options = (exams?.items ?? []).map((exam) => ({ const options = (exams ?? []).map((exam) => ({
value: exam.id, value: exam.id,
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date] label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
.filter(Boolean) .filter(Boolean)
.join(' — '), .join(' — '),
})); }));
const selected = exams?.items?.find((exam) => exam.id === examId); const selected = exams?.find((exam) => exam.id === examId);
function confirm() { function confirm() {
if (!examId) return; if (!examId) return;
onConfirm({ onConfirm({
examId, examId,
admissionNumber: admissionNumber.trim() || undefined,
examDate: selected?.date ? String(selected.date) : undefined, examDate: selected?.date ? String(selected.date) : undefined,
}); });
} }
@@ -72,7 +70,7 @@ export function ScheduleExamModal({
<Alert color="orange" icon={<IconCalendarEvent size={16} />}> <Alert color="orange" icon={<IconCalendarEvent size={16} />}>
{t( {t(
'review.scheduleExam.noSessions', '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> </Alert>
) : ( ) : (
@@ -88,15 +86,12 @@ export function ScheduleExamModal({
/> />
)} )}
<TextInput <Text size="xs" c="dimmed">
label={t('review.scheduleExam.admissionNumber', 'Admission number')} {t(
description={t(
'review.scheduleExam.admissionHint', 'review.scheduleExam.admissionHint',
'Leave blank to let the system issue one.', 'An admission number is issued automatically when the candidate is seated.',
)} )}
value={admissionNumber} </Text>
onChange={(e) => setAdmissionNumber(e.currentTarget.value)}
/>
<ModalFooter> <ModalFooter>
<Button variant="default" onClick={onClose}> <Button variant="default" onClick={onClose}>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Text } from '@mantine/core'; import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import { import {
seaServiceDays, seaServiceDays,
type MedicalCertificate, type MedicalCertificate,
@@ -18,10 +20,10 @@ export function ownerName(profile?: SeafarerProfileSummary): string {
); );
} }
const STATUS_COLOR: Record<SeafarerRecordStatus, string> = { const STATUS_TONE: Record<SeafarerRecordStatus, StatusTone> = {
SUBMITTED: 'yellow', SUBMITTED: 'warning',
VERIFIED: 'teal', VERIFIED: 'success',
REJECTED: 'red', REJECTED: 'danger',
}; };
/** Only meaningful now the queue can show ruled records too. */ /** 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'), label: t('recordVerification.columns.status', 'Status'),
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => ( cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}> <StatusBadge
{t(`recordVerification.status.${row.original.status}`, row.original.status)} tone={STATUS_TONE[row.original.status]}
</Badge> 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 { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {Badge, Button, Center, Container, Group, Loader, Modal, Paper, SegmentedControl, Stack, Text, Textarea} from '@mantine/core';
Badge,
Button,
Center,
Container,
Group,
Loader,
Modal,
Paper,
SegmentedControl,
Stack,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react'; import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
import { import { AdvancedTable, notify, PageHeader, PdfPreviewModal, type AdvancedColumn } from '@ema-platform/ui';
AdvancedTable,
notify,
PdfPreviewModal,
type AdvancedColumn,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
import { import {
extractErrorMessage, extractErrorMessage,
@@ -198,20 +179,21 @@ export type VerificationKind = 'medical' | 'sea-service';
*/ */
export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) { export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMedical = kind === 'medical';
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED'); const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
const { const {
data: pendingMedical, data: pendingMedical,
isLoading: loadingMedical, isLoading: loadingMedical,
isFetching: fetchingMedical, isFetching: fetchingMedical,
refetch: refetchMedical, refetch: refetchMedical,
} = useGetPendingMedicalQuery(filter); } = useGetPendingMedicalQuery(filter, { skip: !isMedical });
const { const {
data: pendingSeaService, data: pendingSeaService,
isLoading: loadingSeaService, isLoading: loadingSeaService,
isFetching: fetchingSeaService, isFetching: fetchingSeaService,
refetch: refetchSeaService, refetch: refetchSeaService,
} = useGetPendingSeaServiceQuery(filter); } = useGetPendingSeaServiceQuery(filter, { skip: isMedical });
const [verifyMedical, { isLoading: rulingMedical }] = const [verifyMedical, { isLoading: rulingMedical }] =
useVerifyMedicalCertificateMutation(); useVerifyMedicalCertificateMutation();
@@ -372,17 +354,16 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
[rulingSeaService, rule, verifySeaService, showDate, t], [rulingSeaService, rule, verifySeaService, showDate, t],
); );
const isMedical = kind === 'medical';
return ( return (
<Container size="xl" py="md"> <Container size="xl" py="md">
<Title order={3} mb={4}> <PageHeader
{isMedical title={
isMedical
? t('recordVerification.medicalTitle', 'Medical Certificate Verification') ? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')} : t('recordVerification.seaServiceTitle', 'Sea Service Verification')
</Title> }
<Text size="sm" c="dimmed" mb="md"> subtitle={
{isMedical isMedical
? t( ? t(
'recordVerification.medicalSubtitle', 'recordVerification.medicalSubtitle',
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.', '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( : t(
'recordVerification.seaServiceSubtitle', '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.', '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 ? ( {isMedical ? (
<AdvancedTable <AdvancedTable
@@ -408,7 +392,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
onPageSizeChange={handleMedicalPageSizeChange} onPageSizeChange={handleMedicalPageSizeChange}
refresh={refetchMedical} refresh={refetchMedical}
isLoading={loadingMedical || fetchingMedical} isLoading={loadingMedical || fetchingMedical}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')} emptyText={emptyText}
/> />
) : ( ) : (
<AdvancedTable <AdvancedTable
@@ -425,7 +409,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
onPageSizeChange={handleSeaServicePageSizeChange} onPageSizeChange={handleSeaServicePageSizeChange}
refresh={refetchSeaService} refresh={refetchSeaService}
isLoading={loadingSeaService || fetchingSeaService} isLoading={loadingSeaService || fetchingSeaService}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')} emptyText={emptyText}
/> />
)} )}

View File

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

View File

@@ -31,7 +31,6 @@ import {
IconLock, IconLock,
IconMail, IconMail,
IconMoon, IconMoon,
IconPhone,
IconSettings, IconSettings,
IconShieldLock, IconShieldLock,
IconSun, IconSun,
@@ -42,7 +41,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslation } from 'react-i18next'; 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 { useApiMutation } from '@ema-platform/api';
import { ActiveSessions, setUser } from '@ema-platform/auth'; import { ActiveSessions, setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth';
@@ -136,6 +135,9 @@ export function ProfilePage() {
register: registerProfile, register: registerProfile,
handleSubmit: handleProfileSubmit, handleSubmit: handleProfileSubmit,
reset: resetProfile, reset: resetProfile,
watch: watchProfile,
setValue: setValueProfile,
trigger: triggerProfile,
formState: { errors: profileErrors }, formState: { errors: profileErrors },
} = useForm<ProfileValues>({ } = useForm<ProfileValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
@@ -247,7 +249,7 @@ export function ProfilePage() {
return ( return (
<Stack gap="lg" maw={900}> <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 */} {/* Profile summary */}
<Paper p="lg" shadow="sm" radius="lg" withBorder> <Paper p="lg" shadow="sm" radius="lg" withBorder>
@@ -372,11 +374,12 @@ export function ProfilePage() {
error={profileErrors.email?.message} error={profileErrors.email?.message}
{...registerProfile('email')} {...registerProfile('email')}
/> />
<TextInput <PhoneInput
label={t('profile.fields.phone')} 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} error={profileErrors.phoneNumber?.message}
{...registerProfile('phoneNumber')}
/> />
</SimpleGrid> </SimpleGrid>
</div> </div>

View File

@@ -1,10 +1,12 @@
import { baseApi } from '@ema-platform/api'; import { baseApi } from '@ema-platform/api';
import type { import type {
Question, Question,
QuestionOption,
ListResponse, ListResponse,
CreateQuestionPayload, CreateQuestionPayload,
UpdateQuestionPayload, UpdateQuestionPayload,
ReviewQuestionPayload, ReviewQuestionPayload,
SetQuestionOptionsPayload,
} from '../types/question'; } from '../types/question';
const questionApi = baseApi.injectEndpoints({ const questionApi = baseApi.injectEndpoints({
@@ -17,6 +19,11 @@ const questionApi = baseApi.injectEndpoints({
query: (id) => `/questions/${id}`, query: (id) => `/questions/${id}`,
providesTags: ['Api'], 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>({ createQuestion: builder.mutation<Question, CreateQuestionPayload>({
query: (body) => ({ url: '/questions', method: 'POST', body }), query: (body) => ({ url: '/questions', method: 'POST', body }),
invalidatesTags: ['Api'], invalidatesTags: ['Api'],
@@ -47,6 +54,15 @@ const questionApi = baseApi.injectEndpoints({
}), }),
invalidatesTags: ['Api'], 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, overrideExisting: false,
}); });
@@ -54,9 +70,11 @@ const questionApi = baseApi.injectEndpoints({
export const { export const {
useGetQuestionsQuery, useGetQuestionsQuery,
useGetQuestionQuery, useGetQuestionQuery,
useGetQuestionWithOptionsQuery,
useCreateQuestionMutation, useCreateQuestionMutation,
useUpdateQuestionMutation, useUpdateQuestionMutation,
useDeleteQuestionMutation, useDeleteQuestionMutation,
useSubmitQuestionMutation, useSubmitQuestionMutation,
useReviewQuestionMutation, useReviewQuestionMutation,
useSetQuestionOptionsMutation,
} = questionApi; } = 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 { ActionIcon, Menu } from '@mantine/core';
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react'; import {
IconDotsVertical,
IconEdit,
IconGavel,
IconSend,
IconTrash,
} from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -21,52 +27,64 @@ export function questionActionsColumn(
cell: ({ row }) => { cell: ({ row }) => {
const q = row.original; const q = row.original;
return ( 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') && ( {(q.status === 'DRAFT' || q.status === 'REJECTED') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly> <RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<Button <Menu.Item
size="compact-xs" leftSection={<IconSend size={14} />}
variant="light"
leftSection={<IconSend size={12} />}
loading={handlers.isSubmittingReview}
onClick={() => handlers.onSubmitForApproval(q)} onClick={() => handlers.onSubmitForApproval(q)}
> >
{t('question.qc.submit')} {t('question.qc.submit')}
</Button> </Menu.Item>
</RequirePermission> </RequirePermission>
)} )}
{q.status === 'PENDING_APPROVAL' && ( {q.status === 'PENDING_APPROVAL' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly> <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')} {t('question.qc.approve')}
</Button> </Menu.Item>
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}> <Menu.Item color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
{t('question.qc.reject')} {t('question.qc.reject')}
</Button> </Menu.Item>
</RequirePermission> </RequirePermission>
)} )}
{q.status === 'APPROVED' && ( {q.status === 'APPROVED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly> <RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Button <Menu.Item
size="compact-xs"
variant="subtle"
color="dark" color="dark"
leftSection={<IconGavel size={12} />} leftSection={<IconGavel size={14} />}
onClick={() => handlers.onReview(q, 'RETIRED')} onClick={() => handlers.onReview(q, 'RETIRED')}
> >
{t('question.qc.retire')} {t('question.qc.retire')}
</Button> </Menu.Item>
</RequirePermission> </RequirePermission>
)} )}
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly> <RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}> <Menu.Divider />
<IconEdit size={14} /> <Menu.Item leftSection={<IconEdit size={14} />} onClick={() => handlers.onEdit(q)}>
</ActionIcon> {t('question.action.edit', 'Edit')}
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}> </Menu.Item>
<IconTrash size={14} /> <Menu.Item
</ActionIcon> color="red"
leftSection={<IconTrash size={14} />}
onClick={() => handlers.onDelete(q)}
>
{t('question.action.delete', 'Delete')}
</Menu.Item>
</RequirePermission> </RequirePermission>
</Group> </Menu.Dropdown>
</Menu>
); );
}, },
}; };

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState } from "react";
import { import {
Stack, Stack,
Title, Title,
@@ -13,14 +13,30 @@ import {
Select, Select,
NumberInput, NumberInput,
Textarea, Textarea,
} from '@mantine/core'; Checkbox,
import { useDisclosure } from '@mantine/hooks'; ActionIcon,
import { useTranslation } from 'react-i18next'; } from "@mantine/core";
import { IconPlus, IconInfoCircle } from '@tabler/icons-react'; import { useDisclosure } from "@mantine/hooks";
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui'; import { useTranslation } from "react-i18next";
import { extractErrorMessage } from '@ema-platform/api'; import {
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; IconPlus,
import { useGetCertificationsQuery } from '../../../certification/api/certification-api'; 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 { import {
useGetQuestionsQuery, useGetQuestionsQuery,
useCreateQuestionMutation, useCreateQuestionMutation,
@@ -28,10 +44,92 @@ import {
useDeleteQuestionMutation, useDeleteQuestionMutation,
useSubmitQuestionMutation, useSubmitQuestionMutation,
useReviewQuestionMutation, useReviewQuestionMutation,
} from '../../api/question-api'; useSetQuestionOptionsMutation,
import type { Question, QuestionForm } from '../../types/question'; } from "../../api/question-api";
import { questionColumns } from './columns'; import type {
import { questionActionsColumn } from './actions'; 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({ function QuestionForm({
editing, editing,
@@ -43,7 +141,8 @@ function QuestionForm({
editing: Question | null; editing: Question | null;
certOptions: { value: string; label: string }[]; certOptions: { value: string; label: string }[];
isSubmitting: boolean; isSubmitting: boolean;
onSubmit: (values: { onSubmit: (
values: {
certificationId: string; certificationId: string;
titleEn: string; titleEn: string;
titleAm: string; titleAm: string;
@@ -52,49 +151,171 @@ function QuestionForm({
days: number; days: number;
hours: number; hours: number;
minutes: number; minutes: number;
}, isEdit: boolean) => void; draftOptions: DraftOption[];
},
isEdit: boolean,
) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null); const [certificationId, setCertificationId] = useState<string | null>(
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? ''); editing?.certificationId ?? null,
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? ''); );
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
const [form, setForm] = useState<string | null>(editing?.form ?? null); const [form, setForm] = useState<string | null>(editing?.form ?? null);
const [points, setPoints] = useState<number>(editing?.points ?? 0); const [points, setPoints] = useState<number>(editing?.points ?? 0);
const [days, setDays] = useState(editing?.time?.days ?? 0); const [days, setDays] = useState(editing?.time?.days ?? 0);
const [hours, setHours] = useState(editing?.time?.hours ?? 0); const [hours, setHours] = useState(editing?.time?.hours ?? 0);
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0); const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
const [draftOptions, setDraftOptions] =
useState<DraftOption[]>(BLANK_DRAFT_OPTIONS);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!certificationId || !titleEn || !titleAm || !form) { if (!certificationId || !titleEn || !titleAm || !form) {
notify.error('Please fill all required fields'); notify.error("Please fill all required fields");
return; return;
} }
onSubmit({ if (!editing && form === "CHOICE") {
certificationId, titleEn, titleAm, form, points, days, hours, minutes if (draftOptions.length < 2) {
}, !!editing); 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 ( 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}> <form onSubmit={handleSubmit}>
<Stack gap="sm"> <Stack gap="sm">
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required /> <Select
<TextInput label={t('question.form.titleEn')} placeholder={t('question.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required /> label={t("question.form.certification")}
<TextInput label={t('question.form.titleAm')} placeholder={t('question.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required /> placeholder={t("question.form.selectCertification")}
<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 /> data={certOptions}
<NumberInput label={t('question.form.points')} placeholder={t('question.form.pointsPlaceholder')} value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required /> value={certificationId}
<Text fz="sm" fw={500}>{t('question.form.timeAllowed')}</Text> 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> <Group gap="sm" grow>
<NumberInput label={t('question.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" /> <NumberInput
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" /> label={t("question.form.days")}
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" /> 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> </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> <ModalFooter>
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button> <Button variant="default" onClick={onCancel} size="sm">
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button> {t("question.cancel")}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editing ? t("question.update") : t("question.create")}
</Button>
</ModalFooter> </ModalFooter>
</Stack> </Stack>
</form> </form>
@@ -104,7 +325,7 @@ function QuestionForm({
export function QuestionPage() { export function QuestionPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am'; const locale = i18n.language as "en" | "am";
const { handleError } = useErrorHandler(); const { handleError } = useErrorHandler();
const { data: certRes } = useGetCertificationsQuery(); const { data: certRes } = useGetCertificationsQuery();
const { data, isFetching, isError, refetch } = useGetQuestionsQuery(); const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
@@ -112,7 +333,10 @@ export function QuestionPage() {
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation(); const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation(); const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
const [deleteQ] = useDeleteQuestionMutation(); const [deleteQ] = useDeleteQuestionMutation();
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation(); const [setOptions, { isLoading: isSavingOptions }] =
useSetQuestionOptionsMutation();
const [submitQ, { isLoading: isSubmittingReview }] =
useSubmitQuestionMutation();
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation(); const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
const certifications = certRes?.items ?? []; const certifications = certRes?.items ?? [];
@@ -121,60 +345,115 @@ export function QuestionPage() {
const [editing, setEditing] = useState<Question | null>(null); const [editing, setEditing] = useState<Question | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null); 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 [certFilter, setCertFilter] = useState<string | null>(null);
const [reviewTarget, setReviewTarget] = useState<Question | null>(null); const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED'); const [reviewOutcome, setReviewOutcome] = useState<
const [reviewRemark, setReviewRemark] = 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 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: { const handleSubmit = async (
certificationId: string; titleEn: string; titleAm: string; values: {
form: string; points: number; days: number; hours: number; minutes: number; certificationId: string;
}, isEdit: boolean) => { 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 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 { try {
if (isEdit && editing) { if (isEdit && editing) {
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap(); await updateQ({
notify.success(t('question.updated')); id: editing.id,
certificationId: values.certificationId,
title,
form: values.form as QuestionForm,
points: values.points,
time,
}).unwrap();
notify.success(t("question.updated"));
} else { } else {
await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap(); const created = await createQ({
notify.success(t('question.created')); 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(); resetForm();
} catch { } catch {
notify.error(t('question.error')); notify.error(t("question.error"));
} }
}; };
const handleSubmitForApproval = async (question: Question) => { const handleSubmitForApproval = async (question: Question) => {
try { try {
await submitQ(question.id).unwrap(); await submitQ(question.id).unwrap();
notify.success(t('question.qc.submitted')); notify.success(t("question.qc.submitted"));
} catch (error) { } 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); setReviewTarget(question);
setReviewOutcome(outcome); setReviewOutcome(outcome);
setReviewRemark(''); setReviewRemark("");
}; };
const handleReview = async () => { const handleReview = async () => {
if (!reviewTarget) return; if (!reviewTarget) return;
if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) { if (reviewOutcome !== "APPROVED" && !reviewRemark.trim()) {
notify.error(t('question.qc.remarkRequired')); notify.error(t("question.qc.remarkRequired"));
return; return;
} }
try { try {
@@ -183,10 +462,10 @@ export function QuestionPage() {
outcome: reviewOutcome, outcome: reviewOutcome,
remark: reviewRemark.trim() || undefined, remark: reviewRemark.trim() || undefined,
}).unwrap(); }).unwrap();
notify.success(t('question.qc.reviewed')); notify.success(t("question.qc.reviewed"));
setReviewTarget(null); setReviewTarget(null);
} catch (error) { } 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; if (!deleteTarget) return;
try { try {
await deleteQ(deleteTarget.id).unwrap(); await deleteQ(deleteTarget.id).unwrap();
notify.success(t('question.deleted')); notify.success(t("question.deleted"));
closeDelete(); closeDelete();
setDeleteTarget(null); setDeleteTarget(null);
} catch (e) { } 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>[] = [ const columns: AdvancedColumn<Question>[] = [
...questionColumns(t, { locale, getCertName }), ...questionColumns(t, { locale, getCertName }),
@@ -210,51 +490,70 @@ export function QuestionPage() {
isSubmittingReview, isSubmittingReview,
onSubmitForApproval: handleSubmitForApproval, onSubmitForApproval: handleSubmitForApproval,
onReview: openReview, onReview: openReview,
onEdit: (q) => { setEditing(q); setShowForm(true); }, onEdit: (q) => {
onDelete: (q) => { setDeleteTarget(q); openDelete(); }, setEditing(q);
setShowForm(true);
},
onDelete: (q) => {
setDeleteTarget(q);
openDelete();
},
}), }),
]; ];
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between" align="flex-end"> <PageHeader
<Title order={2}>{t('question.title')}</Title> title={t("question.title")}
{!showForm && ( noMargin
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly> action={
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm"> !showForm && (
{t('question.addQuestion')} <RequirePermission
anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]}
hideOnly
>
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
size="sm"
>
{t("question.addQuestion")}
</Button> </Button>
</RequirePermission> </RequirePermission>
)} )
</Group> }
/>
{showForm && ( {showForm && (
<QuestionForm <QuestionForm
editing={editing} editing={editing}
certOptions={certOptions} certOptions={certOptions}
isSubmitting={isCreating || isUpdating} isSubmitting={isCreating || isUpdating || isSavingOptions}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={resetForm} onCancel={resetForm}
/> />
)} )}
<Card withBorder padding={0}> <AdvancedTable
<Group p="md" justify="space-between" wrap="wrap" gap="sm"> columns={columns}
<Text fw={600}>{t('question.pool')}</Text> data={page.rows}
title={t("question.pool")}
tableName={t("question.title")}
toolbar={
<Select <Select
placeholder={t('question.filterByCertification')} placeholder={t("question.filterByCertification")}
data={[{ value: '', label: 'All' }, ...certOptions]} data={[{ value: "", label: "All" }, ...certOptions]}
value={certFilter} value={certFilter}
onChange={(v) => { setCertFilter(v ?? null); setPageIndex(0); }} onChange={(v) => {
setCertFilter(v ?? null);
setPageIndex(0);
}}
size="sm" size="sm"
style={{ width: 280 }} style={{ width: 280 }}
clearable clearable
/> />
</Group> }
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('question.title')}
itemCount={page.itemCount} itemCount={page.itemCount}
pageIndex={page.pageIndex} pageIndex={page.pageIndex}
onPageChange={setPageIndex} onPageChange={setPageIndex}
@@ -262,47 +561,75 @@ export function QuestionPage() {
onPageSizeChange={setPageSize} onPageSizeChange={setPageSize}
refresh={refetch} refresh={refetch}
isLoading={isFetching} isLoading={isFetching}
emptyText={t('question.noQuestions')} emptyText={t("question.noQuestions")}
/> />
</Card>
<Modal <Modal
opened={Boolean(reviewTarget)} opened={Boolean(reviewTarget)}
onClose={() => setReviewTarget(null)} onClose={() => setReviewTarget(null)}
title={t('question.qc.reviewTitle')} title={t("question.qc.reviewTitle")}
size="md" size="md"
radius="lg" radius="lg"
> >
<Stack gap="sm"> <Stack gap="sm">
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text> <Text fz="sm" fw={500}>
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text> {reviewTarget?.title?.[locale]}
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content"> </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}`)} {t(`question.qc.${reviewOutcome}`)}
</Badge> </Badge>
<Textarea <Textarea
label={t('question.qc.remark')} label={t("question.qc.remark")}
minRows={3} minRows={3}
autosize autosize
value={reviewRemark} value={reviewRemark}
onChange={(e) => setReviewRemark(e.currentTarget.value)} onChange={(e) => setReviewRemark(e.currentTarget.value)}
required={reviewOutcome !== 'APPROVED'} required={reviewOutcome !== "APPROVED"}
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setReviewTarget(null)}> <Button
{t('question.cancel')} variant="default"
size="sm"
onClick={() => setReviewTarget(null)}
>
{t("question.cancel")}
</Button> </Button>
<Button size="sm" loading={isReviewing} onClick={handleReview}> <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> </Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm"> <Modal
<Text mb="md">{t('question.deleteConfirmText')}</Text> opened={deleteOpened}
onClose={closeDelete}
title={t("question.confirmDelete")}
size="sm"
>
<Text mb="md">{t("question.deleteConfirmText")}</Text>
<ModalFooter> <ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button> <Button variant="default" onClick={closeDelete} size="sm">
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button> {t("question.cancel")}
</Button>
<Button color="red" onClick={handleDelete} size="sm">
{t("question.delete")}
</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
</Stack> </Stack>

View File

@@ -16,6 +16,17 @@ export type QuestionStatus =
| 'REJECTED' | 'REJECTED'
| 'RETIRED'; | '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 { export interface Question {
id: string; id: string;
certificationId: string; certificationId: string;
@@ -32,6 +43,8 @@ export interface Question {
submittedAt: string | null; submittedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** Only populated when explicitly requested (`?i=options`). */
options?: QuestionOption[];
} }
export interface ReviewQuestionPayload { export interface ReviewQuestionPayload {
@@ -64,3 +77,13 @@ export interface UpdateQuestionPayload {
points?: number; points?: number;
isActive?: boolean; 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 { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {Alert, Button, Center, Group, Loader, Modal, Paper, Select, Stack, Text, Textarea} from '@mantine/core';
Alert,
Button,
Center,
Group,
Loader,
Modal,
Paper,
Select,
Stack,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react'; 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 { useDateDisplayer } from '@ema-platform/shared';
import { extractErrorMessage } from '@ema-platform/api'; import { extractErrorMessage } from '@ema-platform/api';
import { import {
@@ -75,12 +62,11 @@ export function ExamAppealsPage() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<div> <PageHeader
<Title order={2}>{t('result.appeals.title')}</Title> title={t('result.appeals.title')}
<Text fz="sm" c="dimmed"> subtitle={t('result.appeals.subtitle')}
{t('result.appeals.subtitle')} noMargin
</Text> />
</div>
<Paper withBorder radius="md"> <Paper withBorder radius="md">
<AdvancedTable<ExamAppeal> <AdvancedTable<ExamAppeal>

View File

@@ -1,5 +1,5 @@
import { Button, Group } from '@mantine/core'; import { ActionIcon, Menu } from '@mantine/core';
import { IconEye, IconTrash } from '@tabler/icons-react'; import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -13,6 +13,7 @@ export function resultActionsColumn(
onQc: (result: Result, action: QcAction) => void; onQc: (result: Result, action: QcAction) => void;
onViewDetail: (result: Result) => void; onViewDetail: (result: Result) => void;
onDelete: (result: Result) => void; onDelete: (result: Result) => void;
onPublish: (result: Result) => void;
}, },
): AdvancedColumn<Result> { ): AdvancedColumn<Result> {
return { return {
@@ -21,18 +22,27 @@ export function resultActionsColumn(
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return ( 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') && ( {(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
<> <>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly> <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')} {t('result.review.moderate')}
</Button> </Menu.Item>
</RequirePermission> </RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly> <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')} {t('result.review.approve')}
</Button> </Menu.Item>
</RequirePermission> </RequirePermission>
</> </>
)} )}
@@ -44,26 +54,34 @@ export function resultActionsColumn(
]} ]}
hideOnly 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')} {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> </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> <RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Button <Menu.Divider />
size="xs" <Menu.Item
variant="subtle"
color="red" color="red"
leftSection={<IconTrash size={13} />} leftSection={<IconTrash size={14} />}
onClick={() => handlers.onDelete(r)} onClick={() => handlers.onDelete(r)}
> >
{t('result.action.delete')} {t('result.action.delete')}
</Button> </Menu.Item>
</RequirePermission> </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 { Badge, Box, Text } from '@mantine/core';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Result, ResultReviewStatus } from '../../types/result'; import type { Result, ResultReviewStatus } from '../../types/result';
export const STATUS_COLOR: Record<string, string> = { export const STATUS_TONE: Record<string, StatusTone> = {
PASSED: 'teal', PASSED: 'success',
FAILED: 'red', FAILED: 'danger',
}; };
/** Where a mark sits in quality control (US-EXAM-011 → 014). */ /** Where a mark sits in quality control (US-EXAM-011 → 014). */
@@ -46,20 +48,22 @@ export function resultColumns(
{ {
header: t('result.columns.status'), header: t('result.columns.status'),
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <StatusBadge
tone={STATUS_TONE[row.original.status]}
label={t(`result.status.${row.original.status}`)}
size="sm" size="sm"
variant="light" variant="light"
color={STATUS_COLOR[row.original.status]}
leftSection={ leftSection={
<Box <Box
w={6} w={6}
h={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 { useState, useCallback, type ElementType } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
Stack,
Title,
Group,
Table,
Badge,
Modal,
Text,
Paper,
Card,
Loader,
Center,
Alert,
Select,
SimpleGrid,
Divider,
Button,
ThemeIcon,
TextInput,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
IconInfoCircle, import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
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 { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
import type { BilingualValue } from '@ema-platform/ui'; import type { BilingualValue } from '@ema-platform/ui';
import { extractErrorMessage, useLocalized } from '@ema-platform/api'; import { extractErrorMessage, useLocalized } from '@ema-platform/api';
@@ -53,7 +22,7 @@ import { useGetExamsQuery } from '../../../exam/api/exam-api';
import { RecordResultModal } from '../../components/RecordResultModal'; import { RecordResultModal } from '../../components/RecordResultModal';
import type { Result, ResultBreakdown } from '../../types/result'; import type { Result, ResultBreakdown } from '../../types/result';
import type { Exam } from '../../../exam/types/exam'; 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'; import { resultActionsColumn, type QcAction } from './actions';
function ResultStat({ function ResultStat({
@@ -126,6 +95,8 @@ export function ResultPage() {
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false); const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null); const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false); 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 [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]); const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
const [detailSaving, setDetailSaving] = useState(false); 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 = () => { const handleDetailClose = () => {
closeDetail(); closeDetail();
setDetailBreakdowns([]); 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 = [ const columns = [
...resultColumns(t, locale, showDate, getExamTitle), ...resultColumns(t, locale, showDate, getExamTitle),
@@ -288,6 +272,7 @@ export function ResultPage() {
onQc: openQc, onQc: openQc,
onViewDetail: viewDetail, onViewDetail: viewDetail,
onDelete: (r) => { setDeleteTarget(r); openDelete(); }, onDelete: (r) => { setDeleteTarget(r); openDelete(); },
onPublish: (r) => { setPublishTarget(r); openPublish(); },
}), }),
]; ];
@@ -295,11 +280,11 @@ export function ResultPage() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between" align="flex-end"> <PageHeader
<div> title={t('result.title')}
<Title order={2}>{t('result.title')}</Title> subtitle={t('result.subtitle')}
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text> noMargin
</div> action={
<Group gap="sm"> <Group gap="sm">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly> <RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
<Button <Button
@@ -314,11 +299,14 @@ export function ResultPage() {
{t('result.review.publish')} {t('result.review.publish')}
</Button> </Button>
</RequirePermission> </RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm"> <Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')} {t('result.record')}
</Button> </Button>
</RequirePermission>
</Group> </Group>
</Group> }
/>
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg"> <SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" /> <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" /> <ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
</SimpleGrid> </SimpleGrid>
<Card withBorder padding={0}> <AdvancedTable
<Group p="md" justify="space-between" wrap="wrap" gap="sm"> columns={columns}
<Text fw={600}>{t('result.section')}</Text> data={page.rows}
<Group gap="sm" wrap="wrap"> title={t('result.section')}
tableName={t('result.title')}
toolbar={
<>
<TextInput <TextInput
placeholder={t('result.search.seafarer')} placeholder={t('result.search.seafarer')}
leftSection={<IconSearch size={15} />} leftSection={<IconSearch size={15} />}
@@ -348,13 +339,8 @@ export function ResultPage() {
style={{ width: 280 }} style={{ width: 280 }}
clearable clearable
/> />
</Group> </>
</Group> }
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('result.title')}
itemCount={page.itemCount} itemCount={page.itemCount}
pageIndex={page.pageIndex} pageIndex={page.pageIndex}
onPageChange={setPageIndex} onPageChange={setPageIndex}
@@ -364,7 +350,6 @@ export function ResultPage() {
isLoading={isFetching} isLoading={isFetching}
emptyText={t('result.noItems')} emptyText={t('result.noItems')}
/> />
</Card>
<Modal <Modal
opened={detailOpened} opened={detailOpened}
@@ -421,9 +406,10 @@ export function ResultPage() {
{t('result.review.derivedStatus')} {t('result.review.derivedStatus')}
</Text> </Text>
<Group gap="xs" mt={4}> <Group gap="xs" mt={4}>
<Badge variant="light" color={STATUS_COLOR[detailResult.status]}> <StatusBadge
{t(`result.status.${detailResult.status}`)} tone={STATUS_TONE[detailResult.status]}
</Badge> label={t(`result.status.${detailResult.status}`)}
/>
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}> <Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
{t(`result.review.${detailResult.reviewStatus}`)} {t(`result.review.${detailResult.reviewStatus}`)}
</Badge> </Badge>
@@ -504,6 +490,13 @@ export function ResultPage() {
<ModalFooter> <ModalFooter>
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button> <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 <Button
onClick={handleDetailSave} onClick={handleDetailSave}
size="sm" size="sm"
@@ -512,6 +505,7 @@ export function ResultPage() {
> >
{t('result.save')} {t('result.save')}
</Button> </Button>
</RequirePermission>
</ModalFooter> </ModalFooter>
</Stack> </Stack>
) : ( ) : (
@@ -574,6 +568,20 @@ export function ResultPage() {
</ModalFooter> </ModalFooter>
</Modal> </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 */} {/* Choose exam, then record */}
<Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg"> <Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg">
<Stack gap="md"> <Stack gap="md">

View File

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

View File

@@ -1,22 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { import {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, SimpleGrid, Stack, Text, Textarea, ThemeIcon, rem} from '@mantine/core';
Alert, import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
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 { import {
SEAFARER_DOCUMENT_KIND_LABELS, SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS, SEAFARER_DOCUMENT_STATUS_COLORS,
@@ -29,26 +14,41 @@ import {
useRejectSeafarerDocumentMutation, useRejectSeafarerDocumentMutation,
useScheduleSeafarerDocumentMutation, useScheduleSeafarerDocumentMutation,
} from '@ema-platform/api'; } 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 { useDateDisplayer } from '@ema-platform/shared';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const; 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 ( return (
<Table.Tr> <div>
<Table.Td w="40%"> <Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
<Text size="xs" c="dimmed"> <Text fz="sm" fw={600} component="div">{value ?? '—'}</Text>
{label} </div>
</Text> );
</Table.Td> }
<Table.Td>
<Text size="sm" component="div"> function SectionCard({
{value ?? '—'} title,
</Text> icon,
</Table.Td> color,
</Table.Tr> 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 Back to queue
</Button> </Button>
<Group justify="space-between" align="flex-start" mb="md"> <PageHeader
<div> title={`${kindLabel}${applicant?.name ?? '—'}`}
<Title order={3}> meta={
{kindLabel} {applicant?.name ?? '—'} <>
</Title>
<Group gap="xs" mt={4}>
<Text size="sm" c="dimmed" ff="monospace"> <Text size="sm" c="dimmed" ff="monospace">
{document.requestNumber} {document.requestNumber}
</Text> </Text>
@@ -140,8 +138,9 @@ export function SeafarerDocumentReviewPage() {
{document.documentNumber} {document.documentNumber}
</Badge> </Badge>
)} )}
</Group> </>
</div> }
action={
<Group gap="xs"> <Group gap="xs">
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && ( {(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly> <RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
@@ -175,7 +174,8 @@ export function SeafarerDocumentReviewPage() {
</RequirePermission> </RequirePermission>
)} )}
</Group> </Group>
</Group> }
/>
{document.status === 'REJECTED' && ( {document.status === 'REJECTED' && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md"> <Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
@@ -183,61 +183,49 @@ export function SeafarerDocumentReviewPage() {
</Alert> </Alert>
)} )}
<Paper withBorder p="lg" radius="md"> <Paper withBorder radius="md" p="lg" mb="md">
<Stack gap="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> <div>
<Text fw={600} size="sm" mb={4}> <Text fw={700} fz="lg" lh={1.2}>{applicant?.name ?? '—'}</Text>
Seafarer <Group gap={6} mt={4}>
</Text> <Text fz="xs" c="dimmed" ff="monospace">{applicant?.seafarerNumber ?? '—'}</Text>
<Table withTableBorder withColumnBorders> {applicant?.registrationId && (
<Table.Tbody> <>
<Row label="Name" value={applicant?.name} /> <Text fz="xs" c="dimmed">·</Text>
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
<Row
label="Registration"
value={
applicant?.registrationId ? (
<Link to={`/seafarer-registrations/${applicant.registrationId}`}> <Link to={`/seafarer-registrations/${applicant.registrationId}`}>
{applicant.registrationNumber} <Text fz="xs" c="blue.6">{applicant.registrationNumber}</Text>
</Link> </Link>
) : ( </>
applicant?.registrationNumber )}
) </Group>
}
/>
</Table.Tbody>
</Table>
</div> </div>
<div> </Group>
<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>
</Paper> </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> <Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
<Stack> <Stack>
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required /> <AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />

View File

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

View File

@@ -1,28 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { import {Alert, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
Alert,
Badge,
Button,
Center,
Container,
Divider,
Group,
Loader,
Modal,
Paper,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react'; import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react';
import { import {
SEAFARER_REGISTRATION_DOCUMENTS, SEAFARER_REGISTRATION_DOCUMENTS,
SEAFARER_REGISTRATION_FIELD_LABELS, SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_SECTIONS, SEAFARER_REGISTRATION_SECTIONS,
SEAFARER_REGISTRATION_STATUS_COLORS, SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS, SEAFARER_REGISTRATION_STATUS_LABELS,
displaySeafarerAnswer, displaySeafarerAnswer,
extractErrorMessage, extractErrorMessage,
@@ -31,7 +15,7 @@ import {
useRejectSeafarerRegistrationMutation, useRejectSeafarerRegistrationMutation,
useRequestSeafarerRegistrationChangesMutation, useRequestSeafarerRegistrationChangesMutation,
} from '@ema-platform/api'; } 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 { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { applicantName } from './SeafarerRegistrationQueuePage'; 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"> <Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/seafarer-registrations')} mb="xs">
Back to queue Back to queue
</Button> </Button>
<Group justify="space-between" align="flex-start" mb="md"> <PageHeader
<div> title={applicantName(registration)}
<Title order={3}>{applicantName(registration)}</Title> meta={
<Group gap="xs" mt={4}> <>
<Text size="sm" c="dimmed" ff="monospace"> <Text size="sm" c="dimmed" ff="monospace">
{registration.registrationNumber} {registration.registrationNumber}
</Text> </Text>
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}> <StatusBadge
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]} tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
</Badge> label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
/>
{registration.seafarerNumber && ( {registration.seafarerNumber && (
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}> <Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
{registration.seafarerNumber} {registration.seafarerNumber}
</Badge> </Badge>
)} )}
</Group> </>
</div> }
action={
<Group gap="xs"> <Group gap="xs">
{canDecide && ( {canDecide && (
<> <>
@@ -147,7 +133,8 @@ export function SeafarerRegistrationReviewPage() {
</> </>
)} )}
</Group> </Group>
</Group> }
/>
{registration.status === 'RESUBMIT_REQUIRED' && ( {registration.status === 'RESUBMIT_REQUIRED' && (
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md"> <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}> <Text fw={600} size="sm" mb={4}>
{section.title} {section.title}
</Text> </Text>
<Table withTableBorder withColumnBorders> <Table variant="vertical">
<Table.Tbody> <Table.Tbody>
{section.fields {section.fields
.filter((f) => f !== 'passportExpiry' || registration.passportNumber) .filter((f) => f !== 'passportExpiry' || registration.passportNumber)
@@ -192,7 +179,7 @@ export function SeafarerRegistrationReviewPage() {
<Text fw={600} size="sm" mb={4}> <Text fw={600} size="sm" mb={4}>
Documents Documents
</Text> </Text>
<Table withTableBorder withColumnBorders> <Table variant="vertical">
<Table.Tbody> <Table.Tbody>
{slots.map((slot) => { {slots.map((slot) => {
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0]; 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 { useState } from 'react';
import { useApiQuery } from '@ema-platform/api'; import { useApiQuery } from '@ema-platform/api';
import { import {ActionIcon, Avatar, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
ActionIcon,
Badge,
Button,
Card,
Collapse,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import { import {
IconBook2, IconBook2,
IconCertificate, IconCertificate,
@@ -32,6 +15,7 @@ import {
IconShieldCheck, IconShieldCheck,
IconUser, IconUser,
IconUsers, IconUsers,
IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -59,72 +43,78 @@ const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired']; const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' }; 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 // 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 }) { function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
if (!sf) return null; if (!sf) return null;
const initials = sf.name.split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase();
return ( return (
<Modal <Modal opened={opened} onClose={onClose} size="xl" radius="lg" padding={0} withCloseButton={false}>
opened={opened} <Stack gap={0}>
onClose={onClose} {/* Header */}
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} {sf.id}</Text></Group>} <Group justify="space-between" wrap="nowrap" p="lg" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
size="xl" <Group gap="md" wrap="nowrap">
radius="lg" <Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>{initials}</Avatar>
> <div>
<Stack gap="lg"> <Text fw={700} fz="lg" lh={1.2}>{sf.name}</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <Group gap={6} mt={4}>
<Paper withBorder radius="md" p="md"> <Text fz="xs" c="dimmed" ff="monospace">{sf.id}</Text>
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text> <Text fz="xs" c="dimmed">·</Text>
<Stack gap={4}> <Text fz="xs" c="dimmed">{sf.rank}</Text>
<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>
</Group> </Group>
</Stack> </div>
</Paper> </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> </SimpleGrid>
{sf.cocCerts.length > 0 && ( {sf.cocCerts.length > 0 && (
<Paper withBorder radius="md" p="md"> <Paper withBorder radius="md" p="md">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text> <Text fz="xs" fw={700} c="dimmed" mb="sm" tt="uppercase">CoC / CoP Certificates</Text>
<Table fz="xs" verticalSpacing="xs"> <Stack gap="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>
{sf.cocCerts.map((c) => ( {sf.cocCerts.map((c) => (
<Table.Tr key={c.no}> <Group key={c.no} justify="space-between" wrap="nowrap" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td> <Group gap="xs" wrap="nowrap">
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td> <ThemeIcon variant="light" color="violet" size={30} radius="md"><IconCertificate size={15} /></ThemeIcon>
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td> <div>
</Table.Tr> <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> </Stack>
</Table>
</Paper> </Paper>
)} )}
</Stack> </Stack>
</Stack>
</Modal> </Modal>
); );
} }
@@ -169,10 +159,11 @@ export function SeafarerRegistryPage() {
return ( return (
<Stack gap="md"> <Stack gap="md">
<div> <PageHeader
<Title order={3}>Seafarer Registry</Title> title="Seafarer Registry"
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text> subtitle="Search and view all registered seafarers, their documents, and certificate status"
</div> noMargin
/>
{/* KPIs */} {/* KPIs */}
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm"> <SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
@@ -264,7 +255,12 @@ export function SeafarerRegistryPage() {
: <Text fz="xs" c="dimmed"></Text>} : <Text fz="xs" c="dimmed"></Text>}
</Table.Td> </Table.Td>
<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>
<Table.Td> <Table.Td>
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}> <ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>

View File

@@ -336,7 +336,7 @@ export function VesselRegistrationFormBuilderPage() {
<IconSettings size={24} /> <IconSettings size={24} />
</ThemeIcon> </ThemeIcon>
<div> <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> <Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
</div> </div>
</Group> </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 { IconShieldCog } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Vessel } from '@ema-platform/api'; import type { Vessel } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const VESSEL_STATUS_COLORS: Record<string, string> = { const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
REGISTERED: 'green', REGISTERED: 'success',
SUSPENDED: 'orange', SUSPENDED: 'pending',
DEREGISTERED: 'gray', DEREGISTERED: 'neutral',
}; };
export const CATEGORY_LABELS: Record<string, string> = { export const CATEGORY_LABELS: Record<string, string> = {
@@ -63,13 +65,12 @@ export function vesselRegistrationQueueColumns(
{ {
header: 'Status', header: 'Status',
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <StatusBadge
tone={VESSEL_STATUS_TONES[row.original.status]}
label={row.original.status}
size="sm" size="sm"
variant="light" variant="light"
color={VESSEL_STATUS_COLORS[row.original.status]} />
>
{row.original.status}
</Badge>
), ),
}, },
{ {

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,11 @@ export const en = {
tagline: 'Control Center', tagline: 'Control Center',
}, },
// Strings only assistive technology encounters.
a11y: {
skipToContent: 'Skip to main content',
},
msg: { msg: {
genericError: 'Something went wrong. Please try again.', genericError: 'Something went wrong. Please try again.',
serverError: 'Server error. Please try again later.', serverError: 'Server error. Please try again later.',
@@ -251,8 +256,10 @@ export const en = {
oral: 'Oral', oral: 'Oral',
essay: 'Essay', essay: 'Essay',
choice: 'Choice', choice: 'Choice',
both: 'Both',
offline: 'Offline', offline: 'Offline',
online: 'Online', online: 'Online',
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
sum: 'Sum', sum: 'Sum',
average: 'Average', average: 'Average',
percentage: 'Percentage', percentage: 'Percentage',
@@ -260,6 +267,11 @@ export const en = {
random: 'Random', random: 'Random',
cuttingPoint: 'Cutting Point (Pass Mark)', cuttingPoint: 'Cutting Point (Pass Mark)',
cuttingPointPlaceholder: 'Minimum score to pass', 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', status: 'Status',
statusPlaceholder: 'Exam status', statusPlaceholder: 'Exam status',
pending: 'Pending', pending: 'Pending',
@@ -296,6 +308,7 @@ export const en = {
formType: { formType: {
ESSAY: 'Essay', ESSAY: 'Essay',
CHOICE: 'Choice', CHOICE: 'Choice',
BOTH: 'Both',
}, },
admin: { admin: {
OFFLINE: 'Offline', OFFLINE: 'Offline',
@@ -323,6 +336,10 @@ export const en = {
retake: 'Retake {{n}}', retake: 'Retake {{n}}',
firstSitting: 'First sitting', firstSitting: 'First sitting',
remarkRequired: 'A reason is required for a withdrawal or a disqualification.', 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: { attendance: {
REGISTERED: 'Not called', REGISTERED: 'Not called',
@@ -368,6 +385,8 @@ export const en = {
randomError: 'Could not draw questions', randomError: 'Could not draw questions',
notEnoughApproved: notEnoughApproved:
'Not enough approved questions in the bank for this subject.', '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: { country: {
@@ -687,6 +706,8 @@ export const en = {
returned: 'Result returned to the examiner', returned: 'Result returned to the examiner',
publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.', publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.',
publishNeedsExam: 'Filter by an exam first to publish its results.', 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: lockedAfterApproval:
'This result is approved and can no longer be edited. Return it to the examiner first.', 'This result is approved and can no longer be edited. Return it to the examiner first.',
originalScore: 'Examiner total', originalScore: 'Examiner total',
@@ -780,6 +801,23 @@ export const en = {
'Only approved items can be placed on an examination paper.', 'Only approved items can be placed on an examination paper.',
error: 'Operation failed', 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: { configuration: {
@@ -980,7 +1018,10 @@ export const en = {
requestAdjustment: 'Request adjustment', requestAdjustment: 'Request adjustment',
reject: 'Reject', reject: 'Reject',
scheduleExam: 'Schedule exam', scheduleExam: 'Schedule exam',
recordExamOutcome: 'Record exam outcome',
confirmPayment: 'Confirm payment', confirmPayment: 'Confirm payment',
scheduleIssuance: 'Schedule pickup',
issueCertificate: 'Issue certificate',
print: 'Print dossier', print: 'Print dossier',
copyLink: 'Copy link', copyLink: 'Copy link',
downloadDocuments: 'Download all documents', downloadDocuments: 'Download all documents',

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react'; 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 { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 type { NavItem, NavSection } from '@ema-platform/ui';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { AppTopNav, filterByPermissions } 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 { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
import { usePermissions } from '@ema-platform/auth'; import { usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config'; import { SUPPORTED_LANGUAGES } from '../i18n/config';
@@ -26,6 +27,14 @@ const BADGE_POLL_MS = 60_000;
const HEADER_HEIGHT = 116; 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 * 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, * 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"; const isSidebar = layoutMode === "sidebar";
return ( 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 <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={ navbar={
isSidebar isSidebar
? { ? {
@@ -162,13 +177,28 @@ export function BackofficeLayout() {
<AppShell.Header <AppShell.Header
style={{ style={{
background: "var(--mantine-color-body)", background: "var(--mantine-color-body)",
borderBottom: "1px solid var(--mantine-color-gray-2)", borderBottom: "1px solid var(--mantine-color-default-border)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
> >
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}> <div
style={{ height: 74, flexShrink: 0, padding: `0 ${CHROME_PAD_X}px` }}
>
<AppHeader <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} onToggleNav={toggleNav}
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav} onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
navOpened={opened} navOpened={opened}
@@ -182,13 +212,14 @@ export function BackofficeLayout() {
</div> </div>
{!isSidebar && ( {!isSidebar && (
<div <Box
visibleFrom="sm"
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '0 32px', padding: `0 ${NAV_STRIP_PAD_X}px`,
height: 42, height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)', borderTop: '1px solid var(--mantine-color-default-border)',
flexShrink: 0, flexShrink: 0,
}} }}
> >
@@ -199,7 +230,7 @@ export function BackofficeLayout() {
activePath={location.pathname} activePath={location.pathname}
onNavigate={go} onNavigate={go}
/> />
</div> </Box>
)} )}
</AppShell.Header> </AppShell.Header>
@@ -210,7 +241,7 @@ export function BackofficeLayout() {
overflow: "hidden", overflow: "hidden",
transition: "width 200ms ease", transition: "width 200ms ease",
background: "var(--mantine-color-body)", background: "var(--mantine-color-body)",
borderRight: "1px solid var(--mantine-color-gray-2)", borderRight: "1px solid var(--mantine-color-default-border)",
}} }}
> >
<AppSidebar <AppSidebar
@@ -226,7 +257,7 @@ export function BackofficeLayout() {
</AppShell.Navbar> </AppShell.Navbar>
)} )}
<AppShell.Main> <AppShell.Main id={MAIN_CONTENT_ID}>
<div key={location.pathname} className="ema-page-enter"> <div key={location.pathname} className="ema-page-enter">
<Outlet /> <Outlet />
</div> </div>
@@ -238,7 +269,6 @@ export function BackofficeLayout() {
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside {/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
click) instead of AppShell's full-width mobile navbar. Mirrors the click) instead of AppShell's full-width mobile navbar. Mirrors the
landing page's mobile menu. */} landing page's mobile menu. */}
{isSidebar && (
<Drawer <Drawer
opened={opened} opened={opened}
onClose={closeNav} onClose={closeNav}
@@ -261,7 +291,7 @@ export function BackofficeLayout() {
brandLogo={<BrandMark size={32} />} brandLogo={<BrandMark size={32} />}
/> />
</Drawer> </Drawer>
)}
</AppShell> </AppShell>
</>
); );
} }

View File

@@ -12,6 +12,7 @@ import {
RequirePermission, RequirePermission,
LICENSE_PERMISSIONS as P, LICENSE_PERMISSIONS as P,
} from '@ema-platform/auth'; } from '@ema-platform/auth';
import { ThemeGallery } from '@ema-platform/ui';
import { AuthLayout } from '../layouts/AuthLayout'; import { AuthLayout } from '../layouts/AuthLayout';
import { BackofficeLayout } from '../layouts/BackofficeLayout'; import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { ProtectedRoute } from './ProtectedRoute'; import { ProtectedRoute } from './ProtectedRoute';
@@ -68,6 +69,9 @@ const router = createBrowserRouter([
], ],
}, },
{ path: '/um/*', element: <UserManagementPage /> }, { 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: '/', element: <LandingRoute /> },
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> }, { 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/notifications/styles.css';
import '@mantine/dates/styles.css'; import '@mantine/dates/styles.css';
import '@mantine/spotlight/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 './styles.css';
import './app/i18n/config'; import './app/i18n/config';
import { App } from './app/app'; 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'); /* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
@tailwind base; Without it every Amharic string in the app renders in whatever the OS
@tailwind components; happens to substitute — different on Windows, macOS and Android. */
@tailwind utilities; @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; } *, *::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 expect(page.getByLabel('National ID (Fayda) Number')).toHaveValue('FYD1234567890');
await page.getByRole('button', { name: /^continue$/i }).click(); 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 page.getByLabel('Place of Birth').fill('Addis Ababa');
await pick(page, 'Department', /deck/i); await pick(page, 'Department', /deck/i);
await pick(page, 'City', /addis ababa/i); await pick(page, 'City', /addis ababa/i);
@@ -356,15 +356,15 @@ test.describe('seafarer registration', () => {
await pick(page, 'Eye Colour', /brown/i); await pick(page, 'Eye Colour', /brown/i);
await page.getByLabel('Height (cm)').fill('172'); await page.getByLabel('Height (cm)').fill('172');
await page.getByLabel('Weight (kg)').fill('68'); 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(); 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('Full Name').fill('Almaz Tesfaye');
await page.getByLabel('Relationship').fill('Sister'); await page.getByLabel('Relationship').fill('Sister');
await page.getByLabel('Phone Number').fill('+251911222333'); 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(); await page.getByRole('button', { name: /^continue$/i }).click();
// Step 4 — Documents: all four required slots show as uploaded. // 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 { useRef, useState } from 'react';
import { useApiQuery } from '@ema-platform/api'; import { useApiQuery } from '@ema-platform/api';
import { import {
@@ -30,7 +31,7 @@ import {
IconTrash, IconTrash,
IconUpload, IconUpload,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui'; import { StatusBadge, notify } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -45,11 +46,11 @@ interface BSTRecord {
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification'; status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
} }
const STATUS_COLOR: Record<string, string> = { const STATUS_TONE: Record<string, StatusTone> = {
Valid: 'teal', Valid: 'success',
Expiring: 'orange', Expiring: 'pending',
Expired: 'red', Expired: 'danger',
'Pending Verification': 'yellow', 'Pending Verification': 'warning',
}; };
/** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */ /** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */
@@ -289,14 +290,13 @@ export function BasicSafetyTrainingPage() {
</Text> </Text>
</div> </div>
{record && ( {record && (
<Badge <StatusBadge
tone={STATUS_TONE[record.status]}
label={record.status}
size="lg" size="lg"
variant="light" variant="light"
color={STATUS_COLOR[record.status]}
leftSection={<IconShieldCheck size={14} />} leftSection={<IconShieldCheck size={14} />}
> />
{record.status}
</Badge>
)} )}
</Group> </Group>
@@ -321,7 +321,7 @@ export function BasicSafetyTrainingPage() {
<Paper withBorder radius="lg" p="lg"> <Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm"> <Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group 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} /> <IconShieldCheck size={24} />
</ThemeIcon> </ThemeIcon>
<div> <div>
@@ -329,9 +329,7 @@ export function BasicSafetyTrainingPage() {
<Text fz="xs" c="dimmed">Combined certificate all 5 STCW components</Text> <Text fz="xs" c="dimmed">Combined certificate all 5 STCW components</Text>
</div> </div>
</Group> </Group>
<Badge color={STATUS_COLOR[record.status]} variant="light"> <StatusBadge tone={STATUS_TONE[record.status]} label={record.status} variant="light" />
{record.status}
</Badge>
</Group> </Group>
<Stack gap="xs" mb="md"> <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 { 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 { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual } from '@ema-platform/api'; import type { Bilingual } from '@ema-platform/api';
@@ -20,6 +20,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
DISQUALIFIED: 'red', DISQUALIFIED: 'red',
}; };
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
export function registrationColumns( export function registrationColumns(
t: TFunction, t: TFunction,
deps: { deps: {
@@ -28,6 +30,7 @@ export function registrationColumns(
localized: (value: Bilingual | undefined) => string; localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string; showDate: (value: string | null | undefined) => string;
onDownloadSlip: (registration: MyRegistration) => void; onDownloadSlip: (registration: MyRegistration) => void;
onStartExam: (registration: MyRegistration) => void;
}, },
): AdvancedColumn<MyRegistration>[] { ): AdvancedColumn<MyRegistration>[] {
return [ return [
@@ -91,6 +94,44 @@ export function registrationColumns(
</Button> </Button>
) : null, ) : 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 { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Badge, Badge,
Button, Button,
@@ -53,6 +54,8 @@ export interface MyRegistration {
attemptNumber: number; attemptNumber: number;
attendanceStatus: AttendanceStatus; attendanceStatus: AttendanceStatus;
exam?: OpenExam; exam?: OpenExam;
/** The candidate's online sitting, when one has been started. */
attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
} }
export interface MyResult { export interface MyResult {
@@ -80,6 +83,7 @@ export interface MyAppeal {
*/ */
export function ExamsPage() { export function ExamsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const localized = useLocalized(); const localized = useLocalized();
const [appealFor, setAppealFor] = useState<MyResult | null>(null); const [appealFor, setAppealFor] = useState<MyResult | null>(null);
@@ -245,6 +249,7 @@ export function ExamsPage() {
localized, localized,
showDate, showDate,
onDownloadSlip: downloadSlip, onDownloadSlip: downloadSlip,
onStartExam: (registration) => navigate(`/exams/${registration.exam?.id}/take`),
})} })}
data={pagedRegistrations.rows} data={pagedRegistrations.rows}
itemCount={pagedRegistrations.itemCount} 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) { export function ApplicantDetailsStep(p: StepProps) {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
@@ -138,6 +138,29 @@ export function ApplicantDetailsStep(p: StepProps) {
/> />
</Grid> </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 /> <Divider />
<SectionTitle <SectionTitle
title="Medical Certificate" title="Medical Certificate"
@@ -157,17 +180,3 @@ export function ApplicantDetailsStep(p: StepProps) {
</Stack> </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 { useEffect, useRef, useState } from 'react';
import { import {
Alert, Alert,
Badge,
Button, Button,
Center, Center,
Container, Container,
@@ -20,7 +19,7 @@ import { notifications } from '@mantine/notifications';
import { import {
PHYSICAL_BOUNDS, PHYSICAL_BOUNDS,
SEAFARER_REGISTRATION_FIELD_LABELS, SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_STATUS_COLORS, SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS, SEAFARER_REGISTRATION_STATUS_LABELS,
extractErrorMessage, extractErrorMessage,
extractValidationIssues, extractValidationIssues,
@@ -33,7 +32,7 @@ import {
type SeafarerRegistration, type SeafarerRegistration,
type ValidationIssue, type ValidationIssue,
} from '@ema-platform/api'; } 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 { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks'; import { useAppSelector } from '../../../store/hooks';
import { CheckboxField, type AnswerKey } from '../components/fields'; import { CheckboxField, type AnswerKey } from '../components/fields';
@@ -41,16 +40,19 @@ import { ApplicantDetailsStep, EmergencyContactStep, IdentityDetailsStep } from
import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments'; import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments';
import { RegistrationSummary } from '../components/RegistrationSummary'; 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. */ /** Which answers each step must have before "Continue" — mirrors the API's submission check. */
const REQUIRED_BY_STEP: AnswerKey[][] = [ const REQUIRED_BY_STEP: AnswerKey[][] = [
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'], ['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
[ ['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
'placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg', ['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
'medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate',
],
[],
[], [],
['declarationAccepted'], ['declarationAccepted'],
]; ];
@@ -123,7 +125,7 @@ export function SeafarerRegistrationPage() {
const registration = data?.registration ?? null; const registration = data?.registration ?? null;
const [start] = useStartSeafarerRegistrationMutation(); const [start] = useStartSeafarerRegistrationMutation();
const [save] = useSaveSeafarerRegistrationMutation(); const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation(); const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
const [startError, setStartError] = useState<string | null>(null); const [startError, setStartError] = useState<string | null>(null);
const started = useRef(false); const started = useRef(false);
@@ -215,12 +217,16 @@ export function SeafarerRegistrationPage() {
} }
} }
setErrors(found); setErrors(found);
const count = Object.keys(found).length; const missingKeys = Object.keys(found) as AnswerKey[];
if (count) { 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({ notifications.show({
color: 'red', color: 'red',
title: 'Incomplete', title: missingKeys.length > 1 ? 'Some details are missing' : 'One detail is missing',
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`, message: `${names.join(', ')}.`,
}); });
return false; return false;
} }
@@ -307,9 +313,10 @@ export function SeafarerRegistrationPage() {
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{registration.registrationNumber} {registration.registrationNumber}
</Text> </Text>
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}> <StatusBadge
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]} tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
</Badge> label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
/>
</Group> </Group>
</div> </div>
{showSummary && !readOnly && ( {showSummary && !readOnly && (
@@ -362,8 +369,8 @@ export function SeafarerRegistrationPage() {
{!showSummary && ( {!showSummary && (
<Paper withBorder p="lg" radius="md"> <Paper withBorder p="lg" radius="md">
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg"> <Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
{STEPS.map((label) => ( {STEPS.map((step) => (
<Stepper.Step key={label} label={label} /> <Stepper.Step key={step.label} label={step.label} description={step.description} />
))} ))}
</Stepper> </Stepper>
@@ -408,9 +415,11 @@ export function SeafarerRegistrationPage() {
Back Back
</Button> </Button>
{active < STEPS.length - 1 ? ( {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'} {isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
</Button> </Button>
)} )}

View File

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

View File

@@ -164,6 +164,13 @@ function EvidenceField({
// ---------------------------------------------------------------- sea service // ---------------------------------------------------------------- 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 = { const EMPTY_SEA_SERVICE = {
vesselName: '', vesselName: '',
imoNumber: '', 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 = const valid =
form.vesselName.trim().length > 1 && form.vesselName.trim().length > 1 &&
form.rank.trim().length > 1 && form.rank.trim().length > 1 &&
form.engagementDate && form.engagementDate &&
form.dischargeDate && form.dischargeDate &&
form.engagementDate < form.dischargeDate; !dateError;
// Shown under the date pickers as they are filled: the seafarer sees what // Shown under the date pickers as they are filled: the seafarer sees what
// the engagement is worth before saving it. // the engagement is worth before saving it.
@@ -408,6 +430,7 @@ function SeaServiceTab() {
onChange={(val) => onChange={(val) =>
setForm({ ...form, engagementDate: val }) setForm({ ...form, engagementDate: val })
} }
maxDate={form.dischargeDate || today}
dateFormat="date" dateFormat="date"
/> />
<AmharicDatePicker <AmharicDatePicker
@@ -417,21 +440,20 @@ function SeaServiceTab() {
onChange={(val) => onChange={(val) =>
setForm({ ...form, dischargeDate: val }) setForm({ ...form, dischargeDate: val })
} }
minDate={form.engagementDate || undefined}
maxDate={today}
dateFormat="date" dateFormat="date"
/> />
</Group> </Group>
{form.engagementDate && form.dischargeDate && ( {(dateError || (form.engagementDate && form.dischargeDate)) && (
<Alert <Alert
variant="light" variant="light"
color={formDays === null ? 'red' : 'teal'} color={dateError ? 'red' : 'teal'}
icon={<IconInfoCircle size={16} />} icon={<IconInfoCircle size={16} />}
py={6} py={6}
> >
{formDays === null {dateError ??
? t('seaRecords.seaService.dateOrder', { t('seaRecords.seaService.daysServed', {
defaultValue: 'Discharge date must be after the engagement date.',
})
: t('seaRecords.seaService.daysServed', {
days: formDays, days: formDays,
defaultValue: 'Days served on this engagement: {{days}} (both days counted)', defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
})} })}
@@ -581,10 +603,12 @@ function MedicalTab() {
} }
}; };
const today = todayKey();
const valid = const valid =
form.issuerName.trim().length > 1 && form.issuerName.trim().length > 1 &&
form.issueDate && form.issueDate &&
form.expiryDate && form.expiryDate &&
form.issueDate <= today &&
form.issueDate < form.expiryDate; form.issueDate < form.expiryDate;
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 }); const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
@@ -664,6 +688,7 @@ function MedicalTab() {
required required
value={form.issueDate} value={form.issueDate}
onChange={(val) => setForm({ ...form, issueDate: val })} onChange={(val) => setForm({ ...form, issueDate: val })}
maxDate={today}
dateFormat="date" dateFormat="date"
/> />
<AmharicDatePicker <AmharicDatePicker
@@ -671,6 +696,7 @@ function MedicalTab() {
required required
value={form.expiryDate} value={form.expiryDate}
onChange={(val) => setForm({ ...form, expiryDate: val })} onChange={(val) => setForm({ ...form, expiryDate: val })}
minDate={form.issueDate || undefined}
dateFormat="date" dateFormat="date"
/> />
</Group> </Group>

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api'; import { useApiMutation } from '@ema-platform/api';
import { import {
Alert, Alert,
Badge,
Box, Box,
Button, Button,
Card, Card,
@@ -31,7 +31,7 @@ import {
IconTransferIn, IconTransferIn,
IconUser, IconUser,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notify, PhoneInput } from '@ema-platform/ui'; import { StatusBadge, notify, PhoneInput } from '@ema-platform/ui';
import { isValidPhoneNumber } from 'libphonenumber-js'; import { isValidPhoneNumber } from 'libphonenumber-js';
// Minimal vessel type for the approved vessel list // Minimal vessel type for the approved vessel list
@@ -117,11 +117,11 @@ const TRANSFER_REASONS = [
'Other', 'Other',
]; ];
const STATUS_COLOR: Record<string, string> = { const STATUS_TONE: Record<string, StatusTone> = {
Pending: 'gray', Pending: 'neutral',
'Under Review': 'yellow', 'Under Review': 'warning',
Approved: 'teal', Approved: 'success',
Rejected: 'red', Rejected: 'danger',
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -140,7 +140,11 @@ function TransferCard({ req }: { req: OwnershipTransferRequest }) {
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text> <Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
</div> </div>
</Group> </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> </Group>
<Divider my="xs" /> <Divider my="xs" />
<SimpleGrid cols={2} spacing="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 { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Alert, Alert,
Badge,
Button, Button,
Card, Card,
Divider, Divider,
@@ -27,7 +27,7 @@ import {
IconClockHour4, IconClockHour4,
IconTransferIn, IconTransferIn,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { AdvancedTable } from '@ema-platform/ui'; import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
import { inFlightColumns } from '../inFlightColumns'; import { inFlightColumns } from '../inFlightColumns';
import { import {
TERMINAL_STATUSES, TERMINAL_STATUSES,
@@ -68,12 +68,12 @@ interface VesselRegistration {
expiryDate: string | null; expiryDate: string | null;
} }
const STATUS_COLOR: Record<string, string> = { const STATUS_TONE: Record<string, StatusTone> = {
Pending: 'gray', Pending: 'neutral',
'Under Review': 'yellow', 'Under Review': 'warning',
Approved: 'teal', Approved: 'success',
Rejected: 'red', Rejected: 'danger',
'Correction Required': 'orange', 'Correction Required': 'pending',
}; };
// Inland vessel certificates (1) // Inland vessel certificates (1)
@@ -281,9 +281,12 @@ export function VesselRegistrationPage() {
<Text fz="xs" c="dimmed">{registration.id}</Text> <Text fz="xs" c="dimmed">{registration.id}</Text>
</div> </div>
</Group> </Group>
<Badge color={STATUS_COLOR[registration.status] ?? 'gray'} size="lg" variant="light"> <StatusBadge
{registration.status} tone={STATUS_TONE[registration.status] ?? 'neutral'}
</Badge> label={registration.status}
size="lg"
variant="light"
/>
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md"> <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 { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -30,10 +32,10 @@ import {
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER'; const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
const VESSEL_STATUS_COLORS: Record<string, string> = { const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
REGISTERED: 'green', REGISTERED: 'success',
SUSPENDED: 'orange', SUSPENDED: 'pending',
DEREGISTERED: 'gray', DEREGISTERED: 'neutral',
}; };
/** /**
@@ -194,12 +196,11 @@ export function VesselTransferPage() {
{categoryLabels[vessel.category] ?? vessel.category} {categoryLabels[vessel.category] ?? vessel.category}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge <StatusBadge
tone={VESSEL_STATUS_TONES[vessel.status]}
label={vessel.status}
size="sm" size="sm"
color={VESSEL_STATUS_COLORS[vessel.status]} />
>
{vessel.status}
</Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group justify="flex-end"> <Group justify="flex-end">

View File

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

View File

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

View File

@@ -26,6 +26,8 @@ import {
AppHeader, AppHeader,
AppSidebar, AppSidebar,
filterByPermissions, filterByPermissions,
SkipLink,
MAIN_CONTENT_ID,
} from "@ema-platform/ui"; } from "@ema-platform/ui";
import type { NavItem } from "@ema-platform/ui"; import type { NavItem } from "@ema-platform/ui";
import { import {
@@ -281,6 +283,10 @@ export function PortalLayout() {
: "?"; : "?";
return ( 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 <AppShell
header={{ height: 74 }} header={{ height: 74 }}
navbar={{ navbar={{
@@ -335,7 +341,7 @@ export function PortalLayout() {
/> />
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main> <AppShell.Main id={MAIN_CONTENT_ID}>
<div key={location.pathname} className="ema-page-enter"> <div key={location.pathname} className="ema-page-enter">
<Outlet /> <Outlet />
</div> </div>
@@ -364,5 +370,6 @@ export function PortalLayout() {
/> />
</Drawer> </Drawer>
</AppShell> </AppShell>
</>
); );
} }

View File

@@ -1,4 +1,5 @@
import { createBrowserRouter, Navigate } from "react-router-dom"; import { createBrowserRouter, Navigate } from "react-router-dom";
import { ThemeGallery } from "@ema-platform/ui";
import { PortalLayout } from "./layouts/PortalLayout"; import { PortalLayout } from "./layouts/PortalLayout";
import { ProtectedRoute } from "./components/ProtectedRoute"; import { ProtectedRoute } from "./components/ProtectedRoute";
import { LandingRoute } from "./components/LandingRoute"; 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 { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage"; import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
import { ExamsPage } from "./features/exams/pages/ExamsPage"; import { ExamsPage } from "./features/exams/pages/ExamsPage";
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
// Phase 1 pages // Phase 1 pages
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage"; import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
@@ -57,6 +59,10 @@ export const router = createBrowserRouter([
// Public landing page — institutional overview + role-based entry points. // Public landing page — institutional overview + role-based entry points.
{ path: "/", element: <LandingRoute /> }, { 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 // Public auth pages
{ path: "/login", element: <LoginPage /> }, { path: "/login", element: <LoginPage /> },
{ path: "/signup", element: <SignupPage /> }, { path: "/signup", element: <SignupPage /> },
@@ -208,6 +214,14 @@ export const router = createBrowserRouter([
</RequirePermission> </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 public-facing registry was a hardcoded mock and does not belong in
// the applicant portal; officers browse seafarers in the backoffice. // 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 /* Portal global styles — loaded after Mantine's CSS, no Tailwind preflight so
it never fights Mantine's base styles. */ 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 { :root {
--ema-surface-light: #f5f8fc; --ema-surface-light: #f5f8fc;
--ema-surface-dark: #0e1521; --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 { body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;

View File

@@ -1,116 +1,13 @@
import { /**
createTheme, * The portal theme now lives in `@ema-platform/shared`, alongside the
rem, * backoffice theme and the base they share.
type MantineColorsTuple, *
} from '@mantine/core'; * It moved because the two themes had diverged into unrelated definitions —
* this one carried a full type scale, radius scale and component defaults that
// ---- Coastal Modern palette ---------------------------------------------- * the backoffice simply lacked. Sharing the structure fixes the backoffice
// Portal-only theme. Lives here (not in @ema-platform/shared) so the backoffice * without changing the portal.
// is unaffected. *
* This re-export is kept so the portal's MantineThemeProvider import stays
const emaPrimary: MantineColorsTuple = [ * valid. Prefer importing from `@ema-platform/shared` directly in new code.
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9', */
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2', export { portalTheme } from '@ema-platform/shared';
];
// 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%)',
},
});

View File

@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import '@mantine/dates/styles.css'; import '@mantine/dates/styles.css';
import '@mantine/notifications/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/theme/portal.css';
import './app/i18n/config'; 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