Merge remote-tracking branch 'origin/dev' into feature/exam-attempt-domain

This commit is contained in:
mihretue
2026-08-24 11:25:31 +00:00
116 changed files with 5018 additions and 2955 deletions

View File

@@ -33,14 +33,15 @@ jobs:
BUILD_ENV_FILE: ${{ matrix.build_env_file }}
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync environment from server
- name: Sync environment from Env Manager App
run: |
chmod +x scripts/deploy/*.sh
./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}"
./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}"
- name: Set compose project name
run: |

4
.gitignore vendored
View File

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

View File

@@ -1,18 +1,19 @@
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY local-packages/ ./local-packages/
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm install --legacy-peer-deps
pnpm install --frozen-lockfile
FROM deps AS base
COPY . .
FROM base AS portal-build
RUN npm run build:portal
RUN pnpm run build:portal
FROM base AS backoffice-build
RUN npm run build:backoffice
RUN pnpm run build:backoffice
FROM nginx:1.29-alpine AS portal
COPY --from=portal-build /app/dist/apps/portal /usr/share/nginx/html

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import {
IconCertificate,
IconHash,
IconInfoCircle,
IconAnchor,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
@@ -36,6 +37,7 @@ import {
import { LocationPage } from "../../../location/pages/LocationPage";
import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab";
import { RankDepartmentTab } from "./RankDepartmentTab";
import {
useGetOrganizationsQuery,
useGetProfessionsQuery,
@@ -46,6 +48,7 @@ import {
import type { Profession } from "../../types/configuration";
import { professionColumns } from "./columns";
import { professionActionsColumn } from "./actions";
import { PageHeader } from '@ema-platform/ui';
interface ProfFormValues {
nameEn: string;
@@ -378,7 +381,7 @@ export function ConfigurationPage() {
return (
<Stack gap="lg">
<Title order={2}>{t("configuration.title")}</Title>
<PageHeader title={t("configuration.title")} noMargin />
<Tabs defaultValue="professions">
<Tabs.List>
@@ -400,6 +403,9 @@ export function ConfigurationPage() {
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
{t("numberFormat.title", "Number Formats")}
</Tabs.Tab>
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
{t("configuration.ranksTab", "Ranks & Departments")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="professions" pt="md">
@@ -417,6 +423,10 @@ export function ConfigurationPage() {
<Tabs.Panel value="numberFormats" pt="md">
<NumberFormatTab />
</Tabs.Panel>
<Tabs.Panel value="ranks" pt="md">
<RankDepartmentTab />
</Tabs.Panel>
</Tabs>
</Stack>
);

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { type StatusTone } from '@ema-platform/shared';
import { useState, useEffect, useRef, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
@@ -39,7 +40,7 @@ import {
IconCheck,
IconX,
} from '@tabler/icons-react';
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import {
@@ -57,16 +58,20 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
import { PageLoader } from '@ema-platform/ui';
import type { ExamStatus, QuestionBrief } from '../types/exam';
const STATUS_COLOR: Record<string, string> = {
PENDING: "gray",
ACTIVE: "blue",
COMPLETED: "teal",
CANCELLED: "red",
POSTPONED: "orange",
PUBLISHED: "green",
const STATUS_TONE: Record<string, StatusTone> = {
PENDING: 'neutral',
ACTIVE: 'info',
COMPLETED: 'success',
CANCELLED: 'danger',
POSTPONED: 'pending',
PUBLISHED: 'success',
};
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
const FORM_LABEL: Record<string, string> = {
ESSAY: "Essay",
CHOICE: "Choice",
BOTH: "Both",
};
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
const ADMIN_LABEL: Record<string, string> = {
OFFLINE: "Offline",
@@ -341,7 +346,7 @@ export function ExamDetailPage() {
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>{exam.title[locale]}</Title>
<Title order={2}>{exam.title[locale]}</Title>
</div>
</Group>
<Group gap="sm">
@@ -364,14 +369,13 @@ export function ExamDetailPage() {
</Group>
{/* Status badge */}
<Badge
<StatusBadge
tone={STATUS_TONE[exam.status]}
label={t(`exam.status.${exam.status}`)}
size="lg"
variant="light"
color={STATUS_COLOR[exam.status]}
style={{ width: "fit-content" }}
>
{t(`exam.status.${exam.status}`)}
</Badge>
/>
{/* Exam Info */}
<Paper withBorder radius="lg" p="lg">

View File

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

View File

@@ -2,7 +2,6 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Stack,
Title,
Group,
Button,
Modal,
@@ -10,7 +9,6 @@ import {
TextInput,
Textarea,
Card,
Alert,
Select,
NumberInput,
Tabs,
@@ -26,6 +24,7 @@ import {
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
import { RANK_KEY_OPTIONS } from "../../../certification/types/certification";
import {
useGetExamsQuery,
useCreateExamMutation,
@@ -35,6 +34,7 @@ import {
import type { Exam } from "../../types/exam";
import { examColumns } from "./columns";
import { examActionsColumn } from "./actions";
import { ErrorState, PageHeader } from '@ema-platform/ui';
function ExamForm({
editing,
@@ -249,6 +249,7 @@ function ExamForm({
data={[
{ value: "ESSAY", label: t("exam.form.essay") },
{ value: "CHOICE", label: t("exam.form.choice") },
{ value: "BOTH", label: t("exam.form.both") },
]}
value={form}
onChange={setForm}
@@ -384,9 +385,18 @@ export function ExamPage() {
const [statusOpened, { open: openStatus, close: closeStatus }] =
useDisclosure(false);
// Rank in the label: an exam inherits its STCW rank from the certification
// it is created under (Certification.rankKey), so the officer sees which
// rank a sitting will serve at the moment they pick the subject.
const certOptions = certifications
.filter((c) => c.isActive)
.map((c) => ({ value: c.id, label: c.name[locale] }));
.map((c) => {
const rank = RANK_KEY_OPTIONS.find((r) => r.value === c.rankKey)?.label;
return {
value: c.id,
label: rank ? `${c.name[locale]}${rank}` : c.name[locale],
};
});
const getCertName = (id: string) =>
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
@@ -461,13 +471,7 @@ export function ExamPage() {
};
if (isError)
return (
<Alert
icon={<IconInfoCircle size={16} />}
color="red"
title={t("exam.loadError")}
/>
);
return <ErrorState title={t("exam.loadError")} onRetry={refetch} />;
const columns = [
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
@@ -494,14 +498,12 @@ export function ExamPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t("exam.title")}</Title>
<Text fz="sm" c="dimmed">
{t("exam.subtitle")}
</Text>
</div>
{!showForm && (
<PageHeader
title={t("exam.title")}
subtitle={t("exam.subtitle")}
noMargin
action={
!showForm && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
@@ -512,8 +514,9 @@ export function ExamPage() {
{t("exam.add")}
</Button>
</RequirePermission>
)}
</Group>
)
}
/>
{showForm && (
<ExamForm

View File

@@ -3,6 +3,8 @@ import type { EstimatedTime } from "../../question/types/question";
import type { QuestionForm } from "../../question/types/question";
export type { QuestionForm };
export type ExamForm = QuestionForm | "BOTH";
export type ExamType = "WRITTEN" | "ORAL";
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
@@ -39,7 +41,7 @@ export interface Exam {
date: string;
givenTime: EstimatedTime | null;
type: ExamType;
form: QuestionForm;
form: ExamForm;
venue: string;
administrationMethod: ExamAdministrationMethod;
evaluationMethod: ExamEvaluationMethod;
@@ -63,7 +65,7 @@ export interface CreateExamPayload {
date: string;
givenTime: EstimatedTime;
type: ExamType;
form: QuestionForm;
form: ExamForm;
venue: string;
administrationMethod: ExamAdministrationMethod;
evaluationMethod: ExamEvaluationMethod;
@@ -79,7 +81,7 @@ export interface UpdateExamPayload {
date?: string;
givenTime?: EstimatedTime;
type?: ExamType;
form?: QuestionForm;
form?: ExamForm;
venue?: string;
administrationMethod?: ExamAdministrationMethod;
evaluationMethod?: ExamEvaluationMethod;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,17 @@
import { useState } from 'react';
import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Modal, Select, Stack, Text } from '@mantine/core';
import { IconCalendarEvent } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui';
import { useGetExamsQuery } from '../../exam/api/exam-api';
import { useGetEligibleExamsQuery } from '@ema-platform/api';
interface Props {
opened: boolean;
applicationId: string;
applicantName: string;
loading: boolean;
onClose: () => void;
onConfirm: (payload: {
examId: string;
admissionNumber?: string;
examDate?: string;
}) => void;
onConfirm: (payload: { examId: string; examDate?: string }) => void;
}
/**
@@ -22,33 +19,34 @@ interface Props {
*
* Sessions are picked from the exam calendar rather than typed, because the
* candidate joins a scheduled sitting — this is an assignment, not the creation
* of a per-candidate appointment.
* of a per-candidate appointment. Scoped to sittings whose certification
* matches this application's rank, so a Chief Mate candidate cannot be seated
* into an OOW Deck sitting by accident.
*/
export function ScheduleExamModal({
opened,
applicationId,
applicantName,
loading,
onClose,
onConfirm,
}: Props) {
const { t } = useTranslation();
const { data: exams, isLoading } = useGetExamsQuery(undefined, { skip: !opened });
const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
const [examId, setExamId] = useState<string | null>(null);
const [admissionNumber, setAdmissionNumber] = useState('');
const options = (exams?.items ?? []).map((exam) => ({
const options = (exams ?? []).map((exam) => ({
value: exam.id,
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
.filter(Boolean)
.join(' — '),
}));
const selected = exams?.items?.find((exam) => exam.id === examId);
const selected = exams?.find((exam) => exam.id === examId);
function confirm() {
if (!examId) return;
onConfirm({
examId,
admissionNumber: admissionNumber.trim() || undefined,
examDate: selected?.date ? String(selected.date) : undefined,
});
}
@@ -72,7 +70,7 @@ export function ScheduleExamModal({
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
{t(
'review.scheduleExam.noSessions',
'No exam sessions exist yet. Create one in the Exams area first.',
'No exam sessions for this rank exist yet. Create one in the Exams area first.',
)}
</Alert>
) : (
@@ -88,15 +86,12 @@ export function ScheduleExamModal({
/>
)}
<TextInput
label={t('review.scheduleExam.admissionNumber', 'Admission number')}
description={t(
<Text size="xs" c="dimmed">
{t(
'review.scheduleExam.admissionHint',
'Leave blank to let the system issue one.',
'An admission number is issued automatically when the candidate is seated.',
)}
value={admissionNumber}
onChange={(e) => setAdmissionNumber(e.currentTarget.value)}
/>
</Text>
<ModalFooter>
<Button variant="default" onClick={onClose}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,9 @@
import { useState, useCallback, type ElementType } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Title,
Group,
Table,
Badge,
Modal,
Text,
Paper,
Card,
Loader,
Center,
Alert,
Select,
SimpleGrid,
Divider,
Button,
ThemeIcon,
TextInput,
} from '@mantine/core';
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconInfoCircle,
IconUser,
IconCertificate,
IconDeviceFloppy,
IconPlus,
IconClipboardList,
IconCircleCheck,
IconCircleX,
IconChartBar,
IconSearch,
IconSend,
} from '@tabler/icons-react';
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import type { BilingualValue } from '@ema-platform/ui';
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
@@ -53,7 +22,7 @@ import { useGetExamsQuery } from '../../../exam/api/exam-api';
import { RecordResultModal } from '../../components/RecordResultModal';
import type { Result, ResultBreakdown } from '../../types/result';
import type { Exam } from '../../../exam/types/exam';
import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns';
import { resultColumns, STATUS_TONE, REVIEW_COLOR } from './columns';
import { resultActionsColumn, type QcAction } from './actions';
function ResultStat({
@@ -295,7 +264,7 @@ export function ResultPage() {
}
};
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
if (isError) return <ErrorState title={t('result.loadError')} onRetry={refetch} />;
const columns = [
...resultColumns(t, locale, showDate, getExamTitle),
@@ -311,11 +280,11 @@ export function ResultPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t('result.title')}</Title>
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
</div>
<PageHeader
title={t('result.title')}
subtitle={t('result.subtitle')}
noMargin
action={
<Group gap="sm">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
<Button
@@ -336,7 +305,8 @@ export function ResultPage() {
</Button>
</RequirePermission>
</Group>
</Group>
}
/>
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
@@ -345,10 +315,13 @@ export function ResultPage() {
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
</SimpleGrid>
<Card withBorder padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>{t('result.section')}</Text>
<Group gap="sm" wrap="wrap">
<AdvancedTable
columns={columns}
data={page.rows}
title={t('result.section')}
tableName={t('result.title')}
toolbar={
<>
<TextInput
placeholder={t('result.search.seafarer')}
leftSection={<IconSearch size={15} />}
@@ -366,13 +339,8 @@ export function ResultPage() {
style={{ width: 280 }}
clearable
/>
</Group>
</Group>
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('result.title')}
</>
}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
@@ -382,7 +350,6 @@ export function ResultPage() {
isLoading={isFetching}
emptyText={t('result.noItems')}
/>
</Card>
<Modal
opened={detailOpened}
@@ -439,9 +406,10 @@ export function ResultPage() {
{t('result.review.derivedStatus')}
</Text>
<Group gap="xs" mt={4}>
<Badge variant="light" color={STATUS_COLOR[detailResult.status]}>
{t(`result.status.${detailResult.status}`)}
</Badge>
<StatusBadge
tone={STATUS_TONE[detailResult.status]}
label={t(`result.status.${detailResult.status}`)}
/>
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
{t(`result.review.${detailResult.reviewStatus}`)}
</Badge>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,10 @@ export const am: Translations = {
tagline: "የቁጥጥር ማዕከል",
},
a11y: {
skipToContent: "ወደ ዋናው ይዘት ዝለል",
},
msg: {
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
@@ -253,6 +257,7 @@ export const am: Translations = {
oral: "ቃል",
essay: "ኢሴይ",
choice: "ምርጫ",
both: "ሁለቱም",
offline: "ከመስመር ውጪ",
online: "በመስመር",
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
@@ -305,6 +310,7 @@ export const am: Translations = {
formType: {
ESSAY: "ኢሴይ",
CHOICE: "ምርጫ",
BOTH: "ሁለቱም",
},
admin: {
OFFLINE: "ከመስመር ውጪ",
@@ -1003,7 +1009,10 @@ export const am: Translations = {
requestAdjustment: "ማስተካከያ ጠይቅ",
reject: "አትቀበል",
scheduleExam: "የፈተና ቀጠሮ ስጥ",
recordExamOutcome: "የፈተና ውጤት መዝግብ",
confirmPayment: "ክፍያ አረጋግጥ",
scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ",
issueCertificate: "ሰርተፍኬት ስጥ",
print: "ሰነድ አትም",
copyLink: "አገናኝ ቅዳ",
downloadDocuments: "ሁሉንም ሰነዶች አውርድ",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View File

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

View File

@@ -30,7 +30,12 @@ import {
IconShieldCheck,
} from '@tabler/icons-react';
import { authStorage, useCurrentProfile } from '@ema-platform/auth';
import { useApiQuery } from '@ema-platform/api';
import {
extractErrorMessage,
useApiQuery,
useBypassPaymentMutation,
useGetPaymentCapabilitiesQuery,
} from '@ema-platform/api';
import {
useGetMySeaServiceRecordsQuery,
useGetMyMedicalCertificatesQuery,
@@ -146,11 +151,32 @@ export function CertificatesPage() {
const [previewTitle, setPreviewTitle] = useState('');
const [loading, setLoading] = useState(false);
const { pay, isPaying } = useApplicationPayment();
// Dev/test only — the API reports false in production and the button is
// never rendered. Same shortcut My Applications offers.
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const { data } = useApiQuery<CertificatesOverview>({
const { data, refetch } = useApiQuery<CertificatesOverview>({
url: '/certificates/my',
method: 'GET',
});
const handleBypass = async (applicationId: string) => {
try {
const result = await bypassPayment(applicationId).unwrap();
notifications.show({
color: 'teal',
title: 'Payment bypassed',
message: result.certificateIssued
? 'The certificate has been issued.'
: `Application is now ${humanStatus(result.status)}.`,
});
// Generic query, not tag-driven: refresh it by hand.
refetch();
} catch (err) {
notifications.show({ color: 'red', title: 'Bypass failed', message: extractErrorMessage(err) });
}
};
const certificates = data?.certificates ?? [];
const applications = data?.applications ?? [];
@@ -331,6 +357,17 @@ export function CertificatesPage() {
Pay {app.feeAmount.toLocaleString()} {app.feeCurrency}
</Button>
)}
{app.feeAmount !== null && capabilities?.bypassEnabled && (
<Button
size="xs"
variant="default"
loading={bypassing}
onClick={() => handleBypass(app.applicationId)}
title="Testing only — marks the fee paid without a provider"
>
Bypass payment
</Button>
)}
<Text
fz="xs"
c="blue"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,10 @@ export const am: Translations = {
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
},
a11y: {
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
},
msg: {
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',

View File

@@ -9,6 +9,11 @@ export const en = {
tagline: 'Maritime licensing & certification services',
},
// Strings only assistive technology encounters.
a11y: {
skipToContent: 'Skip to main content',
},
msg: {
genericError: 'Something went wrong. Please try again.',
serverError: 'Server error. Please try again later.',

View File

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

View File

@@ -1,4 +1,5 @@
import { createBrowserRouter, Navigate } from "react-router-dom";
import { ThemeGallery } from "@ema-platform/ui";
import { PortalLayout } from "./layouts/PortalLayout";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { LandingRoute } from "./components/LandingRoute";
@@ -58,6 +59,10 @@ export const router = createBrowserRouter([
// Public landing page — institutional overview + role-based entry points.
{ path: "/", element: <LandingRoute /> },
// Theme visual-regression surface. Unauthenticated by design — it renders
// only static primitives, so it needs no API and cannot flake.
{ path: "/__gallery", element: <ThemeGallery /> },
// Public auth pages
{ path: "/login", element: <LoginPage /> },
{ path: "/signup", element: <SignupPage /> },

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { resolveSessionContext } from "../session";
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3001/api";
] ?? "http://localhost:3000/api";
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;

View File

@@ -6,6 +6,7 @@ import type {
ApplicationPayment,
ApplicationStaff,
Attachment,
Department,
DocumentRequirement,
FormSchemaPalette,
FormSectionConfig,
@@ -21,11 +22,14 @@ import type {
AssignableOfficer,
DocumentDecision,
DocumentReview,
EligibleExam,
ExportResult,
LicenseTemplate,
Paginated,
QueueCounts,
QueueFilter,
Rank,
RankCertificateCategory,
RemarkTargetType,
SavedQueueView,
SchemaIssue,
@@ -71,6 +75,8 @@ const TAGS = [
'SavedView',
'LicenseTemplate',
'DocumentRequirement',
'Department',
'Rank',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -253,6 +259,75 @@ export const licensingApi = baseApi
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
// --------------------------------------------------- departments & ranks
/** Every department, for the admin editor. */
getDepartments: builder.query<Paginated<Department>, void>({
query: () => ({ url: '/departments' }),
providesTags: () => [listTag('Department')],
}),
/** Active departments only — the applicant-facing picker. */
getActiveDepartments: builder.query<Department[], void>({
query: () => ({ url: '/departments/active/list' }),
providesTags: () => [listTag('Department')],
}),
createDepartment: builder.mutation<
Department,
Partial<Department> & { code: string; name: Department['name'] }
>({
query: (body) => ({ url: '/departments', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
}),
updateDepartment: builder.mutation<Department, { id: string } & Partial<Department>>({
query: ({ id, ...body }) => ({ url: `/departments/${id}`, method: 'PUT', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
}),
deleteDepartment: builder.mutation<unknown, string>({
query: (id) => ({ url: `/departments/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
}),
/** Every rank, for the admin editor to filter/group by department client-side. */
getRanks: builder.query<Paginated<Rank>, void>({
query: () => ({ url: '/ranks' }),
providesTags: () => [listTag('Rank')],
}),
/** One department's ladder for a category, ordered — the applicant wizard's rank picker. */
getRankLadder: builder.query<
Rank[],
{ departmentId: string; certificateCategory: RankCertificateCategory }
>({
query: (params) => ({ url: '/ranks/ladder', params }),
providesTags: () => [listTag('Rank')],
}),
createRank: builder.mutation<
Rank,
Partial<Rank> & {
departmentId: string;
certificateCategory: RankCertificateCategory;
key: string;
name: Rank['name'];
}
>({
query: (body) => ({ url: '/ranks', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
}),
updateRank: builder.mutation<Rank, { id: string } & Partial<Rank>>({
query: ({ id, ...body }) => ({ url: `/ranks/${id}`, method: 'PUT', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
}),
deleteRank: builder.mutation<unknown, string>({
query: (id) => ({ url: `/ranks/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
}),
// -------------------------------------------------------- application
createApplication: builder.mutation<
LicenseApplication,
@@ -565,6 +640,15 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
/**
* Exam sittings valid for this application's rank — what the
* schedule-exam picker offers, instead of every exam in the system.
*/
getEligibleExams: builder.query<EligibleExam[], string>({
query: (id) => ({ url: `/license-application-review/${id}/eligible-exams` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
}),
/** Places a candidate who has paid the examination fee into a sitting. */
scheduleExam: builder.mutation<
LicenseApplication,
@@ -631,6 +715,8 @@ export const licensingApi = baseApi
LicenseTemplate,
{
licenseTypeId: string;
/** Scopes the draft to one rank's certificate. Omit for the type's default design. */
rankId?: string | null;
name: string;
hbsSource?: string;
pageOptions?: TemplatePageOptions;
@@ -644,6 +730,7 @@ export const licensingApi = baseApi
LicenseTemplate,
{
id: string;
rankId?: string | null;
name?: string;
hbsSource?: string;
pageOptions?: TemplatePageOptions;
@@ -909,6 +996,16 @@ export const {
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetDepartmentsQuery,
useGetActiveDepartmentsQuery,
useCreateDepartmentMutation,
useUpdateDepartmentMutation,
useDeleteDepartmentMutation,
useGetRanksQuery,
useGetRankLadderQuery,
useCreateRankMutation,
useUpdateRankMutation,
useDeleteRankMutation,
useUpdateLicenseValidityMutation,
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
@@ -963,6 +1060,7 @@ export const {
useApproveDocumentsMutation,
useFinalApproveMutation,
useRejectApplicationMutation,
useGetEligibleExamsQuery,
useScheduleExamMutation,
useRecordExamOutcomeMutation,
useRetakeExamMutation,

View File

@@ -558,14 +558,25 @@ export function validateSections(
}
/** Evaluates a config condition against the current form answers. */
interface ConditionLike {
field?: string;
equals?: unknown;
notEquals?: unknown;
in?: (string | number)[];
isSet?: boolean;
/** Holds when ANY listed sub-condition holds — see FieldCondition.anyOf. */
anyOf?: ConditionLike[];
}
export function conditionHolds(
condition:
| { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean }
| undefined
| null,
condition: ConditionLike | undefined | null,
formData: Record<string, Record<string, unknown>>,
): boolean {
if (!condition?.field) return true;
if (!condition) return true;
if (condition.anyOf) {
return condition.anyOf.some((sub) => conditionHolds(sub, formData));
}
if (!condition.field) return true;
const value = condition.field
.split('.')
.reduce<unknown>(

View File

@@ -67,11 +67,18 @@ export type FormFieldType =
| "TIN";
export interface FieldCondition {
field: string;
/** Omitted when `anyOf` is used instead — see below. */
field?: string;
equals?: string | number | boolean;
notEquals?: string | number | boolean;
in?: (string | number)[];
isSet?: boolean;
/**
* Holds when ANY listed condition holds — for a value that can live on one
* of several mutually-exclusive fields (e.g. a rank split by department).
* `field`/`equals`/etc are ignored when this is present.
*/
anyOf?: FieldCondition[];
}
export interface FormFieldConfig {
@@ -575,9 +582,37 @@ export interface TemplateFieldPlacement {
}
/** A certificate design authored in the backoffice. */
/** An STCW seafarer department (Deck, Engine, Catering), backoffice-managed. */
export interface Department {
id: string;
/** Matches the ESeafarerDepartment value stored elsewhere, e.g. "DECK". */
code: string;
name: Bilingual;
sortOrder: number;
isActive: boolean;
}
export type RankCertificateCategory = "COC" | "COP";
/** One rung of a CoC/CoP ladder for a department. */
export interface Rank {
id: string;
departmentId: string;
certificateCategory: RankCertificateCategory;
/** Value stored on License.rank / Certification.rankKey, e.g. "CHIEF_MATE". */
key: string;
name: Bilingual;
/** Rung position within its department+category ladder. 0 is the entry rank. */
ladderOrder: number;
sortOrder: number;
isActive: boolean;
}
export interface LicenseTemplate {
id: string;
licenseTypeId: string;
/** Scopes this design to one rank's certificate. Null = the type's default. */
rankId?: string | null;
name: string;
version: number;
hbsSource: string;
@@ -690,3 +725,13 @@ export interface IssuedLicense {
verificationCode: string;
certificateFileKey: string | null;
}
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
export interface EligibleExam {
id: string;
title: { en: string; am: string };
date: string;
venue: string;
status: string;
certification?: { id: string; name: { en: string; am: string }; rankKey: string | null };
}

View File

@@ -107,12 +107,26 @@ export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationSta
REJECTED: 'Rejected',
};
export const SEAFARER_REGISTRATION_STATUS_COLORS: Record<SeafarerRegistrationStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
RESUBMIT_REQUIRED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',
/**
* Registration status → platform tone.
*
* Tones, not colours. `StatusTone` is the platform's status vocabulary and the
* one place a tone becomes a colour (`STATUS_TONE_COLOR` in
* @ema-platform/shared), so this map cannot drift the way `APPROVED: 'teal'`
* had already drifted from every other feature's green success.
*
* The union is repeated rather than imported because @ema-platform/api does not
* depend on the theme layer, and should not start to for five string literals.
*/
export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
SeafarerRegistrationStatus,
'success' | 'warning' | 'danger' | 'info' | 'pending' | 'neutral'
> = {
DRAFT: 'neutral',
SUBMITTED: 'info',
RESUBMIT_REQUIRED: 'pending',
APPROVED: 'success',
REJECTED: 'danger',
};
/** Human label for each answer — the review table and the summary both use it. */

View File

@@ -1,4 +1,8 @@
export * from './lib/theme/palettes';
export * from './lib/theme/base-theme';
export * from './lib/theme/ema-theme';
export * from './lib/theme/portal-theme';
export * from './lib/theme/status-tone';
export * from './lib/date/date-displayer';
export * from './lib/date/use-date-displayer';
export * from './lib/date/ethiopic';

View File

@@ -0,0 +1,156 @@
import type { CSSProperties } from 'react';
import { createTheme, rem } from '@mantine/core';
/**
* Everything both apps agree on: scale, shape, elevation, and component
* defaults. No colours — those are the one thing the backoffice and the portal
* deliberately differ on, so each theme layers its own ramps over this.
*
* This began as the portal's theme. The backoffice had no heading scale, no
* radius scale and no component defaults at all, which is why its features
* drifted: with nothing to inherit, every page invented its own spacing and
* sizing. Promoting the portal's structure here fixes 23 features by editing
* one file, and costs the portal nothing — the values are unchanged.
*/
/**
* The type stack.
*
* Noto Sans Ethiopic sits directly after Inter rather than being swapped in by
* a `[lang='am']` rule. Browsers fall back per *glyph*, not per element, so one
* stack renders Latin in Inter and Ge'ez in Noto automatically — including
* inside a single string. That matters here: a registry is full of mixed-script
* lines like an Amharic name beside a Latin IMO number, and a language-scoped
* swap would render one half of those in the wrong face.
*
* Inter carries no Ge'ez glyphs at all, so before this the Amharic half of a
* bilingual system rendered in whatever the OS happened to substitute.
*/
export const EMA_FONT_STACK =
'Inter, "Noto Sans Ethiopic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
export const baseTheme = createTheme({
fontFamily: EMA_FONT_STACK,
headings: {
fontFamily: EMA_FONT_STACK,
fontWeight: '700',
// A little looser than the portal's original values. Ge'ez has taller
// ascenders and deeper descenders than Latin, so a heading set to Inter's
// natural leading clips its Amharic rendering — which only became visible
// once Ethiopic was actually being rendered rather than substituted.
sizes: {
h1: { fontSize: rem(32), lineHeight: '1.3' },
h2: { fontSize: rem(25), lineHeight: '1.35' },
h3: { fontSize: rem(21), lineHeight: '1.4' },
h4: { fontSize: rem(17), lineHeight: '1.45' },
h5: { fontSize: rem(15), lineHeight: '1.5' },
},
},
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',
// Draw a focus ring for keyboard users only. The codebase had no
// `:focus-visible` handling anywhere, which is the single largest WCAG gap.
focusRing: 'auto',
// Shade 6 in light. The dark counterpart is deliberately left unset until
// dark mode is verified end to end — changing it moves every filled control.
primaryShade: { light: 6 },
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' } },
// The page sits on a tinted surface and cards float on white. Without this
// the main area is the same white as every Paper on it, and the card
// borders are the only thing separating content from chrome.
AppShell: {
styles: { main: { background: 'var(--ema-surface-page)' } },
},
// Tables — the registry look: quiet uppercase headers, hairline row
// borders, a tint on hover, no zebra striping and no column rules. Set
// once here so the 14 pages rendering a raw <Table> match the 28 that go
// through AdvancedTable instead of each picking their own density.
//
// Header text is `text-secondary` rather than the lighter dimmed gray the
// mockup used: at 11px uppercase, gray-5 on white fails 4.5:1.
Table: {
defaultProps: { highlightOnHover: true, verticalSpacing: 'sm', horizontalSpacing: 'md' },
styles: {
table: {
'--table-border-color': 'var(--ema-border-subtle)',
'--table-hover-color': 'var(--ema-surface-page)',
'--table-striped-color': 'var(--ema-surface-sunken)',
} as CSSProperties,
th: {
fontSize: rem(11),
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--ema-text-secondary)',
whiteSpace: 'nowrap',
},
td: { fontSize: rem(13) },
},
},
// 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

@@ -1,55 +1,32 @@
import { createTheme, type MantineColorsTuple } from '@mantine/core';
import { createTheme, mergeThemeOverrides } from '@mantine/core';
import { baseTheme } from './base-theme';
import { emaBlue, emaSecondary } from './palettes';
const emaPrimary: MantineColorsTuple = [
'#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa',
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
];
const emaSecondary: MantineColorsTuple = [
'#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0',
'#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a',
];
export const emaTheme = createTheme({
/**
* Backoffice theme.
*
* Structure, scale and component defaults come from `baseTheme`; this file
* contributes only the brand. The backoffice keeps its own blue rather than
* adopting the portal's: shade 8 (#1e40af) is the higher-contrast choice for a
* tool staff read all day, and a distinct accent tells an officer at a glance
* which of the two systems they are looking at — worth having when both share
* a domain vocabulary but not the same authority.
*
* The export name is load-bearing: `libs/shared/src/index.ts` and the
* backoffice's MantineThemeProvider both import `emaTheme` by name.
*
* Note this theme does NOT override `colors.gray`. The portal's blue-tinted
* neutrals shift every dimmed label, neutral badge and table border, so that
* change is being made one app at a time rather than as a side effect of
* sharing a base.
*/
export const emaTheme = mergeThemeOverrides(
baseTheme,
createTheme({
primaryColor: 'emaPrimary',
colors: {
emaPrimary,
emaPrimary: emaBlue,
emaSecondary,
},
fontFamily: 'Inter, sans-serif',
defaultRadius: 'md',
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
shadows: {
xs: '0 1px 3px rgba(0,0,0,0.05)',
sm: '0 1px 5px rgba(0,0,0,0.07)',
md: '0 4px 20px rgba(15,23,42,0.08)',
lg: '0 8px 30px rgba(15,23,42,0.12)',
},
components: {
// 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

@@ -0,0 +1,66 @@
import type { MantineColorsTuple } from '@mantine/core';
/**
* Every colour ramp the platform uses, in one place.
*
* Themes compose these; they do not define colours inline. Keeping the ramps
* separate from the themes is what lets the backoffice and the portal share a
* structure while keeping distinct brands — and it gives the hardcoded hexes
* scattered through feature code somewhere legitimate to be migrated to.
*/
/**
* Backoffice brand. Shade 8 (#1e40af) is the accessible-government blue; the
* ramp is deliberately more saturated than the portal's because staff tools
* are read all day under worse conditions than a citizen portal.
*/
export const emaBlue: MantineColorsTuple = [
'#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa',
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
];
/** Backoffice secondary — a warm brown, used sparingly for accents. */
export const emaSecondary: MantineColorsTuple = [
'#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0',
'#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a',
];
/** Portal brand — the "Coastal Modern" blue. Softer than the backoffice ramp. */
export const emaCoastalBlue: MantineColorsTuple = [
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
];
/** Portal accent — the "coastal" half of the palette. */
export const emaTeal: MantineColorsTuple = [
'#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
'#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
];
/**
* Cool, slightly blue-tinted neutrals for surfaces and text.
*
* Overriding Mantine's stock `gray` with this shifts every `c="dimmed"`, every
* neutral badge and every table border in whichever app adopts it — so it is
* applied per-theme rather than in the base, and promoted one app at a time.
*/
export const emaGray: MantineColorsTuple = [
'#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
'#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
];
/**
* Ethiopian flag colours.
*
* These are intentional brand, not drift — they appear in the boot splashes,
* the maritime loader and the landing page. They live here so those usages can
* reference a name instead of repeating a hex, but they are deliberately NOT
* semantic tokens: `emaFlag.green` means "the flag's green", never "success".
*/
export const emaFlag = {
blue: '#0284C7',
yellow: '#FCD116',
green: '#078930',
gold: '#D4AF37',
sky: '#38BDF8',
} as const;

View File

@@ -0,0 +1,29 @@
import { createTheme, mergeThemeOverrides } from '@mantine/core';
import { baseTheme } from './base-theme';
import { emaCoastalBlue, emaGray, emaTeal } from './palettes';
/**
* Portal theme — "Coastal Modern".
*
* Structure comes from `baseTheme` (which this theme's own structure was the
* source of, so nothing here changes visually). What remains is the brand: a
* softer blue than the backoffice, a teal accent, and cool blue-tinted
* neutrals in place of Mantine's stock gray.
*
* The gray override stays portal-only for now. It is the widest-reaching
* single line in either theme — it retints every dimmed label, neutral badge
* and table border — so the backoffice adopts it as its own reviewed change,
* not as a side effect of sharing a base.
*/
export const portalTheme = mergeThemeOverrides(
baseTheme,
createTheme({
primaryColor: 'emaPrimary',
primaryShade: { light: 6, dark: 5 },
colors: {
emaPrimary: emaCoastalBlue,
emaTeal,
gray: emaGray,
},
}),
);

View File

@@ -0,0 +1,178 @@
/* ============================================================================
Semantic tokens.
These name a *role* — "the page background", "a subtle border", "danger" —
rather than a colour. Feature code should reach for these instead of a hex,
because a hex cannot follow the colour scheme and a Mantine shade index
(`gray.5`) says nothing about why that shade was chosen.
Every token resolves to a Mantine variable rather than a literal. That is
deliberate: Mantine already recomputes its own variables under
[data-mantine-color-scheme], so tokens defined in terms of them switch for
free and can never drift from the theme. A parallel palette of raw hexes
would recreate exactly the problem this layer exists to fix.
Loaded once per app, after Mantine's CSS.
============================================================================ */
:root {
/* --- Surfaces ---------------------------------------------------------- */
/* The page itself, a raised card, and a recessed well. */
--ema-surface-page: var(--mantine-color-gray-0);
--ema-surface-raised: var(--mantine-color-white);
--ema-surface-sunken: var(--mantine-color-gray-1);
/* --- Borders ----------------------------------------------------------- */
/* Subtle separates rows; strong outlines an input or a focused container. */
--ema-border-subtle: var(--mantine-color-gray-2);
--ema-border-strong: var(--mantine-color-gray-4);
/* --- Text -------------------------------------------------------------- */
/* Secondary must stay a *text* colour: it has to clear 4.5:1, not 3:1, so
it deliberately sits darker than the gray-5 that reads as "dimmed". */
--ema-text-primary: var(--mantine-color-gray-9);
--ema-text-secondary: var(--mantine-color-gray-7);
--ema-text-disabled: var(--mantine-color-gray-5);
/* --- Status ------------------------------------------------------------
Six tones, which is the entire vocabulary a status needs. Domain statuses
map onto these rather than each picking their own colour.
`-fg` is text on the app background; `-bg` is a tint to sit that text on.
Both are needed because a badge and a label have different contrast
requirements against the same surface. */
--ema-status-success-fg: var(--mantine-color-green-8);
--ema-status-success-bg: var(--mantine-color-green-0);
--ema-status-warning-fg: var(--mantine-color-yellow-8);
--ema-status-warning-bg: var(--mantine-color-yellow-0);
--ema-status-danger-fg: var(--mantine-color-red-8);
--ema-status-danger-bg: var(--mantine-color-red-0);
--ema-status-info-fg: var(--mantine-color-blue-8);
--ema-status-info-bg: var(--mantine-color-blue-0);
--ema-status-pending-fg: var(--mantine-color-orange-8);
--ema-status-pending-bg: var(--mantine-color-orange-0);
--ema-status-neutral-fg: var(--mantine-color-gray-7);
--ema-status-neutral-bg: var(--mantine-color-gray-1);
/* --- Focus -------------------------------------------------------------
One ring for the whole platform. Sized to stay visible against both a
white card and a tinted surface. */
--ema-focus-ring: var(--mantine-primary-color-filled);
--ema-focus-ring-width: 2px;
--ema-focus-ring-offset: 2px;
}
[data-mantine-color-scheme='dark'] {
/* Dark is not light inverted. Surfaces lift with elevation rather than
dropping, and text steps down from white rather than up from black. */
--ema-surface-page: var(--mantine-color-dark-8);
--ema-surface-raised: var(--mantine-color-dark-7);
--ema-surface-sunken: var(--mantine-color-dark-9);
--ema-border-subtle: var(--mantine-color-dark-4);
--ema-border-strong: var(--mantine-color-dark-3);
--ema-text-primary: var(--mantine-color-gray-0);
--ema-text-secondary: var(--mantine-color-gray-4);
--ema-text-disabled: var(--mantine-color-dark-2);
/* Saturated mid-shades go muddy on a dark ground; these step lighter so the
foreground still clears 4.5:1 and the tint stays distinguishable. */
--ema-status-success-fg: var(--mantine-color-green-4);
--ema-status-success-bg: var(--mantine-color-green-9);
--ema-status-warning-fg: var(--mantine-color-yellow-4);
--ema-status-warning-bg: var(--mantine-color-yellow-9);
--ema-status-danger-fg: var(--mantine-color-red-4);
--ema-status-danger-bg: var(--mantine-color-red-9);
--ema-status-info-fg: var(--mantine-color-blue-4);
--ema-status-info-bg: var(--mantine-color-blue-9);
--ema-status-pending-fg: var(--mantine-color-orange-4);
--ema-status-pending-bg: var(--mantine-color-orange-9);
--ema-status-neutral-fg: var(--mantine-color-gray-4);
--ema-status-neutral-bg: var(--mantine-color-dark-5);
}
/* ============================================================================
Focus.
The codebase had no :focus-visible rule anywhere, which is the single
largest accessibility gap in it. :focus-visible rather than :focus so a
mouse click does not leave a ring behind — that is the behaviour that gets
focus rings deleted from designs in the first place.
============================================================================ */
/* Mantine already rings its own controls (`.mantine-focus-auto:focus-visible`
resolves to the same 2px solid primary). This rule is the safety net for
everything it does not own: plain anchors, custom elements, and the
UnstyledButtons this codebase uses for its own controls.
Note there is deliberately no `outline: none` opt-out for the Mantine
classes. An earlier attempt at one suppressed Mantine's working ring and
left portal buttons with no focus indicator at all — which of the two rules
won came down to stylesheet order, and that differs between the apps.
Matching values mean overlap is invisible, so overlap is the safe default. */
:focus-visible {
outline: var(--ema-focus-ring-width) solid var(--ema-focus-ring);
outline-offset: var(--ema-focus-ring-offset);
}
/* ============================================================================
Screen-reader-only utility.
No equivalent existed anywhere in the codebase, so anything needing a text
alternative had nowhere to put it.
============================================================================ */
.ema-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
/* clip-path rather than the legacy clip: it does not force a layer and is
not deprecated. */
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* A skip link is sr-only until focused, then must be plainly visible. */
.ema-skip-link {
position: absolute;
top: 0;
left: 0;
z-index: 9999;
padding: 0.75rem 1.25rem;
background: var(--ema-surface-raised);
color: var(--ema-text-primary);
border: 1px solid var(--ema-border-strong);
border-radius: 0 0 var(--mantine-radius-md) 0;
font-weight: 600;
text-decoration: none;
/* Off-screen rather than display:none, so it stays focusable. */
transform: translateY(-150%);
}
.ema-skip-link:focus-visible {
transform: translateY(0);
}
/* ============================================================================
Reduced motion.
Honour the OS setting globally. Animation is not removed outright — a
near-instant transition still conveys that something changed, without the
movement that triggers vestibular symptoms.
============================================================================ */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,51 @@
/**
* The platform's status vocabulary.
*
* There are 48 separate status→colour maps across the codebase, each deciding
* independently what "pending" looks like. They disagree. The fix is not one
* bigger map — domain statuses genuinely differ per feature — but one small set
* of *tones* that every domain maps onto, so the colour decision is made six
* times instead of forty-eight.
*
* `semantic.css` carries the CSS-variable form of these for stylesheet use.
* This module is for the many places that need a Mantine `color` prop instead.
*/
export type StatusTone =
| 'success'
| 'warning'
| 'danger'
| 'info'
| 'pending'
| 'neutral';
/**
* Tone → Mantine colour name.
*
* Deliberately the only place a tone becomes a colour. Changing the platform's
* idea of "warning" is an edit here, not a sweep through 48 files.
*/
export const STATUS_TONE_COLOR: Record<StatusTone, string> = {
success: 'green',
warning: 'yellow',
danger: 'red',
info: 'blue',
pending: 'orange',
neutral: 'gray',
};
/**
* Tone → CSS custom properties, for inline styles and stylesheets.
*
* Returns variable references rather than resolved colours so the values keep
* following the active colour scheme.
*/
export function statusToneVars(tone: StatusTone): {
color: string;
background: string;
} {
return {
color: `var(--ema-status-${tone}-fg)`,
background: `var(--ema-status-${tone}-bg)`,
};
}

View File

@@ -9,6 +9,7 @@ export * from "./lib/feedback/FeatureUnavailable";
export * from "./lib/feedback/EmptyState";
export * from "./lib/feedback/ErrorState";
export * from "./lib/feedback/PageLoader";
export * from "./lib/feedback/StatusBadge";
export * from "./lib/components/MaritimeLoader";
export * from "./lib/theme/maritime-loader-theme";
export * from "./lib/layout/AppHeader";
@@ -19,13 +20,17 @@ export * from "./lib/layout/BrandAvatar";
export * from "./lib/layout/ColorSchemeToggle";
export * from "./lib/layout/LanguageSwitcher";
export * from "./lib/layout/PageHeader";
export * from "./lib/layout/SkipLink";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/PhoneInput";
export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/data/WaitingFor";
export * from "./lib/data/StatTile";
export * from "./lib/feedback/use-error-handler";
export * from "./lib/data/useServerTable";
export * from "./lib/landing/LandingPage";
export * from "./lib/landing/landing-copy";
export * from "./lib/utils/person-name";
export * from "./lib/dev/ThemeGallery";

View File

@@ -51,6 +51,10 @@ interface AdvancedTableProps<T> {
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
/** Makes rows clickable (adds pointer cursor). */
onRowClick?: (row: T) => void;
/** Card title, top-left. Defaults to `tableName`, which every caller already passes. */
title?: ReactNode;
/** Search box, filters, export — rendered top-right before Refresh/View. */
toolbar?: ReactNode;
}
function getByPath(obj: unknown, path?: string): unknown {
@@ -82,6 +86,8 @@ export function AdvancedTable<T extends { id?: string | number }>({
verticalSpacing = "sm",
rowStyle,
onRowClick,
title,
toolbar,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(
@@ -94,13 +100,22 @@ export function AdvancedTable<T extends { id?: string | number }>({
});
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
const heading = title ?? tableName;
const from = itemCount === 0 ? 0 : pageIndex * pageSize + 1;
const to = Math.min(itemCount, pageIndex * pageSize + data.length);
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md">
<Paper withBorder radius="lg" p={0}>
<Group justify="space-between" px="md" py="sm" wrap="wrap" gap="sm">
<Group gap="xs">
<Text fw={600}>{""}</Text>
{heading && (
<Text fw={600} size="sm">
{heading}
</Text>
)}
</Group>
<Group gap="xs">
<Group gap="xs" wrap="wrap">
{toolbar}
{refresh && (
<Button
variant="default"
@@ -162,13 +177,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Group>
<Table.ScrollContainer minWidth={480}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
verticalSpacing={verticalSpacing}
>
<Table verticalSpacing={verticalSpacing}>
<Table.Thead>
<Table.Tr>
{shownColumns.map((col, i) => (
@@ -230,8 +239,21 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Table>
</Table.ScrollContainer>
{(itemCount > pageSize || onPageSizeChange) && (
<Group justify="flex-end" mt="md">
<Group
justify="space-between"
px="md"
py="sm"
style={{ borderTop: "1px solid var(--ema-border-subtle)" }}
>
<Text size="xs" c="dimmed">
{t("common.showingRange", {
from,
to,
total: itemCount,
defaultValue: "Showing {{from}}{{to}} of {{total}}",
})}
</Text>
<Group gap="sm">
{onPageSizeChange && (
<Select
size="sm"
@@ -253,7 +275,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
/>
)}
</Group>
)}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,83 @@
import type { ReactNode } from 'react';
import { Group, Paper, Text, ThemeIcon, UnstyledButton } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
import './stat-tile.css';
export interface StatTileProps {
label: string;
/** The number itself. A string so callers can pass "—" while loading. */
value: ReactNode;
/** One line under the value: what the number means, or how it is trending. */
hint?: ReactNode;
icon?: Icon;
/**
* Which tone the icon carries. Tone rather than colour so a tile counting
* overdue work is the same red as an overdue badge.
*/
tone?: StatusTone;
/** Makes the whole tile a button — use when the number has somewhere to go. */
onClick?: () => void;
}
/**
* One figure on a dashboard.
*
* The four stat cards on the backoffice home were `<Card>` + two `<Text>`,
* re-declared inline on every dashboard that wanted them — so the logistics
* overview and the backoffice home showed the same kind of number at different
* sizes. The number leads, the label sits above it small and quiet, and the
* icon is decoration that carries the tone.
*
* A tile with `onClick` becomes a real button: dashboards exist to be a
* jumping-off point, and a count you cannot click is a dead end.
*/
export function StatTile({ label, value, hint, icon: TileIcon, tone, onClick }: StatTileProps) {
const body = (
<>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text size="xs" c="dimmed" tt="uppercase" fw={700} lh={1.4}>
{label}
</Text>
{TileIcon && (
<ThemeIcon
size={38}
radius="md"
variant="light"
color={tone ? STATUS_TONE_COLOR[tone] : undefined}
>
<TileIcon size={20} stroke={1.7} />
</ThemeIcon>
)}
</Group>
<Text fz={30} fw={800} lh={1.15} mt="xs">
{value}
</Text>
{hint && (
<Text size="xs" c="dimmed" mt={2}>
{hint}
</Text>
)}
</>
);
if (!onClick) {
return (
<Paper withBorder radius="lg" p="lg">
{body}
</Paper>
);
}
return (
<UnstyledButton
onClick={onClick}
className="ema-stat-tile-clickable"
style={{ display: 'block', width: '100%', height: '100%' }}
>
<Paper withBorder radius="lg" p="lg" h="100%">
{body}
</Paper>
</UnstyledButton>
);
}

View File

@@ -0,0 +1,83 @@
import { Text, Tooltip } from '@mantine/core';
import { statusToneVars } from '@ema-platform/shared';
export interface WaitingForProps {
/** When the clock started — normally the moment the applicant submitted. */
since: string | null | undefined;
/**
* Days after which the wait reads as overdue. Amber at half of it, red past
* it. Defaults to 14, which is the shortest SLA any configured licence type
* currently uses; pass the real one where a queue knows it.
*/
slaDays?: number;
/** Set once the item is decided — a closed item is not waiting for anyone. */
done?: boolean;
}
/**
* Fraction of the target elapsed before the wait reads as at-risk. Matches the
* licence queue's own `WARNING_RATIO`, so a queue using this component and one
* using `computeSla` turn amber at the same point rather than disagreeing.
*/
const WARNING_RATIO = 0.7;
/** Whole days between `since` and now, floored. Negative clock skew reads as 0. */
function daysSince(since: string): number {
const ms = Date.now() - new Date(since).getTime();
return Math.max(0, Math.floor(ms / 86_400_000));
}
/**
* How long an item has been sitting in a queue.
*
* No review queue showed this. An officer opening a list of thirty
* registrations could see what each one *was*, but not which had been waiting
* three days and which had been waiting three weeks — so the queue was worked
* top-down by whatever the sort happened to be rather than by urgency.
*
* Colour comes from the platform's status tones rather than its own scale, so
* "overdue" here is the same red as "rejected" everywhere else.
*/
export function WaitingFor({ since, slaDays = 14, done }: WaitingForProps) {
if (!since) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
const days = daysSince(since);
// A decided item keeps its elapsed time visible — useful when reviewing how
// long something took — but never coloured, because nothing is pending.
const tone = done
? 'neutral'
: days >= slaDays
? 'danger'
: days >= slaDays * WARNING_RATIO
? 'pending'
: 'neutral';
const label = days === 0 ? 'today' : `${days}d`;
return (
<Tooltip
label={
done
? `Took ${days} day${days === 1 ? '' : 's'}`
: `Waiting ${days} day${days === 1 ? '' : 's'} · ${slaDays}-day target`
}
withArrow
>
<Text
size="sm"
fw={tone === 'neutral' ? 400 : 600}
c={tone === 'neutral' ? 'dimmed' : undefined}
style={tone === 'neutral' ? undefined : { color: statusToneVars(tone).color }}
>
{label}
</Text>
</Tooltip>
);
}

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