Merge remote-tracking branch 'origin/dev' into feature/exam-attempt-domain
5
.github/workflows/deploy.yml
vendored
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,6 +33,196 @@ 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(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function ConditionArmFields({
|
||||
value,
|
||||
onChange,
|
||||
targets,
|
||||
palette,
|
||||
}: {
|
||||
value: ConditionArm;
|
||||
onChange: (value: ConditionArm) => void;
|
||||
targets: ConditionTarget[];
|
||||
palette: FormSchemaPalette | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
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) {
|
||||
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
|
||||
}
|
||||
|
||||
function setOperator(next: Operator) {
|
||||
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 = '';
|
||||
else base.equals = '';
|
||||
onChange(base);
|
||||
}
|
||||
|
||||
function setValueRaw(raw: string) {
|
||||
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;
|
||||
onChange({
|
||||
field: value.field,
|
||||
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Autocomplete
|
||||
label={t('certReq.condition.field', 'Field path')}
|
||||
placeholder="certificate.rank"
|
||||
description={t(
|
||||
'certReq.condition.fieldHelp',
|
||||
'Dot path into the form, e.g. sectionKey.fieldKey',
|
||||
)}
|
||||
data={targets.map((c) => c.path)}
|
||||
value={value.field ?? ''}
|
||||
onChange={setField}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label={t('certReq.condition.operator', 'Operator')}
|
||||
data={operators.map((op) => ({ value: op, label: op }))}
|
||||
value={operator}
|
||||
onChange={(v) => v && setOperator(v as Operator)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
|
||||
<Select
|
||||
label={t('certReq.condition.value', 'Value')}
|
||||
data={(target.field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label) || o.value,
|
||||
}))}
|
||||
value={String(value.equals ?? value.notEquals ?? '')}
|
||||
onChange={(v) => v !== null && setValueRaw(v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && (
|
||||
target?.field.type === 'BOOLEAN' ? (
|
||||
<Checkbox
|
||||
mt="xl"
|
||||
label={t('certReq.condition.value', 'Value')}
|
||||
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 ?? '')}
|
||||
onChange={(e) => setValueRaw(e.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{operator === 'in' && target?.field.type === 'SELECT' && (
|
||||
<Select
|
||||
label={t('certReq.condition.values', 'Any of')}
|
||||
data={(target.field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label) || o.value,
|
||||
}))}
|
||||
multiple={undefined}
|
||||
value={null}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
const current = (value.in ?? []) as string[];
|
||||
if (!current.includes(v)) setInValues([...current, v]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operator === 'in' && target?.field.type !== 'SELECT' && (
|
||||
<TextInput
|
||||
label={t('certReq.condition.values', 'Any of (comma-separated)')}
|
||||
value={(value.in ?? []).join(', ')}
|
||||
onChange={(e) =>
|
||||
setInValues(
|
||||
e.currentTarget.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{operator === 'in' && (value.in?.length ?? 0) > 0 && (
|
||||
<Group gap={4}>
|
||||
{(value.in ?? []).map((v, i) => (
|
||||
<Text
|
||||
key={`${v}-${i}`}
|
||||
fz="xs"
|
||||
px={6}
|
||||
py={2}
|
||||
bg="var(--mantine-color-gray-1)"
|
||||
style={{ borderRadius: 4, cursor: 'pointer' }}
|
||||
onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
|
||||
title={t('certReq.condition.removeValue', 'Click to remove')}
|
||||
>
|
||||
{String(v)} ×
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!target && value.field && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t(
|
||||
'certReq.condition.unknownField',
|
||||
'This path is not a field in the current schema yet — it will still be saved as typed.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_ARM: ConditionArm = { field: '', equals: '' };
|
||||
|
||||
/**
|
||||
* Authors one `FieldCondition` (`showWhen` on a section/field, or
|
||||
* `conditionExpression` on a document requirement).
|
||||
@@ -38,6 +232,11 @@ function coerce(raw: string, targetType: string | undefined): string | number |
|
||||
* 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,
|
||||
@@ -54,40 +253,35 @@ export function ConditionBuilder({
|
||||
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 operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
|
||||
const isAnyOf = Boolean(value?.anyOf);
|
||||
const arms = (value?.anyOf ?? []) as ConditionArm[];
|
||||
|
||||
function setField(field: string) {
|
||||
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
|
||||
function setArm(i: number, arm: ConditionArm) {
|
||||
const next = arms.slice();
|
||||
next[i] = arm;
|
||||
onChange({ anyOf: next });
|
||||
}
|
||||
|
||||
function setOperator(next: Operator) {
|
||||
if (!value?.field) return;
|
||||
const base: ConditionValue = { field: value.field };
|
||||
if (next === 'isSet') base.isSet = true;
|
||||
else if (next === 'in') base.in = [];
|
||||
else if (next === 'notEquals') base.notEquals = '';
|
||||
else base.equals = '';
|
||||
onChange(base);
|
||||
function addArm() {
|
||||
onChange({ anyOf: [...arms, { ...EMPTY_ARM }] });
|
||||
}
|
||||
|
||||
function setValueRaw(raw: string) {
|
||||
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 removeArm(i: number) {
|
||||
onChange({ anyOf: arms.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function setInValues(raws: string[]) {
|
||||
if (!value?.field) return;
|
||||
onChange({
|
||||
field: value.field,
|
||||
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
|
||||
});
|
||||
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 (
|
||||
@@ -102,116 +296,58 @@ export function ConditionBuilder({
|
||||
|
||||
{active && (
|
||||
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
|
||||
<Autocomplete
|
||||
label={t('certReq.condition.field', 'Field path')}
|
||||
placeholder="certificate.rank"
|
||||
description={t(
|
||||
'certReq.condition.fieldHelp',
|
||||
'Dot path into the form, e.g. sectionKey.fieldKey',
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t(
|
||||
'certReq.condition.anyOfEnable',
|
||||
'Any of these (the value can live on one of several fields)',
|
||||
)}
|
||||
data={targets.map((c) => c.path)}
|
||||
value={value?.field ?? ''}
|
||||
onChange={setField}
|
||||
checked={isAnyOf}
|
||||
onChange={(e) => toggleAnyOf(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label={t('certReq.condition.operator', 'Operator')}
|
||||
data={operators.map((op) => ({ value: op, label: op }))}
|
||||
value={operator}
|
||||
onChange={(v) => v && setOperator(v as Operator)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
|
||||
<Select
|
||||
label={t('certReq.condition.value', 'Value')}
|
||||
data={(target.field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label) || o.value,
|
||||
}))}
|
||||
value={String(value?.equals ?? value?.notEquals ?? '')}
|
||||
onChange={(v) => v !== null && setValueRaw(v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && (
|
||||
target?.field.type === 'BOOLEAN' ? (
|
||||
<Checkbox
|
||||
mt="xl"
|
||||
label={t('certReq.condition.value', 'Value')}
|
||||
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 ?? '')}
|
||||
onChange={(e) => setValueRaw(e.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{operator === 'in' && target?.field.type === 'SELECT' && (
|
||||
<Select
|
||||
label={t('certReq.condition.values', 'Any of')}
|
||||
data={(target.field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label) || o.value,
|
||||
}))}
|
||||
multiple={undefined}
|
||||
value={null}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
const current = (value?.in ?? []) as string[];
|
||||
if (!current.includes(v)) setInValues([...current, v]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operator === 'in' && target?.field.type !== 'SELECT' && (
|
||||
<TextInput
|
||||
label={t('certReq.condition.values', 'Any of (comma-separated)')}
|
||||
value={(value?.in ?? []).join(', ')}
|
||||
onChange={(e) =>
|
||||
setInValues(
|
||||
e.currentTarget.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{operator === 'in' && (value?.in?.length ?? 0) > 0 && (
|
||||
<Group gap={4}>
|
||||
{(value?.in ?? []).map((v, i) => (
|
||||
<Text
|
||||
key={`${v}-${i}`}
|
||||
fz="xs"
|
||||
px={6}
|
||||
py={2}
|
||||
bg="var(--mantine-color-gray-1)"
|
||||
style={{ borderRadius: 4, cursor: 'pointer' }}
|
||||
onClick={() => setInValues((value?.in ?? []).filter((_, idx) => idx !== i).map(String))}
|
||||
title={t('certReq.condition.removeValue', 'Click to remove')}
|
||||
>
|
||||
{String(v)} ×
|
||||
</Text>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{!target && value?.field && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t(
|
||||
'certReq.condition.unknownField',
|
||||
'This path is not a field in the current schema yet — it will still be saved as typed.',
|
||||
)}
|
||||
</Text>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -44,6 +44,7 @@ export function CertificateRequirementsPage() {
|
||||
'certReq.subtitle',
|
||||
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
|
||||
)}
|
||||
noMargin
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
|
||||
@@ -16,6 +16,15 @@ export function certificationColumns(
|
||||
header: t('certification.columns.description'),
|
||||
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.rank', 'Rank'),
|
||||
cell: ({ row }) =>
|
||||
row.original.rankKey ? (
|
||||
<Badge size="sm" variant="outline" color="violet">{row.original.rankKey}</Badge>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed">—</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Card,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
||||
import {IconPlus} from '@tabler/icons-react';
|
||||
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
useUpdateCertificationMutation,
|
||||
useDeleteCertificationMutation,
|
||||
} from '../../api/certification-api';
|
||||
import type { Certification } from '../../types/certification';
|
||||
import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
|
||||
import { certificationColumns } from './columns';
|
||||
import { certificationActionsColumn } from './actions';
|
||||
|
||||
@@ -33,7 +22,7 @@ function CertificationForm({
|
||||
}: {
|
||||
editing: Certification | null;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => void;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -41,6 +30,7 @@ function CertificationForm({
|
||||
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
|
||||
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
||||
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
||||
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -48,7 +38,7 @@ function CertificationForm({
|
||||
notify.error('Name fields are required');
|
||||
return;
|
||||
}
|
||||
onSubmit({ nameEn, nameAm, descEn, descAm }, !!editing);
|
||||
onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -59,6 +49,17 @@ function CertificationForm({
|
||||
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
|
||||
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Select
|
||||
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
|
||||
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
|
||||
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
|
||||
data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]}
|
||||
value={rankKey}
|
||||
onChange={setRankKey}
|
||||
size="sm"
|
||||
clearable
|
||||
searchable
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||
@@ -91,15 +92,17 @@ export function CertificationPage() {
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => {
|
||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateCert({ id: editing.id, name, description }).unwrap();
|
||||
// null clears a previously-set rank; undefined would leave it
|
||||
// untouched server-side, so the two are not interchangeable here.
|
||||
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap();
|
||||
notify.success(t('certification.updated'));
|
||||
} else {
|
||||
await createCert({ name, description }).unwrap();
|
||||
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
|
||||
notify.success(t('certification.created'));
|
||||
}
|
||||
resetForm();
|
||||
@@ -120,7 +123,8 @@ export function CertificationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
||||
if (isError)
|
||||
return <ErrorState title={t('certification.loadError')} onRetry={refetch} />;
|
||||
|
||||
const columns = [
|
||||
...certificationColumns(t, locale),
|
||||
@@ -134,17 +138,18 @@ export function CertificationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t('certification.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('certification.add')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<PageHeader
|
||||
title={t('certification.title')}
|
||||
subtitle={t('certification.subtitle')}
|
||||
noMargin
|
||||
action={
|
||||
!showForm && (
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('certification.add')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<CertificationForm
|
||||
|
||||
@@ -3,11 +3,30 @@ export interface LocalePair {
|
||||
am: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* STCW rank an exam certification is for — the join that lets the
|
||||
* schedule-exam picker offer only sittings valid for an application's rank.
|
||||
* Matches `LicenseApplication.formData.certificate.rank` / `rankEngine` /
|
||||
* `proficiency` on the backend. Not every certification is on the examined
|
||||
* ladder, so this stays a plain optional string rather than a required enum.
|
||||
*/
|
||||
export const RANK_KEY_OPTIONS = [
|
||||
{ value: 'OOW_DECK', label: 'Officer of the Watch (Deck)' },
|
||||
{ value: 'CHIEF_MATE', label: 'Chief Mate' },
|
||||
{ value: 'MASTER', label: 'Master' },
|
||||
{ value: 'OOW_ENGINE', label: 'Officer of the Watch (Engine)' },
|
||||
{ value: 'SECOND_ENGINEER', label: 'Second Engineer' },
|
||||
{ value: 'CHIEF_ENGINEER', label: 'Chief Engineer' },
|
||||
{ value: 'ABLE_SEAFARER_DECK', label: 'Able Seafarer Deck' },
|
||||
{ value: 'ABLE_SEAFARER_ENGINE', label: 'Able Seafarer Engine' },
|
||||
] as const;
|
||||
|
||||
export interface Certification {
|
||||
id: string;
|
||||
name: LocalePair;
|
||||
description: LocalePair;
|
||||
isActive: boolean;
|
||||
rankKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -20,6 +39,7 @@ export interface ListResponse<T> {
|
||||
export interface CreateCertificationPayload {
|
||||
name: LocalePair;
|
||||
description: LocalePair;
|
||||
rankKey?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCertificationPayload {
|
||||
@@ -27,4 +47,6 @@ export interface UpdateCertificationPayload {
|
||||
name?: LocalePair;
|
||||
description?: LocalePair;
|
||||
isActive?: boolean;
|
||||
/** Omit to leave unchanged, null to clear a previously-set rank. */
|
||||
rankKey?: string | null;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
|
||||
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
IconAlertTriangle,
|
||||
IconCreditCard,
|
||||
IconFileText,
|
||||
IconInbox,
|
||||
IconUserCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
useGetAssignedToMeQuery,
|
||||
useGetQueueQuery,
|
||||
useListSeafarerDocumentsQuery,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
type LicenseApplication,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
AdvancedTable,
|
||||
PageHeader,
|
||||
PageLoader,
|
||||
StatTile,
|
||||
WaitingFor,
|
||||
useServerTable,
|
||||
} from '@ema-platform/ui';
|
||||
import { dashboardQueueColumns } from './columns';
|
||||
|
||||
/**
|
||||
* Backoffice home.
|
||||
*
|
||||
* Shows the licence pipeline, which is the part of the platform that has real
|
||||
* data behind it. The previous version charted invented registration volumes
|
||||
* and a fictional breakdown of staff roles.
|
||||
* Every figure here is counted from a queue the officer can open, and each
|
||||
* tile navigates to the list it counted — a dashboard that cannot be drilled
|
||||
* into is a poster. Nothing is charted: the platform exposes queues, not time
|
||||
* series, and an earlier version of this page invented both a registration
|
||||
* trend and a staff-role breakdown rather than admit that.
|
||||
*
|
||||
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
|
||||
* queues are permission-gated and an officer without them simply gets no
|
||||
* count — never a broken page — so the tiles read `—` rather than `0`, which
|
||||
* would be a lie.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -27,6 +44,13 @@ export function DashboardPage() {
|
||||
const mine = useGetAssignedToMeQuery();
|
||||
const table = useServerTable();
|
||||
|
||||
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
|
||||
const seamanBooks = useListSeafarerDocumentsQuery({
|
||||
kind: 'SEAMAN_BOOK',
|
||||
status: 'PAYMENT_PENDING',
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
||||
}
|
||||
@@ -34,73 +58,143 @@ export function DashboardPage() {
|
||||
const unclaimed = queue.data?.items ?? [];
|
||||
const inProgress = mine.data?.items ?? [];
|
||||
const all = [...unclaimed, ...inProgress];
|
||||
const paged = table.paginate(unclaimed.slice(0, 8));
|
||||
|
||||
const stats = [
|
||||
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
|
||||
{ label: 'Assigned to me', value: inProgress.length, color: 'indigo' },
|
||||
{
|
||||
label: 'Needs applicant action',
|
||||
value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
label: 'Awaiting payment',
|
||||
value: all.filter((a) => a.status === 'PAYMENT_PENDING').length,
|
||||
color: 'yellow',
|
||||
},
|
||||
];
|
||||
const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
|
||||
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
|
||||
|
||||
/** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
|
||||
const byAge = [...unclaimed].sort((a, b) =>
|
||||
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
|
||||
);
|
||||
const paged = table.paginate(byAge.slice(0, 8));
|
||||
|
||||
/** `undefined` while loading or forbidden — rendered as "—", never as 0. */
|
||||
const countOf = (q: { data?: { total: number }; isError: boolean }) =>
|
||||
q.isError ? undefined : q.data?.total;
|
||||
|
||||
const show = (n: number | undefined) => (n === undefined ? '—' : n);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Dashboard
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="lg">
|
||||
Licence applications currently in the system.
|
||||
</Text>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Work waiting across the Authority's review queues."
|
||||
noMargin
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||
{stats.map((stat) => (
|
||||
<Card withBorder key={stat.label} padding="md" radius="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
|
||||
{stat.value}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<StatTile
|
||||
label="Awaiting claim"
|
||||
value={unclaimed.length}
|
||||
hint="Licence applications nobody has picked up"
|
||||
icon={IconInbox}
|
||||
tone="info"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Assigned to me"
|
||||
value={inProgress.length}
|
||||
hint="Your open licence reviews"
|
||||
icon={IconFileText}
|
||||
tone="neutral"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Needs applicant action"
|
||||
value={needsApplicant}
|
||||
hint="Returned for corrections"
|
||||
icon={IconAlertTriangle}
|
||||
tone="pending"
|
||||
/>
|
||||
<StatTile
|
||||
label="Awaiting payment"
|
||||
value={awaitingPayment}
|
||||
hint="Approved, fee not yet settled"
|
||||
icon={IconCreditCard}
|
||||
tone="warning"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder padding={0} radius="md">
|
||||
<Group justify="space-between" p="md" pb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Awaiting claim
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
|
||||
</Text>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
tableName="Awaiting claim"
|
||||
columns={dashboardQueueColumns}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
onRowClick={() => navigate('/licence-review')}
|
||||
refresh={queue.refetch}
|
||||
emptyText="Nothing waiting to be claimed."
|
||||
{/* Same 4-column track as the row above, so a two-tile row lines up with
|
||||
it instead of stretching each tile to half the page. */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<StatTile
|
||||
label="Seafarer registrations"
|
||||
value={show(countOf(registrations))}
|
||||
hint="Submitted, awaiting review"
|
||||
icon={IconUserCheck}
|
||||
tone="info"
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
/>
|
||||
</Card>
|
||||
</Container>
|
||||
<StatTile
|
||||
label="Seaman books"
|
||||
value={show(countOf(seamanBooks))}
|
||||
hint="Released, awaiting payment"
|
||||
icon={IconCreditCard}
|
||||
tone="pending"
|
||||
onClick={() => navigate('/seaman-book-queue')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid gutter="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<AdvancedTable<LicenseApplication>
|
||||
title="Awaiting claim — oldest first"
|
||||
tableName="Awaiting claim"
|
||||
columns={dashboardQueueColumns}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
|
||||
refresh={queue.refetch}
|
||||
isLoading={queue.isFetching}
|
||||
emptyText="Nothing waiting to be claimed."
|
||||
toolbar={
|
||||
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
|
||||
Open queue
|
||||
</Anchor>
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Paper withBorder radius="lg" p="lg" h="100%">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
Longest waiting
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Unclaimed applications, by how long they have sat.
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{byAge.slice(0, 5).map((app) => (
|
||||
<Group key={app.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Anchor
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||
>
|
||||
{app.applicationNumber}
|
||||
</Anchor>
|
||||
<WaitingFor
|
||||
since={app.submittedAt ?? app.createdAt}
|
||||
slaDays={
|
||||
app.licenseType?.slaHours ? app.licenseType.slaHours / 24 : undefined
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
{byAge.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing waiting.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
OPEN: 'red',
|
||||
UNDER_REVIEW: 'yellow',
|
||||
RESOLVED: 'teal',
|
||||
DISMISSED: 'gray',
|
||||
const STATUS_TONE: Record<ExamIncidentStatus, StatusTone> = {
|
||||
OPEN: 'danger',
|
||||
UNDER_REVIEW: 'warning',
|
||||
RESOLVED: 'success',
|
||||
DISMISSED: 'neutral',
|
||||
};
|
||||
|
||||
export function examIncidentColumns(
|
||||
@@ -56,13 +58,12 @@ export function examIncidentColumns(
|
||||
{
|
||||
header: t('exam.incidents.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status] ?? 'neutral'}
|
||||
label={t(`exam.incidentStatus.${row.original.status}`)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[row.original.status] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.incidentStatus.${row.original.status}`)}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import {
|
||||
@@ -57,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">
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Badge, Text } from "@mantine/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Exam } from "../../types/exam";
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
ACTIVE: "blue",
|
||||
COMPLETED: "teal",
|
||||
CANCELLED: "red",
|
||||
POSTPONED: "orange",
|
||||
PUBLISHED: "green",
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
PENDING: 'neutral',
|
||||
ACTIVE: 'info',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
POSTPONED: 'pending',
|
||||
PUBLISHED: 'success',
|
||||
};
|
||||
|
||||
export function examColumns(
|
||||
@@ -72,9 +74,12 @@ export function examColumns(
|
||||
{
|
||||
header: t("exam.columns.status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{t(`exam.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status]}
|
||||
label={t(`exam.status.${row.original.status}`)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Modal,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
TextInput,
|
||||
Textarea,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
@@ -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,26 +498,25 @@ export function ExamPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t("exam.title")}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("exam.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.add")}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
<PageHeader
|
||||
title={t("exam.title")}
|
||||
subtitle={t("exam.subtitle")}
|
||||
noMargin
|
||||
action={
|
||||
!showForm && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.add")}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<ExamForm
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Item } from '../../api/item-api';
|
||||
|
||||
const STATUS_COLORS: Record<Item['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
ACTIVE: 'green',
|
||||
ARCHIVED: 'orange',
|
||||
const STATUS_TONES: Record<Item['status'], StatusTone> = {
|
||||
DRAFT: 'neutral',
|
||||
ACTIVE: 'success',
|
||||
ARCHIVED: 'pending',
|
||||
};
|
||||
|
||||
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
|
||||
@@ -14,7 +15,7 @@ export function itemColumns(showDate: (date: string) => string): AdvancedColumn<
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={STATUS_COLORS[row.original.status]}>{row.original.status}</Badge>
|
||||
<StatusBadge tone={STATUS_TONES[row.original.status]} label={row.original.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Stack, Title, Paper } from '@mantine/core';
|
||||
import {Stack, Paper} from '@mantine/core';
|
||||
import { ItemTable } from '../components/ItemTable';
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
export function ItemPage() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>Items</Title>
|
||||
<PageHeader title="Items" noMargin />
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<ItemTable />
|
||||
</Paper>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const LICENSE_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'green',
|
||||
EXPIRED: 'yellow',
|
||||
SUSPENDED: 'orange',
|
||||
CANCELLED: 'red',
|
||||
SUPERSEDED: 'gray',
|
||||
const LICENSE_STATUS_TONES: Record<string, StatusTone> = {
|
||||
ACTIVE: 'success',
|
||||
EXPIRED: 'warning',
|
||||
SUSPENDED: 'pending',
|
||||
CANCELLED: 'danger',
|
||||
SUPERSEDED: 'neutral',
|
||||
};
|
||||
|
||||
export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
|
||||
@@ -70,13 +72,12 @@ export function licenseRegisterColumns(
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={LICENSE_STATUS_TONES[row.original.status] ?? 'neutral'}
|
||||
label={row.original.status}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={LICENSE_STATUS_COLORS[row.original.status] ?? 'gray'}
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Textarea} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -151,21 +139,19 @@ export function LicenseRegisterPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Licence register</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Certificate № or company"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
<PageHeader
|
||||
title="Licence register"
|
||||
subtitle={`${data?.total ?? 0} issued licence${(data?.total ?? 0) === 1 ? '' : 's'}`}
|
||||
action={
|
||||
<TextInput
|
||||
placeholder="Certificate № or company"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable<IssuedLicense>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,16 +501,11 @@ export function LicenseQueuePage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{queueTitle}</Title>
|
||||
{typeCode && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<PageHeader
|
||||
title={queueTitle}
|
||||
subtitle={typeCode ? t(`nav.type${typeCode}`, { defaultValue: typeCode }) : undefined}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={density}
|
||||
@@ -534,8 +529,9 @@ export function LicenseQueuePage() {
|
||||
>
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Saved views, counted. */}
|
||||
<Tabs
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
useLocalized,
|
||||
useApproveDocumentsMutation,
|
||||
useAssignApplicationMutation,
|
||||
useClaimApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
@@ -198,6 +199,7 @@ export function LicenseReviewPage() {
|
||||
[requirements],
|
||||
);
|
||||
|
||||
const [claimApplication] = useClaimApplicationMutation();
|
||||
const [completeReview] = useCompleteReviewMutation();
|
||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||
const [approveDocuments] = useApproveDocumentsMutation();
|
||||
@@ -531,8 +533,12 @@ export function LicenseReviewPage() {
|
||||
try {
|
||||
switch (action.id) {
|
||||
case "claim":
|
||||
// Claim is fired from the queue in practice; kept here for the case
|
||||
// where an officer opens an unclaimed application directly.
|
||||
// Usually fired from the queue, but an officer can also open an
|
||||
// unclaimed application directly and claim it from here.
|
||||
await run(
|
||||
() => claimApplication(id).unwrap(),
|
||||
t("review.done.claim", "Application claimed"),
|
||||
);
|
||||
break;
|
||||
case "complete-review":
|
||||
await run(
|
||||
@@ -724,7 +730,7 @@ export function LicenseReviewPage() {
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{headerName}</Title>
|
||||
<Title order={2}>{headerName}</Title>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.applicationNumber}
|
||||
@@ -1156,6 +1162,7 @@ export function LicenseReviewPage() {
|
||||
|
||||
<ScheduleExamModal
|
||||
opened={scheduleExamOpen}
|
||||
applicationId={id}
|
||||
applicantName={
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Paper,
|
||||
Text,
|
||||
Grid,
|
||||
Modal,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Group, Button, Paper, Text, Grid, Modal, ActionIcon, Tooltip, Loader, Center, Alert} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui';
|
||||
import { ModalFooter, notify, PageHeader, PageLoader, useErrorHandler } from '@ema-platform/ui';
|
||||
import { LocationTree } from '../components/LocationTree';
|
||||
import { LocationDetail } from '../components/LocationDetail';
|
||||
import { LocationForm } from '../components/LocationForm';
|
||||
@@ -108,9 +94,11 @@ export function LocationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>{t('location.title')}</Title>
|
||||
<Group gap="sm">
|
||||
<PageHeader
|
||||
title={t('location.title')}
|
||||
noMargin
|
||||
action={
|
||||
<Group gap="sm">
|
||||
{locationTypes.length > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -135,8 +123,9 @@ export function LocationPage() {
|
||||
<IconSettings size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{locationTypes.length === 0 && (
|
||||
<Alert
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Badge, Card, Center, Container, Grid, Group, Loader, SimpleGrid, Stack, Text} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -74,12 +62,10 @@ export function LogisticsHeadDashboardPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Logistics overview
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="lg">
|
||||
Licence applications currently in the department.
|
||||
</Text>
|
||||
<PageHeader
|
||||
title="Logistics overview"
|
||||
subtitle="Licence applications currently in the department."
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||
{stats.map((stat) => (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import {
|
||||
seaServiceDays,
|
||||
type MedicalCertificate,
|
||||
@@ -18,10 +20,10 @@ export function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
|
||||
SUBMITTED: 'yellow',
|
||||
VERIFIED: 'teal',
|
||||
REJECTED: 'red',
|
||||
const STATUS_TONE: Record<SeafarerRecordStatus, StatusTone> = {
|
||||
SUBMITTED: 'warning',
|
||||
VERIFIED: 'success',
|
||||
REJECTED: 'danger',
|
||||
};
|
||||
|
||||
/** Only meaningful now the queue can show ruled records too. */
|
||||
@@ -33,9 +35,12 @@ function statusColumn<T extends { status: SeafarerRecordStatus }>(
|
||||
label: t('recordVerification.columns.status', 'Status'),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{t(`recordVerification.status.${row.original.status}`, row.original.status)}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status]}
|
||||
label={t(`recordVerification.status.${row.original.status}`, row.original.status)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Badge, Button, Center, Container, Group, Loader, Modal, Paper, SegmentedControl, Stack, Text, Textarea} from '@mantine/core';
|
||||
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
|
||||
import {
|
||||
AdvancedTable,
|
||||
notify,
|
||||
PdfPreviewModal,
|
||||
type AdvancedColumn,
|
||||
} from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, PdfPreviewModal, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -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,26 +354,28 @@ 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
|
||||
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
||||
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{isMedical
|
||||
? t(
|
||||
'recordVerification.medicalSubtitle',
|
||||
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
|
||||
)
|
||||
: t(
|
||||
'recordVerification.seaServiceSubtitle',
|
||||
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
|
||||
)}
|
||||
</Text>
|
||||
<PageHeader
|
||||
title={
|
||||
isMedical
|
||||
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
||||
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')
|
||||
}
|
||||
subtitle={
|
||||
isMedical
|
||||
? t(
|
||||
'recordVerification.medicalSubtitle',
|
||||
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
|
||||
)
|
||||
: t(
|
||||
'recordVerification.seaServiceSubtitle',
|
||||
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{statusFilter}
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Center, Group, Loader, Modal, NumberInput, Paper, Stack, Switch, Text, TextInput, ThemeIcon, Tooltip} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCreditCard,
|
||||
IconInfoCircle,
|
||||
IconLock,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
notify,
|
||||
ModalFooter,
|
||||
AdvancedTable,
|
||||
useServerTable,
|
||||
type AdvancedColumn,
|
||||
PageLoader,
|
||||
} from '@ema-platform/ui';
|
||||
import { AdvancedTable, ModalFooter, notify, PageHeader, PageLoader, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
@@ -92,20 +68,19 @@ export function PaymentConfigPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{t(
|
||||
'paymentConfig.subtitle',
|
||||
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconCreditCard size={22} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<PageHeader
|
||||
title={t('paymentConfig.title', 'Payment configuration')}
|
||||
subtitle={t(
|
||||
'paymentConfig.subtitle',
|
||||
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||
)}
|
||||
noMargin
|
||||
action={
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconCreditCard size={22} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
variant="light"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,87 +141,181 @@ function QuestionForm({
|
||||
editing: Question | null;
|
||||
certOptions: { value: string; label: string }[];
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: {
|
||||
certificationId: string;
|
||||
titleEn: string;
|
||||
titleAm: string;
|
||||
form: string;
|
||||
points: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
draftOptions: DraftOption[];
|
||||
}, isEdit: boolean) => void;
|
||||
onSubmit: (
|
||||
values: {
|
||||
certificationId: string;
|
||||
titleEn: string;
|
||||
titleAm: string;
|
||||
form: string;
|
||||
points: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
draftOptions: DraftOption[];
|
||||
},
|
||||
isEdit: boolean,
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
|
||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
|
||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
|
||||
const [certificationId, setCertificationId] = useState<string | null>(
|
||||
editing?.certificationId ?? null,
|
||||
);
|
||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
|
||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
|
||||
|
||||
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
||||
const [points, setPoints] = useState<number>(editing?.points ?? 0);
|
||||
const [days, setDays] = useState(editing?.time?.days ?? 0);
|
||||
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
|
||||
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
|
||||
const [draftOptions, setDraftOptions] = useState<DraftOption[]>(BLANK_DRAFT_OPTIONS);
|
||||
const [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;
|
||||
draftOptions: DraftOption[];
|
||||
}, isEdit: boolean) => {
|
||||
const handleSubmit = async (
|
||||
values: {
|
||||
certificationId: string;
|
||||
titleEn: string;
|
||||
titleAm: string;
|
||||
form: string;
|
||||
points: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
draftOptions: DraftOption[];
|
||||
},
|
||||
isEdit: boolean,
|
||||
) => {
|
||||
const title = { en: values.titleEn, am: values.titleAm };
|
||||
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
|
||||
const time = {
|
||||
days: values.days,
|
||||
hours: values.hours,
|
||||
minutes: values.minutes,
|
||||
};
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
|
||||
notify.success(t('question.updated'));
|
||||
await updateQ({
|
||||
id: editing.id,
|
||||
certificationId: values.certificationId,
|
||||
title,
|
||||
form: values.form as QuestionForm,
|
||||
points: values.points,
|
||||
time,
|
||||
}).unwrap();
|
||||
notify.success(t("question.updated"));
|
||||
} else {
|
||||
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) => ({
|
||||
text: { en: o.textEn, am: o.textAm },
|
||||
isCorrect: o.isCorrect,
|
||||
}));
|
||||
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')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
<PageHeader
|
||||
title={t("question.title")}
|
||||
noMargin
|
||||
action={
|
||||
!showForm && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]}
|
||||
hideOnly
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("question.addQuestion")}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<QuestionForm
|
||||
@@ -358,71 +535,101 @@ 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}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('question.noQuestions')}
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t("question.noQuestions")}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(reviewTarget)}
|
||||
onClose={() => setReviewTarget(null)}
|
||||
title={t('question.qc.reviewTitle')}
|
||||
title={t("question.qc.reviewTitle")}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text>
|
||||
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text>
|
||||
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content">
|
||||
<Text fz="sm" fw={500}>
|
||||
{reviewTarget?.title?.[locale]}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t("question.qc.onlyApprovedUsable")}
|
||||
</Text>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={
|
||||
reviewOutcome === "APPROVED"
|
||||
? "teal"
|
||||
: reviewOutcome === "REJECTED"
|
||||
? "red"
|
||||
: "dark"
|
||||
}
|
||||
w="fit-content"
|
||||
>
|
||||
{t(`question.qc.${reviewOutcome}`)}
|
||||
</Badge>
|
||||
<Textarea
|
||||
label={t('question.qc.remark')}
|
||||
label={t("question.qc.remark")}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={reviewRemark}
|
||||
onChange={(e) => setReviewRemark(e.currentTarget.value)}
|
||||
required={reviewOutcome !== 'APPROVED'}
|
||||
required={reviewOutcome !== "APPROVED"}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setReviewTarget(null)}>
|
||||
{t('question.cancel')}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setReviewTarget(null)}
|
||||
>
|
||||
{t("question.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" loading={isReviewing} onClick={handleReview}>
|
||||
{t(`question.qc.${reviewOutcome === 'APPROVED' ? 'approve' : reviewOutcome === 'REJECTED' ? 'reject' : 'retire'}`)}
|
||||
{t(
|
||||
`question.qc.${reviewOutcome === "APPROVED" ? "approve" : reviewOutcome === "REJECTED" ? "reject" : "retire"}`,
|
||||
)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||
<Modal
|
||||
opened={deleteOpened}
|
||||
onClose={closeDelete}
|
||||
title={t("question.confirmDelete")}
|
||||
size="sm"
|
||||
>
|
||||
<Text mb="md">{t("question.deleteConfirmText")}</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">
|
||||
{t("question.cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">
|
||||
{t("question.delete")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Button, Center, Group, Loader, Modal, Paper, Select, Stack, Text, Textarea} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
@@ -75,12 +62,11 @@ export function ExamAppealsPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2}>{t('result.appeals.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('result.appeals.subtitle')}
|
||||
</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={t('result.appeals.title')}
|
||||
subtitle={t('result.appeals.subtitle')}
|
||||
noMargin
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<AdvancedTable<ExamAppeal>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
|
||||
import { Badge, Box, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Result, ResultReviewStatus } from '../../types/result';
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
PASSED: 'teal',
|
||||
FAILED: 'red',
|
||||
export const STATUS_TONE: Record<string, StatusTone> = {
|
||||
PASSED: 'success',
|
||||
FAILED: 'danger',
|
||||
};
|
||||
|
||||
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
|
||||
@@ -46,20 +48,22 @@ export function resultColumns(
|
||||
{
|
||||
header: t('result.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[row.original.status]}
|
||||
label={t(`result.status.${row.original.status}`)}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[row.original.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${STATUS_TONE_COLOR[STATUS_TONE[row.original.status]]}-6)`,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
import { useState, useCallback, type ElementType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Table,
|
||||
Badge,
|
||||
Modal,
|
||||
Text,
|
||||
Paper,
|
||||
Card,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Button,
|
||||
ThemeIcon,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconUser,
|
||||
IconCertificate,
|
||||
IconDeviceFloppy,
|
||||
IconPlus,
|
||||
IconClipboardList,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconChartBar,
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
||||
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
|
||||
import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
||||
@@ -53,7 +22,7 @@ import { useGetExamsQuery } from '../../../exam/api/exam-api';
|
||||
import { RecordResultModal } from '../../components/RecordResultModal';
|
||||
import type { Result, ResultBreakdown } from '../../types/result';
|
||||
import type { Exam } from '../../../exam/types/exam';
|
||||
import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns';
|
||||
import { resultColumns, STATUS_TONE, REVIEW_COLOR } from './columns';
|
||||
import { resultActionsColumn, type QcAction } from './actions';
|
||||
|
||||
function ResultStat({
|
||||
@@ -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,12 +280,12 @@ export function ResultPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t('result.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
<PageHeader
|
||||
title={t('result.title')}
|
||||
subtitle={t('result.subtitle')}
|
||||
noMargin
|
||||
action={
|
||||
<Group gap="sm">
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -335,8 +304,9 @@ export function ResultPage() {
|
||||
{t('result.record')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
</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,23 +339,17 @@ export function ResultPage() {
|
||||
style={{ width: 280 }}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('result.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('result.noItems')}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('result.noItems')}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={detailOpened}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
|
||||
import {Badge, Container, Select, Text, TextInput} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type SeafarerDocumentRow,
|
||||
type SeafarerDocumentStatus,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageHeader, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -91,6 +91,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Waiting',
|
||||
accessorKey: 'submittedAt',
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<WaitingFor
|
||||
since={row.original.submittedAt}
|
||||
done={row.original.status === 'ISSUED' || row.original.status === 'REJECTED'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
@@ -106,40 +117,39 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
{SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Requests released by an approved seafarer registration: confirm payment, schedule the
|
||||
collection date, then issue.
|
||||
</Text>
|
||||
<Group mb="md" gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search number, name or seafarer №…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPage(0);
|
||||
}}
|
||||
w={280}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_FILTERS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v as SeafarerDocumentStatus | null);
|
||||
setPage(0);
|
||||
}}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
<PageHeader
|
||||
title={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue`}
|
||||
subtitle="Requests released by an approved seafarer registration: confirm payment, schedule the collection date, then issue."
|
||||
/>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
|
||||
toolbar={
|
||||
<>
|
||||
<TextInput
|
||||
placeholder="Search number, name or seafarer №…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPage(0);
|
||||
}}
|
||||
w={260}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_FILTERS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v as SeafarerDocumentStatus | null);
|
||||
setPage(0);
|
||||
}}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
itemCount={data?.total ?? 0}
|
||||
pageIndex={page}
|
||||
onPageChange={setPage}
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||
import {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, SimpleGrid, Stack, Text, Textarea, ThemeIcon, rem} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
@@ -29,26 +14,41 @@ import {
|
||||
useRejectSeafarerDocumentMutation,
|
||||
useScheduleSeafarerDocumentMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, notify } from '@ema-platform/ui';
|
||||
import { AmharicDatePicker, notify, PageHeader } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const;
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" component="div">
|
||||
{value ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||
<Text fz="sm" fw={600} component="div">{value ?? '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
icon,
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<ThemeIcon variant="light" color={color} size={26} radius="md">{icon}</ThemeIcon>
|
||||
<Text fw={700} fz="sm">{title}</Text>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,12 +123,10 @@ export function SeafarerDocumentReviewPage() {
|
||||
>
|
||||
Back to queue
|
||||
</Button>
|
||||
<Group justify="space-between" align="flex-start" mb="md">
|
||||
<div>
|
||||
<Title order={3}>
|
||||
{kindLabel} — {applicant?.name ?? '—'}
|
||||
</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<PageHeader
|
||||
title={`${kindLabel} — ${applicant?.name ?? '—'}`}
|
||||
meta={
|
||||
<>
|
||||
<Text size="sm" c="dimmed" ff="monospace">
|
||||
{document.requestNumber}
|
||||
</Text>
|
||||
@@ -140,9 +138,10 @@ export function SeafarerDocumentReviewPage() {
|
||||
{document.documentNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||
<Button loading={confirming} onClick={() => run(() => confirmPayment(id).unwrap(), 'Payment confirmed')}>
|
||||
@@ -174,8 +173,9 @@ export function SeafarerDocumentReviewPage() {
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{document.status === 'REJECTED' && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
|
||||
@@ -183,61 +183,49 @@ export function SeafarerDocumentReviewPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="lg" mb="md">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>
|
||||
{(applicant?.name ?? '??').split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Seafarer
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
<Row label="Name" value={applicant?.name} />
|
||||
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
||||
<Row
|
||||
label="Registration"
|
||||
value={
|
||||
applicant?.registrationId ? (
|
||||
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
|
||||
{applicant.registrationNumber}
|
||||
</Link>
|
||||
) : (
|
||||
applicant?.registrationNumber
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Text fw={700} fz="lg" lh={1.2}>{applicant?.name ?? '—'}</Text>
|
||||
<Group gap={6} mt={4}>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{applicant?.seafarerNumber ?? '—'}</Text>
|
||||
{applicant?.registrationId && (
|
||||
<>
|
||||
<Text fz="xs" c="dimmed">·</Text>
|
||||
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
|
||||
<Text fz="xs" c="blue.6">{applicant.registrationNumber}</Text>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Payment
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||
<Row label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
||||
<Row label="Provider" value={payment?.provider} />
|
||||
<Row label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Issuance
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||
<Row label="Document №" value={document.documentNumber} />
|
||||
<Row label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
||||
<Row label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<SectionCard title="Payment" icon={<IconCash size={14} />} color="teal">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Stat label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||
<Stat label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||
<Stat label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
||||
<Stat label="Provider" value={payment?.provider} />
|
||||
<Stat label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Issuance" icon={<IconFileCertificate size={14} />} color="violet">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Stat label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||
<Stat label="Document №" value={document.documentNumber} />
|
||||
<Stat label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
||||
<Stat label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
</SimpleGrid>
|
||||
|
||||
<Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
|
||||
<Stack>
|
||||
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
|
||||
import {Container, Select, Text, TextInput} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_STATUS_COLORS,
|
||||
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
displaySeafarerAnswer,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
type SeafarerRegistration,
|
||||
type SeafarerRegistrationStatus,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageHeader, StatusBadge, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -78,13 +78,25 @@ export function SeafarerRegistrationQueuePage() {
|
||||
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Waiting',
|
||||
accessorKey: 'submittedAt',
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<WaitingFor
|
||||
since={row.original.submittedAt}
|
||||
done={row.original.status === 'APPROVED' || row.original.status === 'REJECTED'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[row.original.status]}>
|
||||
{SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={SEAFARER_REGISTRATION_STATUS_TONES[row.original.status]}
|
||||
label={SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -93,40 +105,39 @@ export function SeafarerRegistrationQueuePage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
Seafarer Registration Queue
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and
|
||||
BTC applications.
|
||||
</Text>
|
||||
<Group mb="md" gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search number, name or ID…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPage(0);
|
||||
}}
|
||||
w={280}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_FILTERS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v as SeafarerRegistrationStatus | null);
|
||||
setPage(0);
|
||||
}}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
<PageHeader
|
||||
title="Seafarer Registration Queue"
|
||||
subtitle="Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and BTC applications."
|
||||
/>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
tableName="Seafarer registrations"
|
||||
toolbar={
|
||||
<>
|
||||
<TextInput
|
||||
placeholder="Search number, name or ID…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPage(0);
|
||||
}}
|
||||
w={260}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_FILTERS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v as SeafarerRegistrationStatus | null);
|
||||
setPage(0);
|
||||
}}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
itemCount={data?.total ?? 0}
|
||||
pageIndex={page}
|
||||
onPageChange={setPage}
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||
SEAFARER_REGISTRATION_SECTIONS,
|
||||
SEAFARER_REGISTRATION_STATUS_COLORS,
|
||||
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
displaySeafarerAnswer,
|
||||
extractErrorMessage,
|
||||
@@ -31,7 +15,7 @@ import {
|
||||
useRejectSeafarerRegistrationMutation,
|
||||
useRequestSeafarerRegistrationChangesMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import { applicantName } from './SeafarerRegistrationQueuePage';
|
||||
|
||||
@@ -109,24 +93,26 @@ export function SeafarerRegistrationReviewPage() {
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/seafarer-registrations')} mb="xs">
|
||||
Back to queue
|
||||
</Button>
|
||||
<Group justify="space-between" align="flex-start" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{applicantName(registration)}</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<PageHeader
|
||||
title={applicantName(registration)}
|
||||
meta={
|
||||
<>
|
||||
<Text size="sm" c="dimmed" ff="monospace">
|
||||
{registration.registrationNumber}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
|
||||
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
|
||||
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
||||
/>
|
||||
{registration.seafarerNumber && (
|
||||
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
{registration.seafarerNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
{canDecide && (
|
||||
<>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REQUEST_ADJUSTMENT]} hideOnly>
|
||||
@@ -146,8 +132,9 @@ export function SeafarerRegistrationReviewPage() {
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{registration.status === 'RESUBMIT_REQUIRED' && (
|
||||
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
|
||||
@@ -167,7 +154,7 @@ export function SeafarerRegistrationReviewPage() {
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
{section.title}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
{section.fields
|
||||
.filter((f) => f !== 'passportExpiry' || registration.passportNumber)
|
||||
@@ -192,7 +179,7 @@ export function SeafarerRegistrationReviewPage() {
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Documents
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
{slots.map((slot) => {
|
||||
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
import { PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useState } from 'react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {ActionIcon, Avatar, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
@@ -32,6 +15,7 @@ import {
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -59,71 +43,77 @@ const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
|
||||
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
|
||||
|
||||
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
|
||||
const STATUS_COLOR: Record<string, string> = { Active: 'teal', Inactive: 'gray', Suspended: 'red' };
|
||||
const STATUS_TONE: Record<string, StatusTone> = { Active: 'success', Inactive: 'neutral', Suspended: 'danger' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||
<Text fz="sm" fw={600}>{value ?? '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
|
||||
if (!sf) return null;
|
||||
const initials = sf.name.split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase();
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} — {sf.id}</Text></Group>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Medical Expiry</Text>
|
||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
|
||||
<Modal opened={opened} onClose={onClose} size="xl" radius="lg" padding={0} withCloseButton={false}>
|
||||
<Stack gap={0}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" wrap="nowrap" p="lg" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>{initials}</Avatar>
|
||||
<div>
|
||||
<Text fw={700} fz="lg" lh={1.2}>{sf.name}</Text>
|
||||
<Group gap={6} mt={4}>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{sf.id}</Text>
|
||||
<Text fz="xs" c="dimmed">·</Text>
|
||||
<Text fz="xs" c="dimmed">{sf.rank}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<StatusBadge tone={STATUS_TONE[sf.status]} label={sf.status} variant="light" />
|
||||
<ActionIcon variant="subtle" color="gray" onClick={onClose}><IconX size={16} /></ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{sf.cocCerts.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
|
||||
<Table fz="xs" verticalSpacing="xs">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Stack gap="lg" p="lg">
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="lg">
|
||||
<Stat label="Nationality" value={sf.nationality} />
|
||||
<Stat label="Date of Birth" value={sf.dob} />
|
||||
<Stat label="Seaman Book №" value={sf.seamanBookNo} />
|
||||
<Stat label="SB Expiry" value={sf.seamanBookExpiry} />
|
||||
<Stat label="BTC №" value={sf.btcNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
|
||||
<Stat label="BSID №" value={sf.bsidNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
|
||||
<Stat label="Medical" value={<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="sm">{sf.medicalStatus}</Badge>} />
|
||||
<Stat label="Medical Expiry" value={sf.medicalExpiry} />
|
||||
</SimpleGrid>
|
||||
|
||||
{sf.cocCerts.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="sm" tt="uppercase">CoC / CoP Certificates</Text>
|
||||
<Stack gap="xs">
|
||||
{sf.cocCerts.map((c) => (
|
||||
<Table.Tr key={c.no}>
|
||||
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
<Group key={c.no} justify="space-between" wrap="nowrap" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="violet" size={30} radius="md"><IconCertificate size={15} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{c.type}</Text>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{c.no}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">Expires {c.expiry}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
@@ -169,10 +159,11 @@ export function SeafarerRegistryPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registry</Title>
|
||||
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Seafarer Registry"
|
||||
subtitle="Search and view all registered seafarers, their documents, and certificate status"
|
||||
noMargin
|
||||
/>
|
||||
|
||||
{/* KPIs */}
|
||||
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
|
||||
@@ -264,7 +255,12 @@ export function SeafarerRegistryPage() {
|
||||
: <Text fz="xs" c="dimmed">—</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[sf.status]}
|
||||
label={sf.status}
|
||||
variant="light"
|
||||
size="xs"
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>
|
||||
|
||||
@@ -336,7 +336,7 @@ export function VesselRegistrationFormBuilderPage() {
|
||||
<IconSettings size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Vessel Registration Form Builder</Title>
|
||||
<Title order={2}>Vessel Registration Form Builder</Title>
|
||||
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import type { Vessel } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
||||
REGISTERED: 'green',
|
||||
SUSPENDED: 'orange',
|
||||
DEREGISTERED: 'gray',
|
||||
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
|
||||
REGISTERED: 'success',
|
||||
SUSPENDED: 'pending',
|
||||
DEREGISTERED: 'neutral',
|
||||
};
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -63,13 +65,12 @@ export function vesselRegistrationQueueColumns(
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
tone={VESSEL_STATUS_TONES[row.original.status]}
|
||||
label={row.original.status}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={VESSEL_STATUS_COLORS[row.original.status]}
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Card, Container, Drawer, Group, Loader, Modal, Select, Stack, Table, Text, TextInput, Textarea} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -238,26 +221,28 @@ export function VesselRegistrationQueuePage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Vessel register</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
<PageHeader
|
||||
title="Vessel register"
|
||||
subtitle={
|
||||
<>
|
||||
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'} —
|
||||
pending registrations are reviewed in the{' '}
|
||||
<Text component={Link} to="/licence-review" inherit c="blue">
|
||||
licence queue
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Name, registration № or IMO"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<TextInput
|
||||
placeholder="Name, registration № or IMO"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<AdvancedTable
|
||||
tableName="Vessel register"
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Alert, Container, Group, Text, Title } from '@mantine/core';
|
||||
import {Alert, Container, Group, Text} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
|
||||
import {
|
||||
ApiErrorAlert,
|
||||
EmptyState,
|
||||
PageLoader,
|
||||
notify,
|
||||
} from '@ema-platform/ui';
|
||||
import { ApiErrorAlert, EmptyState, notify, PageHeader, PageLoader } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
downloadAuthedFile,
|
||||
@@ -85,16 +80,14 @@ export function VesselRegistrationReportPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Vessel registration report</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{report
|
||||
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
||||
: 'The national vessel register at a glance.'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<PageHeader
|
||||
title="Vessel registration report"
|
||||
subtitle={
|
||||
report
|
||||
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
||||
: 'The national vessel register at a glance.'
|
||||
}
|
||||
/>
|
||||
|
||||
<ReportFilters
|
||||
query={query}
|
||||
|
||||
@@ -11,6 +11,10 @@ export const am: Translations = {
|
||||
tagline: "የቁጥጥር ማዕከል",
|
||||
},
|
||||
|
||||
a11y: {
|
||||
skipToContent: "ወደ ዋናው ይዘት ዝለል",
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
|
||||
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
|
||||
@@ -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: "ሁሉንም ሰነዶች አውርድ",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,30 +269,29 @@ 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}
|
||||
hiddenFrom="sm"
|
||||
size="75%"
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
>
|
||||
<AppSidebar
|
||||
navItems={sections}
|
||||
collapsed={false}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
onNavigate={(item) => {
|
||||
go(item);
|
||||
closeNav();
|
||||
}}
|
||||
brandName={t('app.name')}
|
||||
brandSubtitle={t('app.authority')}
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={closeNav}
|
||||
hiddenFrom="sm"
|
||||
size="75%"
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
>
|
||||
<AppSidebar
|
||||
navItems={sections}
|
||||
collapsed={false}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
onNavigate={(item) => {
|
||||
go(item);
|
||||
closeNav();
|
||||
}}
|
||||
brandName={t('app.name')}
|
||||
brandSubtitle={t('app.authority')}
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</Drawer>
|
||||
</AppShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RequirePermission,
|
||||
LICENSE_PERMISSIONS as P,
|
||||
} from '@ema-platform/auth';
|
||||
import { ThemeGallery } from '@ema-platform/ui';
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
@@ -68,6 +69,9 @@ const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: '/um/*', element: <UserManagementPage /> },
|
||||
// Theme visual-regression surface. Unauthenticated by design — it renders
|
||||
// only static primitives, so it needs no API and cannot flake.
|
||||
{ path: '/__gallery', element: <ThemeGallery /> },
|
||||
{ path: '/', element: <LandingRoute /> },
|
||||
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
|
||||
{
|
||||
|
||||
@@ -4,6 +4,10 @@ import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/spotlight/styles.css';
|
||||
// After Mantine's CSS (it defines the variables these tokens resolve to),
|
||||
// before the app's own, which may override them. Relative because the
|
||||
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
|
||||
import '../../../libs/shared/src/lib/theme/semantic.css';
|
||||
import './styles.css';
|
||||
import './app/i18n/config';
|
||||
import { App } from './app/app';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
|
||||
Without it every Amharic string in the app renders in whatever the OS
|
||||
happens to substitute — different on Windows, macOS and Android. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
139
apps/e2e/visual/theme.spec.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 215 KiB |
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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,24 +440,23 @@ 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', {
|
||||
days: formDays,
|
||||
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
|
||||
})}
|
||||
{dateError ??
|
||||
t('seaRecords.seaService.daysServed', {
|
||||
days: formDays,
|
||||
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
<Textarea
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -10,6 +10,10 @@ export const am: Translations = {
|
||||
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
||||
},
|
||||
|
||||
a11y: {
|
||||
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
||||
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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';
|
||||
|
||||
156
libs/shared/src/lib/theme/base-theme.ts
Normal 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%)',
|
||||
},
|
||||
});
|
||||
@@ -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({
|
||||
primaryColor: 'emaPrimary',
|
||||
colors: {
|
||||
emaPrimary,
|
||||
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' },
|
||||
},
|
||||
/**
|
||||
* 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: emaBlue,
|
||||
emaSecondary,
|
||||
},
|
||||
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%)',
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
66
libs/shared/src/lib/theme/palettes.ts
Normal 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;
|
||||
29
libs/shared/src/lib/theme/portal-theme.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
178
libs/shared/src/lib/theme/semantic.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
libs/shared/src/lib/theme/status-tone.ts
Normal 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)`,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
83
libs/ui/src/lib/data/StatTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
libs/ui/src/lib/data/WaitingFor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||