4
.gitignore
vendored
@@ -30,3 +30,7 @@ apps/backoffice/public/_um/
|
|||||||
apps/backoffice/public/tinymce/
|
apps/backoffice/public/tinymce/
|
||||||
local-packages/iamui-extracted/
|
local-packages/iamui-extracted/
|
||||||
|
|
||||||
|
|
||||||
|
# Playwright visual-regression artifacts (baselines under apps/e2e/visual are tracked)
|
||||||
|
test-results/
|
||||||
|
dist/visual-report/
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
|
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
|
||||||
import { IconPlus } from '@tabler/icons-react';
|
import { IconPlus } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalized, type LicenseType } from '@ema-platform/api';
|
import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api';
|
||||||
import { groupedTypeOptions } from '../config/designer';
|
import { groupedTypeOptions } from '../config/designer';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
licenseTypes: LicenseType[];
|
licenseTypes: LicenseType[];
|
||||||
typeId: string | null;
|
typeId: string | null;
|
||||||
onTypeChange: (id: string | null) => void;
|
onTypeChange: (id: string | null) => void;
|
||||||
|
/** The selected licence type's rank ladder — empty for non-CoC/CoP types. */
|
||||||
|
ranks: Rank[];
|
||||||
|
rankId: string | null;
|
||||||
|
onRankChange: (id: string | null) => void;
|
||||||
validityMonths: number;
|
validityMonths: number;
|
||||||
onValidityChange: (months: number) => void;
|
onValidityChange: (months: number) => void;
|
||||||
currentValidityMonths?: number | null;
|
currentValidityMonths?: number | null;
|
||||||
@@ -22,6 +26,9 @@ export function DesignerToolbar({
|
|||||||
licenseTypes,
|
licenseTypes,
|
||||||
typeId,
|
typeId,
|
||||||
onTypeChange,
|
onTypeChange,
|
||||||
|
ranks,
|
||||||
|
rankId,
|
||||||
|
onRankChange,
|
||||||
validityMonths,
|
validityMonths,
|
||||||
onValidityChange,
|
onValidityChange,
|
||||||
currentValidityMonths,
|
currentValidityMonths,
|
||||||
@@ -49,6 +56,23 @@ export function DesignerToolbar({
|
|||||||
w={340}
|
w={340}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* CoC/CoP only — a rank can carry its own design (e.g. Master's
|
||||||
|
certificate differs from an OOW's). "Default" (null) is the design
|
||||||
|
every other rank under the type falls back to. */}
|
||||||
|
{ranks.length > 0 && (
|
||||||
|
<Select
|
||||||
|
label={t('designer.rank', 'Rank')}
|
||||||
|
description={t('designer.rankHint', 'Leave as Default to design for every rank')}
|
||||||
|
data={[
|
||||||
|
{ value: '', label: t('designer.rankDefault', 'Default (all ranks)') },
|
||||||
|
...ranks.map((r) => ({ value: r.id, label: localized(r.name) })),
|
||||||
|
]}
|
||||||
|
value={rankId ?? ''}
|
||||||
|
onChange={(value) => onRankChange(value || null)}
|
||||||
|
w={220}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Validity lives beside the design because it is the other half of
|
{/* Validity lives beside the design because it is the other half of
|
||||||
what a certificate promises. */}
|
what a certificate promises. */}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
useGetBuiltInTemplateQuery,
|
useGetBuiltInTemplateQuery,
|
||||||
useGetLicenseTemplatesQuery,
|
useGetLicenseTemplatesQuery,
|
||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
|
useGetRanksQuery,
|
||||||
useGetTemplateVariablesQuery,
|
useGetTemplateVariablesQuery,
|
||||||
usePublishLicenseTemplateMutation,
|
usePublishLicenseTemplateMutation,
|
||||||
useUpdateLicenseValidityMutation,
|
useUpdateLicenseValidityMutation,
|
||||||
@@ -66,14 +67,19 @@ export function CertificateDesignerPage() {
|
|||||||
|
|
||||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||||
const [typeId, setTypeId] = useState<string | null>(null);
|
const [typeId, setTypeId] = useState<string | null>(null);
|
||||||
|
const [rankId, setRankId] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: templates = [],
|
data: allTemplates = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
|
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
|
||||||
|
// The list is per licence type; a rank-specific design and the type's
|
||||||
|
// default both come back, so the version list is scoped to whichever the
|
||||||
|
// toolbar has selected.
|
||||||
|
const templates = allTemplates.filter((tpl) => (tpl.rankId ?? null) === rankId);
|
||||||
const { data: variables = [] } = useGetTemplateVariablesQuery();
|
const { data: variables = [] } = useGetTemplateVariablesQuery();
|
||||||
const { data: builtIn } = useGetBuiltInTemplateQuery();
|
const { data: builtIn } = useGetBuiltInTemplateQuery();
|
||||||
|
|
||||||
@@ -95,6 +101,27 @@ export function CertificateDesignerPage() {
|
|||||||
|
|
||||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||||
|
|
||||||
|
// A rank ladder only exists for CoC/CoP — every other licence type designs
|
||||||
|
// one certificate for everyone who holds it. Keyed on `key`, not
|
||||||
|
// `certificateCategory`: that STCW-mapping column is unset on the seeded
|
||||||
|
// CoC/CoP rows (it's authored later, per StcwMappingPanel), while `key` is
|
||||||
|
// the stable identity CertificateEligibilityService itself branches on.
|
||||||
|
// CoC/CoP are each a single LicenseType spanning every department's ladder
|
||||||
|
// (the applicant's own department, not the type, decides which ladder they
|
||||||
|
// climb), so the picker offers every rank in the ladder across all
|
||||||
|
// departments rather than one department's.
|
||||||
|
const rankCategory: 'COC' | 'COP' | null =
|
||||||
|
selectedType?.key === 'CERTIFICATE_OF_COMPETENCY'
|
||||||
|
? 'COC'
|
||||||
|
: selectedType?.key === 'CERTIFICATE_OF_PROFICIENCY'
|
||||||
|
? 'COP'
|
||||||
|
: null;
|
||||||
|
const isRankScoped = rankCategory !== null;
|
||||||
|
const { data: allRanks } = useGetRanksQuery(undefined, { skip: !isRankScoped });
|
||||||
|
const ranks = (allRanks?.items ?? [])
|
||||||
|
.filter((r) => r.certificateCategory === rankCategory)
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
|
||||||
// Default to the first licence type so the page is never an empty shell.
|
// Default to the first licence type so the page is never an empty shell.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
|
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
|
||||||
@@ -104,6 +131,12 @@ export function CertificateDesignerPage() {
|
|||||||
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
||||||
}, [selectedType]);
|
}, [selectedType]);
|
||||||
|
|
||||||
|
// Switching licence type leaves a stale rank selected from the previous
|
||||||
|
// type's ladder — reset to the type's default design.
|
||||||
|
useEffect(() => {
|
||||||
|
setRankId(null);
|
||||||
|
}, [typeId]);
|
||||||
|
|
||||||
function startNewVersion() {
|
function startNewVersion() {
|
||||||
setNewName(
|
setNewName(
|
||||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||||
@@ -130,6 +163,12 @@ export function CertificateDesignerPage() {
|
|||||||
setTypeId(value);
|
setTypeId(value);
|
||||||
draft.setSelectedId(null);
|
draft.setSelectedId(null);
|
||||||
}}
|
}}
|
||||||
|
ranks={ranks}
|
||||||
|
rankId={rankId}
|
||||||
|
onRankChange={(value) => {
|
||||||
|
setRankId(value);
|
||||||
|
draft.setSelectedId(null);
|
||||||
|
}}
|
||||||
validityMonths={validityMonths}
|
validityMonths={validityMonths}
|
||||||
onValidityChange={setValidityMonths}
|
onValidityChange={setValidityMonths}
|
||||||
currentValidityMonths={selectedType?.validityMonths}
|
currentValidityMonths={selectedType?.validityMonths}
|
||||||
@@ -369,6 +408,7 @@ export function CertificateDesignerPage() {
|
|||||||
run(async () => {
|
run(async () => {
|
||||||
const created = await createTemplate({
|
const created = await createTemplate({
|
||||||
licenseTypeId: typeId as string,
|
licenseTypeId: typeId as string,
|
||||||
|
rankId,
|
||||||
name: newName.trim(),
|
name: newName.trim(),
|
||||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Autocomplete, Checkbox, Group, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
|
import { ActionIcon, Autocomplete, Checkbox, Group, Paper, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
|
||||||
|
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
|
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
|
||||||
import { useLocalized } from '@ema-platform/api';
|
import { useLocalized } from '@ema-platform/api';
|
||||||
@@ -7,6 +8,9 @@ import type { ConditionTarget } from '../config/schema-paths';
|
|||||||
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
|
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
|
||||||
export type ConditionValue = FieldCondition & { previousDocExpired?: string };
|
export type ConditionValue = FieldCondition & { previousDocExpired?: string };
|
||||||
|
|
||||||
|
/** One editable `anyOf` arm — a single-field condition, same shape a plain condition holds. */
|
||||||
|
type ConditionArm = Omit<ConditionValue, 'anyOf' | 'previousDocExpired'>;
|
||||||
|
|
||||||
type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
|
type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
|
||||||
|
|
||||||
function operatorOf(condition: ConditionValue | undefined): Operator | null {
|
function operatorOf(condition: ConditionValue | undefined): Operator | null {
|
||||||
@@ -29,6 +33,196 @@ function coerce(raw: string, targetType: string | undefined): string | number |
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-line summary of a condition for read-only chips ("when X = Y", "when X = Y or W = Z"). */
|
||||||
|
export function describeCondition(
|
||||||
|
condition: ConditionValue,
|
||||||
|
t: (key: string, fallback: string) => string,
|
||||||
|
): string {
|
||||||
|
if (condition.anyOf?.length) {
|
||||||
|
return condition.anyOf.map((arm) => describeCondition(arm, t)).join(` ${t('certReq.condition.or', 'or')} `);
|
||||||
|
}
|
||||||
|
if (!condition.field) return '';
|
||||||
|
const parts = [condition.field];
|
||||||
|
if (condition.equals !== undefined) parts.push(`= ${condition.equals}`);
|
||||||
|
if (condition.notEquals !== undefined) parts.push(`≠ ${condition.notEquals}`);
|
||||||
|
if (condition.in !== undefined) parts.push(`∈ [${condition.in.join(', ')}]`);
|
||||||
|
if (condition.isSet !== undefined) {
|
||||||
|
parts.push(condition.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'));
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Authors one `FieldCondition` (`showWhen` on a section/field, or
|
||||||
* `conditionExpression` on a document requirement).
|
* `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
|
* SELECT field, the value picker switches to that field's own options
|
||||||
* instead of free text — the condition can only ever reference an answer
|
* instead of free text — the condition can only ever reference an answer
|
||||||
* that could actually be chosen.
|
* 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({
|
export function ConditionBuilder({
|
||||||
value,
|
value,
|
||||||
@@ -54,40 +253,35 @@ export function ConditionBuilder({
|
|||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const localized = useLocalized();
|
|
||||||
|
|
||||||
const active = value !== null;
|
const active = value !== null;
|
||||||
const operator = operatorOf(value ?? undefined) ?? 'equals';
|
const isAnyOf = Boolean(value?.anyOf);
|
||||||
const target = targets.find((c) => c.path === value?.field);
|
const arms = (value?.anyOf ?? []) as ConditionArm[];
|
||||||
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
|
|
||||||
|
|
||||||
function setField(field: string) {
|
function setArm(i: number, arm: ConditionArm) {
|
||||||
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
|
const next = arms.slice();
|
||||||
|
next[i] = arm;
|
||||||
|
onChange({ anyOf: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOperator(next: Operator) {
|
function addArm() {
|
||||||
if (!value?.field) return;
|
onChange({ anyOf: [...arms, { ...EMPTY_ARM }] });
|
||||||
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 setValueRaw(raw: string) {
|
function removeArm(i: number) {
|
||||||
if (!value?.field) return;
|
onChange({ anyOf: arms.filter((_, idx) => idx !== i) });
|
||||||
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[]) {
|
function toggleAnyOf(next: boolean) {
|
||||||
if (!value?.field) return;
|
if (next) {
|
||||||
onChange({
|
// Seed the list from whatever single condition already existed, so
|
||||||
field: value.field,
|
// switching modes doesn't discard work in progress.
|
||||||
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
|
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 (
|
return (
|
||||||
@@ -102,116 +296,58 @@ export function ConditionBuilder({
|
|||||||
|
|
||||||
{active && (
|
{active && (
|
||||||
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
|
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
|
||||||
<Autocomplete
|
<Switch
|
||||||
label={t('certReq.condition.field', 'Field path')}
|
size="sm"
|
||||||
placeholder="certificate.rank"
|
label={t(
|
||||||
description={t(
|
'certReq.condition.anyOfEnable',
|
||||||
'certReq.condition.fieldHelp',
|
'Any of these (the value can live on one of several fields)',
|
||||||
'Dot path into the form, e.g. sectionKey.fieldKey',
|
|
||||||
)}
|
)}
|
||||||
data={targets.map((c) => c.path)}
|
checked={isAnyOf}
|
||||||
value={value?.field ?? ''}
|
onChange={(e) => toggleAnyOf(e.currentTarget.checked)}
|
||||||
onChange={setField}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
{isAnyOf ? (
|
||||||
<Select
|
<Stack gap="sm">
|
||||||
label={t('certReq.condition.operator', 'Operator')}
|
{arms.map((arm, i) => (
|
||||||
data={operators.map((op) => ({ value: op, label: op }))}
|
<Paper key={i} withBorder p="sm" radius="sm">
|
||||||
value={operator}
|
<Group justify="space-between" mb="xs">
|
||||||
onChange={(v) => v && setOperator(v as Operator)}
|
<Text fz="xs" fw={600} c="dimmed">
|
||||||
allowDeselect={false}
|
{t('certReq.condition.anyOfArm', 'Condition {{n}}', { n: i + 1 })}
|
||||||
/>
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
|
variant="subtle"
|
||||||
<Select
|
color="red"
|
||||||
label={t('certReq.condition.value', 'Value')}
|
size="sm"
|
||||||
data={(target.field.options ?? []).map((o) => ({
|
disabled={arms.length <= 1}
|
||||||
value: o.value,
|
onClick={() => removeArm(i)}
|
||||||
label: localized(o.label) || o.value,
|
>
|
||||||
}))}
|
<IconTrash size={14} />
|
||||||
value={String(value?.equals ?? value?.notEquals ?? '')}
|
</ActionIcon>
|
||||||
onChange={(v) => v !== null && setValueRaw(v)}
|
</Group>
|
||||||
/>
|
<ConditionArmFields
|
||||||
)}
|
value={arm}
|
||||||
|
onChange={(next) => setArm(i, next)}
|
||||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && (
|
targets={targets}
|
||||||
target?.field.type === 'BOOLEAN' ? (
|
palette={palette}
|
||||||
<Checkbox
|
/>
|
||||||
mt="xl"
|
</Paper>
|
||||||
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>
|
<Group>
|
||||||
)}
|
<ActionIcon variant="light" onClick={addArm}>
|
||||||
|
<IconPlus size={16} />
|
||||||
{!target && value?.field && (
|
</ActionIcon>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
{t(
|
{t('certReq.condition.anyOfAdd', 'Add another field')}
|
||||||
'certReq.condition.unknownField',
|
</Text>
|
||||||
'This path is not a field in the current schema yet — it will still be saved as typed.',
|
</Group>
|
||||||
)}
|
</Stack>
|
||||||
</Text>
|
) : (
|
||||||
|
<ConditionArmFields
|
||||||
|
value={value as ConditionArm}
|
||||||
|
onChange={(next) => onChange(next)}
|
||||||
|
targets={targets}
|
||||||
|
palette={palette}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ export function DocumentRequirementEditorDrawer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!draft.name.en?.trim()) return;
|
if (!draft.name.en?.trim()) return;
|
||||||
if (draft.mode === 'CONDITIONAL' && !draft.conditionExpression?.field) {
|
const hasCondition =
|
||||||
|
Boolean(draft.conditionExpression?.field) ||
|
||||||
|
Boolean(draft.conditionExpression?.anyOf?.length);
|
||||||
|
if (draft.mode === 'CONDITIONAL' && !hasCondition) {
|
||||||
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -167,7 +170,7 @@ export function DocumentRequirementEditorDrawer({
|
|||||||
<>
|
<>
|
||||||
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
|
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
|
||||||
<ConditionBuilder
|
<ConditionBuilder
|
||||||
value={(draft.conditionExpression ?? null) as ConditionValue | null}
|
value={(draft.conditionExpression ?? { field: '', equals: '' }) as ConditionValue}
|
||||||
onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))}
|
onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))}
|
||||||
targets={conditionTargets}
|
targets={conditionTargets}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { collectConditionTargets } from '../config/schema-paths';
|
import { collectConditionTargets } from '../config/schema-paths';
|
||||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||||
|
import { describeCondition } from './ConditionBuilder';
|
||||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
||||||
|
|
||||||
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
|
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
|
||||||
@@ -137,13 +138,9 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
|||||||
<Text fz="xs" c="dimmed" truncate>
|
<Text fz="xs" c="dimmed" truncate>
|
||||||
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
|
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
{req.mode === 'CONDITIONAL' && req.conditionExpression?.field && (
|
{req.mode === 'CONDITIONAL' && req.conditionExpression && (
|
||||||
<Text fz="xs" c="violet">
|
<Text fz="xs" c="violet">
|
||||||
{t('certReq.doc.when', 'when')} {req.conditionExpression.field}{' '}
|
{t('certReq.doc.when', 'when')} {describeCondition(req.conditionExpression, t)}
|
||||||
{req.conditionExpression.equals !== undefined && `= ${req.conditionExpression.equals}`}
|
|
||||||
{req.conditionExpression.notEquals !== undefined && `≠ ${req.conditionExpression.notEquals}`}
|
|
||||||
{req.conditionExpression.in !== undefined && `∈ [${req.conditionExpression.in.join(', ')}]`}
|
|
||||||
{req.conditionExpression.isSet !== undefined && (req.conditionExpression.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'))}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function CertificateRequirementsPage() {
|
|||||||
'certReq.subtitle',
|
'certReq.subtitle',
|
||||||
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
|
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
|
||||||
)}
|
)}
|
||||||
|
noMargin
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ export function certificationColumns(
|
|||||||
header: t('certification.columns.description'),
|
header: t('certification.columns.description'),
|
||||||
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
|
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: t('certification.columns.rank', 'Rank'),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.rankKey ? (
|
||||||
|
<Badge size="sm" variant="outline" color="violet">{row.original.rankKey}</Badge>
|
||||||
|
) : (
|
||||||
|
<Text fz="sm" c="dimmed">—</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: t('certification.columns.status'),
|
header: t('certification.columns.status'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
|
|||||||
@@ -1,27 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core';
|
||||||
Stack,
|
|
||||||
Title,
|
|
||||||
Group,
|
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Textarea,
|
|
||||||
Card,
|
|
||||||
Alert,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
import {IconPlus} from '@tabler/icons-react';
|
||||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
useGetCertificationsQuery,
|
useGetCertificationsQuery,
|
||||||
useCreateCertificationMutation,
|
useCreateCertificationMutation,
|
||||||
useUpdateCertificationMutation,
|
useUpdateCertificationMutation,
|
||||||
useDeleteCertificationMutation,
|
useDeleteCertificationMutation,
|
||||||
} from '../../api/certification-api';
|
} from '../../api/certification-api';
|
||||||
import type { Certification } from '../../types/certification';
|
import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
|
||||||
import { certificationColumns } from './columns';
|
import { certificationColumns } from './columns';
|
||||||
import { certificationActionsColumn } from './actions';
|
import { certificationActionsColumn } from './actions';
|
||||||
|
|
||||||
@@ -33,7 +22,7 @@ function CertificationForm({
|
|||||||
}: {
|
}: {
|
||||||
editing: Certification | null;
|
editing: Certification | null;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => void;
|
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -41,6 +30,7 @@ function CertificationForm({
|
|||||||
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
|
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
|
||||||
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
||||||
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
||||||
|
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -48,7 +38,7 @@ function CertificationForm({
|
|||||||
notify.error('Name fields are required');
|
notify.error('Name fields are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSubmit({ nameEn, nameAm, descEn, descAm }, !!editing);
|
onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,6 +49,17 @@ function CertificationForm({
|
|||||||
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
|
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
|
||||||
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||||
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||||
|
<Select
|
||||||
|
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
|
||||||
|
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
|
||||||
|
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
|
||||||
|
data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]}
|
||||||
|
value={rankKey}
|
||||||
|
onChange={setRankKey}
|
||||||
|
size="sm"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
||||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||||
@@ -91,15 +92,17 @@ export function CertificationPage() {
|
|||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => {
|
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => {
|
||||||
const name = { en: values.nameEn, am: values.nameAm };
|
const name = { en: values.nameEn, am: values.nameAm };
|
||||||
const description = { en: values.descEn, am: values.descAm };
|
const description = { en: values.descEn, am: values.descAm };
|
||||||
try {
|
try {
|
||||||
if (isEdit && editing) {
|
if (isEdit && editing) {
|
||||||
await updateCert({ id: editing.id, name, description }).unwrap();
|
// null clears a previously-set rank; undefined would leave it
|
||||||
|
// untouched server-side, so the two are not interchangeable here.
|
||||||
|
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap();
|
||||||
notify.success(t('certification.updated'));
|
notify.success(t('certification.updated'));
|
||||||
} else {
|
} else {
|
||||||
await createCert({ name, description }).unwrap();
|
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
|
||||||
notify.success(t('certification.created'));
|
notify.success(t('certification.created'));
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -120,7 +123,8 @@ export function CertificationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
if (isError)
|
||||||
|
return <ErrorState title={t('certification.loadError')} onRetry={refetch} />;
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
...certificationColumns(t, locale),
|
...certificationColumns(t, locale),
|
||||||
@@ -134,17 +138,18 @@ export function CertificationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<PageHeader
|
||||||
<div>
|
title={t('certification.title')}
|
||||||
<Title order={2}>{t('certification.title')}</Title>
|
subtitle={t('certification.subtitle')}
|
||||||
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text>
|
noMargin
|
||||||
</div>
|
action={
|
||||||
{!showForm && (
|
!showForm && (
|
||||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||||
{t('certification.add')}
|
{t('certification.add')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<CertificationForm
|
<CertificationForm
|
||||||
|
|||||||
@@ -3,11 +3,30 @@ export interface LocalePair {
|
|||||||
am: string;
|
am: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STCW rank an exam certification is for — the join that lets the
|
||||||
|
* schedule-exam picker offer only sittings valid for an application's rank.
|
||||||
|
* Matches `LicenseApplication.formData.certificate.rank` / `rankEngine` /
|
||||||
|
* `proficiency` on the backend. Not every certification is on the examined
|
||||||
|
* ladder, so this stays a plain optional string rather than a required enum.
|
||||||
|
*/
|
||||||
|
export const RANK_KEY_OPTIONS = [
|
||||||
|
{ value: 'OOW_DECK', label: 'Officer of the Watch (Deck)' },
|
||||||
|
{ value: 'CHIEF_MATE', label: 'Chief Mate' },
|
||||||
|
{ value: 'MASTER', label: 'Master' },
|
||||||
|
{ value: 'OOW_ENGINE', label: 'Officer of the Watch (Engine)' },
|
||||||
|
{ value: 'SECOND_ENGINEER', label: 'Second Engineer' },
|
||||||
|
{ value: 'CHIEF_ENGINEER', label: 'Chief Engineer' },
|
||||||
|
{ value: 'ABLE_SEAFARER_DECK', label: 'Able Seafarer Deck' },
|
||||||
|
{ value: 'ABLE_SEAFARER_ENGINE', label: 'Able Seafarer Engine' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export interface Certification {
|
export interface Certification {
|
||||||
id: string;
|
id: string;
|
||||||
name: LocalePair;
|
name: LocalePair;
|
||||||
description: LocalePair;
|
description: LocalePair;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
rankKey: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -20,6 +39,7 @@ export interface ListResponse<T> {
|
|||||||
export interface CreateCertificationPayload {
|
export interface CreateCertificationPayload {
|
||||||
name: LocalePair;
|
name: LocalePair;
|
||||||
description: LocalePair;
|
description: LocalePair;
|
||||||
|
rankKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCertificationPayload {
|
export interface UpdateCertificationPayload {
|
||||||
@@ -27,4 +47,6 @@ export interface UpdateCertificationPayload {
|
|||||||
name?: LocalePair;
|
name?: LocalePair;
|
||||||
description?: LocalePair;
|
description?: LocalePair;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
/** Omit to leave unchanged, null to clear a previously-set rank. */
|
||||||
|
rankKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
IconCertificate,
|
||||||
IconHash,
|
IconHash,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconAnchor,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
import { LocationPage } from "../../../location/pages/LocationPage";
|
import { LocationPage } from "../../../location/pages/LocationPage";
|
||||||
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
||||||
import { NumberFormatTab } from "../../components/NumberFormatTab";
|
import { NumberFormatTab } from "../../components/NumberFormatTab";
|
||||||
|
import { RankDepartmentTab } from "./RankDepartmentTab";
|
||||||
import {
|
import {
|
||||||
useGetOrganizationsQuery,
|
useGetOrganizationsQuery,
|
||||||
useGetProfessionsQuery,
|
useGetProfessionsQuery,
|
||||||
@@ -46,6 +48,7 @@ import {
|
|||||||
import type { Profession } from "../../types/configuration";
|
import type { Profession } from "../../types/configuration";
|
||||||
import { professionColumns } from "./columns";
|
import { professionColumns } from "./columns";
|
||||||
import { professionActionsColumn } from "./actions";
|
import { professionActionsColumn } from "./actions";
|
||||||
|
import { PageHeader } from '@ema-platform/ui';
|
||||||
|
|
||||||
interface ProfFormValues {
|
interface ProfFormValues {
|
||||||
nameEn: string;
|
nameEn: string;
|
||||||
@@ -378,7 +381,7 @@ export function ConfigurationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Title order={2}>{t("configuration.title")}</Title>
|
<PageHeader title={t("configuration.title")} noMargin />
|
||||||
|
|
||||||
<Tabs defaultValue="professions">
|
<Tabs defaultValue="professions">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
@@ -400,6 +403,9 @@ export function ConfigurationPage() {
|
|||||||
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
|
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
|
||||||
{t("numberFormat.title", "Number Formats")}
|
{t("numberFormat.title", "Number Formats")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
|
||||||
|
{t("configuration.ranksTab", "Ranks & Departments")}
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="professions" pt="md">
|
<Tabs.Panel value="professions" pt="md">
|
||||||
@@ -417,6 +423,10 @@ export function ConfigurationPage() {
|
|||||||
<Tabs.Panel value="numberFormats" pt="md">
|
<Tabs.Panel value="numberFormats" pt="md">
|
||||||
<NumberFormatTab />
|
<NumberFormatTab />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="ranks" pt="md">
|
||||||
|
<RankDepartmentTab />
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,42 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
Card,
|
IconAlertTriangle,
|
||||||
Center,
|
IconCreditCard,
|
||||||
Container,
|
IconFileText,
|
||||||
Group,
|
IconInbox,
|
||||||
Loader,
|
IconUserCheck,
|
||||||
SimpleGrid,
|
} from '@tabler/icons-react';
|
||||||
Text,
|
import {
|
||||||
Title,
|
useGetAssignedToMeQuery,
|
||||||
} from '@mantine/core';
|
useGetQueueQuery,
|
||||||
import { IconChevronRight } from '@tabler/icons-react';
|
useListSeafarerDocumentsQuery,
|
||||||
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
|
useListSeafarerRegistrationsQuery,
|
||||||
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
|
type LicenseApplication,
|
||||||
|
} from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
AdvancedTable,
|
||||||
|
PageHeader,
|
||||||
|
PageLoader,
|
||||||
|
StatTile,
|
||||||
|
WaitingFor,
|
||||||
|
useServerTable,
|
||||||
|
} from '@ema-platform/ui';
|
||||||
import { dashboardQueueColumns } from './columns';
|
import { dashboardQueueColumns } from './columns';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backoffice home.
|
* Backoffice home.
|
||||||
*
|
*
|
||||||
* Shows the licence pipeline, which is the part of the platform that has real
|
* Every figure here is counted from a queue the officer can open, and each
|
||||||
* data behind it. The previous version charted invented registration volumes
|
* tile navigates to the list it counted — a dashboard that cannot be drilled
|
||||||
* and a fictional breakdown of staff roles.
|
* into is a poster. Nothing is charted: the platform exposes queues, not time
|
||||||
|
* series, and an earlier version of this page invented both a registration
|
||||||
|
* trend and a staff-role breakdown rather than admit that.
|
||||||
|
*
|
||||||
|
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
|
||||||
|
* queues are permission-gated and an officer without them simply gets no
|
||||||
|
* count — never a broken page — so the tiles read `—` rather than `0`, which
|
||||||
|
* would be a lie.
|
||||||
*/
|
*/
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -27,6 +44,13 @@ export function DashboardPage() {
|
|||||||
const mine = useGetAssignedToMeQuery();
|
const mine = useGetAssignedToMeQuery();
|
||||||
const table = useServerTable();
|
const table = useServerTable();
|
||||||
|
|
||||||
|
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
|
||||||
|
const seamanBooks = useListSeafarerDocumentsQuery({
|
||||||
|
kind: 'SEAMAN_BOOK',
|
||||||
|
status: 'PAYMENT_PENDING',
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
if (queue.isLoading || mine.isLoading) {
|
if (queue.isLoading || mine.isLoading) {
|
||||||
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
||||||
}
|
}
|
||||||
@@ -34,73 +58,143 @@ export function DashboardPage() {
|
|||||||
const unclaimed = queue.data?.items ?? [];
|
const unclaimed = queue.data?.items ?? [];
|
||||||
const inProgress = mine.data?.items ?? [];
|
const inProgress = mine.data?.items ?? [];
|
||||||
const all = [...unclaimed, ...inProgress];
|
const all = [...unclaimed, ...inProgress];
|
||||||
const paged = table.paginate(unclaimed.slice(0, 8));
|
|
||||||
|
|
||||||
const stats = [
|
const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
|
||||||
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
|
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
|
||||||
{ label: 'Assigned to me', value: inProgress.length, color: 'indigo' },
|
|
||||||
{
|
/** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
|
||||||
label: 'Needs applicant action',
|
const byAge = [...unclaimed].sort((a, b) =>
|
||||||
value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length,
|
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
|
||||||
color: 'orange',
|
);
|
||||||
},
|
const paged = table.paginate(byAge.slice(0, 8));
|
||||||
{
|
|
||||||
label: 'Awaiting payment',
|
/** `undefined` while loading or forbidden — rendered as "—", never as 0. */
|
||||||
value: all.filter((a) => a.status === 'PAYMENT_PENDING').length,
|
const countOf = (q: { data?: { total: number }; isError: boolean }) =>
|
||||||
color: 'yellow',
|
q.isError ? undefined : q.data?.total;
|
||||||
},
|
|
||||||
];
|
const show = (n: number | undefined) => (n === undefined ? '—' : n);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Stack gap="lg">
|
||||||
<Title order={3} mb="xs">
|
<PageHeader
|
||||||
Dashboard
|
title="Dashboard"
|
||||||
</Title>
|
subtitle="Work waiting across the Authority's review queues."
|
||||||
<Text size="sm" c="dimmed" mb="lg">
|
noMargin
|
||||||
Licence applications currently in the system.
|
/>
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
{stats.map((stat) => (
|
<StatTile
|
||||||
<Card withBorder key={stat.label} padding="md" radius="md">
|
label="Awaiting claim"
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
value={unclaimed.length}
|
||||||
{stat.label}
|
hint="Licence applications nobody has picked up"
|
||||||
</Text>
|
icon={IconInbox}
|
||||||
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
|
tone="info"
|
||||||
{stat.value}
|
onClick={() => navigate('/licence-review')}
|
||||||
</Text>
|
/>
|
||||||
</Card>
|
<StatTile
|
||||||
))}
|
label="Assigned to me"
|
||||||
|
value={inProgress.length}
|
||||||
|
hint="Your open licence reviews"
|
||||||
|
icon={IconFileText}
|
||||||
|
tone="neutral"
|
||||||
|
onClick={() => navigate('/licence-review')}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Needs applicant action"
|
||||||
|
value={needsApplicant}
|
||||||
|
hint="Returned for corrections"
|
||||||
|
icon={IconAlertTriangle}
|
||||||
|
tone="pending"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Awaiting payment"
|
||||||
|
value={awaitingPayment}
|
||||||
|
hint="Approved, fee not yet settled"
|
||||||
|
icon={IconCreditCard}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Card withBorder padding={0} radius="md">
|
{/* Same 4-column track as the row above, so a two-tile row lines up with
|
||||||
<Group justify="space-between" p="md" pb="xs">
|
it instead of stretching each tile to half the page. */}
|
||||||
<Text fw={600} size="sm">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
Awaiting claim
|
<StatTile
|
||||||
</Text>
|
label="Seafarer registrations"
|
||||||
<Text
|
value={show(countOf(registrations))}
|
||||||
size="xs"
|
hint="Submitted, awaiting review"
|
||||||
c="blue"
|
icon={IconUserCheck}
|
||||||
style={{ cursor: 'pointer' }}
|
tone="info"
|
||||||
onClick={() => navigate('/licence-review')}
|
onClick={() => navigate('/seafarer-registrations')}
|
||||||
>
|
|
||||||
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
|
|
||||||
</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."
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
<StatTile
|
||||||
</Container>
|
label="Seaman books"
|
||||||
|
value={show(countOf(seamanBooks))}
|
||||||
|
hint="Released, awaiting payment"
|
||||||
|
icon={IconCreditCard}
|
||||||
|
tone="pending"
|
||||||
|
onClick={() => navigate('/seaman-book-queue')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Grid gutter="lg">
|
||||||
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
|
<AdvancedTable<LicenseApplication>
|
||||||
|
title="Awaiting claim — oldest first"
|
||||||
|
tableName="Awaiting claim"
|
||||||
|
columns={dashboardQueueColumns}
|
||||||
|
data={paged.rows}
|
||||||
|
itemCount={paged.itemCount}
|
||||||
|
pageIndex={paged.pageIndex}
|
||||||
|
onPageChange={table.setPageIndex}
|
||||||
|
pageSize={table.pageSize}
|
||||||
|
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
|
||||||
|
refresh={queue.refetch}
|
||||||
|
isLoading={queue.isFetching}
|
||||||
|
emptyText="Nothing waiting to be claimed."
|
||||||
|
toolbar={
|
||||||
|
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
|
||||||
|
Open queue
|
||||||
|
</Anchor>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Paper withBorder radius="lg" p="lg" h="100%">
|
||||||
|
<Text fw={600} size="sm" mb="xs">
|
||||||
|
Longest waiting
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mb="md">
|
||||||
|
Unclaimed applications, by how long they have sat.
|
||||||
|
</Text>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{byAge.slice(0, 5).map((app) => (
|
||||||
|
<Group key={app.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
lineClamp={1}
|
||||||
|
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||||
|
>
|
||||||
|
{app.applicationNumber}
|
||||||
|
</Anchor>
|
||||||
|
<WaitingFor
|
||||||
|
since={app.submittedAt ?? app.createdAt}
|
||||||
|
slaDays={
|
||||||
|
app.licenseType?.slaHours ? app.licenseType.slaHours / 24 : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
{byAge.length === 0 && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Nothing waiting.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ExamIncident,
|
ExamIncident,
|
||||||
CreateIncidentPayload,
|
CreateIncidentPayload,
|
||||||
ResolveIncidentPayload,
|
ResolveIncidentPayload,
|
||||||
|
RegradeOutcome,
|
||||||
} from '../types/exam';
|
} from '../types/exam';
|
||||||
|
|
||||||
const examApi = baseApi.injectEndpoints({
|
const examApi = baseApi.injectEndpoints({
|
||||||
@@ -20,7 +21,9 @@ const examApi = baseApi.injectEndpoints({
|
|||||||
providesTags: ['Api'],
|
providesTags: ['Api'],
|
||||||
}),
|
}),
|
||||||
getExam: builder.query<Exam, string>({
|
getExam: builder.query<Exam, string>({
|
||||||
query: (id) => `/exams/${id}?i=questions`,
|
// Nested relation so CHOICE questions carry their options here too —
|
||||||
|
// needed to print real answer choices instead of blank A/B/C/D lines.
|
||||||
|
query: (id) => `/exams/${id}?i=questions,questions.options`,
|
||||||
providesTags: ['Api'],
|
providesTags: ['Api'],
|
||||||
}),
|
}),
|
||||||
createExam: builder.mutation<Exam, CreateExamPayload>({
|
createExam: builder.mutation<Exam, CreateExamPayload>({
|
||||||
@@ -90,6 +93,14 @@ const examApi = baseApi.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
invalidatesTags: ['Api'],
|
invalidatesTags: ['Api'],
|
||||||
}),
|
}),
|
||||||
|
/** Staff-triggered re-run of auto-grading for one finalized attempt. */
|
||||||
|
regradeAttempt: builder.mutation<RegradeOutcome, string>({
|
||||||
|
query: (attemptId) => ({
|
||||||
|
url: `/exam-attempts/${attemptId}/regrade`,
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
invalidatesTags: ['Api'],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@@ -107,4 +118,5 @@ export const {
|
|||||||
useGetExamIncidentsQuery,
|
useGetExamIncidentsQuery,
|
||||||
useRecordIncidentMutation,
|
useRecordIncidentMutation,
|
||||||
useResolveIncidentMutation,
|
useResolveIncidentMutation,
|
||||||
|
useRegradeAttemptMutation,
|
||||||
} = examApi;
|
} = examApi;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, Button, Text } from '@mantine/core';
|
import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
|
||||||
import { IconUserCheck } from '@tabler/icons-react';
|
import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
@@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) =>
|
|||||||
|
|
||||||
export function examCandidateColumns(
|
export function examCandidateColumns(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
handlers: { onRecord: (registration: ExamRegistration) => void },
|
handlers: {
|
||||||
|
onRecord: (registration: ExamRegistration) => void;
|
||||||
|
onRegrade: (registration: ExamRegistration) => void;
|
||||||
|
regrading?: string | null;
|
||||||
|
},
|
||||||
): AdvancedColumn<ExamRegistration>[] {
|
): AdvancedColumn<ExamRegistration>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -78,21 +82,51 @@ export function examCandidateColumns(
|
|||||||
header: '',
|
header: '',
|
||||||
label: t('exam.candidates.record'),
|
label: t('exam.candidates.record'),
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<RequirePermission
|
const attemptStatus = row.original.attempt?.status;
|
||||||
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
|
const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED';
|
||||||
hideOnly
|
return (
|
||||||
>
|
<Menu shadow="md" width={180} position="bottom-end">
|
||||||
<Button
|
<Menu.Target>
|
||||||
size="compact-xs"
|
<ActionIcon
|
||||||
variant="light"
|
variant="subtle"
|
||||||
leftSection={<IconUserCheck size={12} />}
|
color="gray"
|
||||||
onClick={() => handlers.onRecord(row.original)}
|
size="sm"
|
||||||
>
|
loading={handlers.regrading === row.original.attempt?.id}
|
||||||
{t('exam.candidates.record')}
|
>
|
||||||
</Button>
|
<IconDotsVertical size={16} />
|
||||||
</RequirePermission>
|
</ActionIcon>
|
||||||
),
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<RequirePermission
|
||||||
|
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
|
||||||
|
hideOnly
|
||||||
|
>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconUserCheck size={14} />}
|
||||||
|
onClick={() => handlers.onRecord(row.original)}
|
||||||
|
>
|
||||||
|
{t('exam.candidates.record')}
|
||||||
|
</Menu.Item>
|
||||||
|
</RequirePermission>
|
||||||
|
{canRegrade && (
|
||||||
|
<RequirePermission
|
||||||
|
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]}
|
||||||
|
hideOnly
|
||||||
|
>
|
||||||
|
<Menu.Item
|
||||||
|
color="grape"
|
||||||
|
leftSection={<IconRefresh size={14} />}
|
||||||
|
onClick={() => handlers.onRegrade(row.original)}
|
||||||
|
>
|
||||||
|
{t('exam.candidates.regrade')}
|
||||||
|
</Menu.Item>
|
||||||
|
</RequirePermission>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { extractErrorMessage } from '@ema-platform/api';
|
|||||||
import {
|
import {
|
||||||
useGetExamRegistrationsQuery,
|
useGetExamRegistrationsQuery,
|
||||||
useRecordAttendanceMutation,
|
useRecordAttendanceMutation,
|
||||||
|
useRegradeAttemptMutation,
|
||||||
} from '../../api/exam-api';
|
} from '../../api/exam-api';
|
||||||
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
|
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
|
||||||
import { candidateName, examCandidateColumns } from './columns';
|
import { candidateName, examCandidateColumns } from './columns';
|
||||||
@@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
|
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
|
||||||
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
|
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
|
||||||
|
const [regradeAttempt] = useRegradeAttemptMutation();
|
||||||
|
const [regrading, setRegrading] = useState<string | null>(null);
|
||||||
const [target, setTarget] = useState<ExamRegistration | null>(null);
|
const [target, setTarget] = useState<ExamRegistration | null>(null);
|
||||||
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
|
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
|
||||||
const [remark, setRemark] = useState('');
|
const [remark, setRemark] = useState('');
|
||||||
const table = useServerTable();
|
const table = useServerTable();
|
||||||
|
|
||||||
|
const regrade = async (registration: ExamRegistration) => {
|
||||||
|
const attemptId = registration.attempt?.id;
|
||||||
|
if (!attemptId) return;
|
||||||
|
setRegrading(attemptId);
|
||||||
|
try {
|
||||||
|
const outcome = await regradeAttempt(attemptId).unwrap();
|
||||||
|
if (outcome.graded) {
|
||||||
|
notify.success(t('exam.candidates.regraded'));
|
||||||
|
} else {
|
||||||
|
notify.error(t('exam.candidates.regradeNotEligible', { reason: outcome.reason }));
|
||||||
|
}
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
notify.error(extractErrorMessage(error, t('exam.candidates.regradeError')));
|
||||||
|
} finally {
|
||||||
|
setRegrading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const startRecording = (registration: ExamRegistration) => {
|
const startRecording = (registration: ExamRegistration) => {
|
||||||
setTarget(registration);
|
setTarget(registration);
|
||||||
setStatus(
|
setStatus(
|
||||||
@@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
tableName={t('exam.candidates.section')}
|
tableName={t('exam.candidates.section')}
|
||||||
columns={examCandidateColumns(t, { onRecord: startRecording })}
|
columns={examCandidateColumns(t, {
|
||||||
|
onRecord: startRecording,
|
||||||
|
onRegrade: regrade,
|
||||||
|
regrading,
|
||||||
|
})}
|
||||||
data={paged.rows}
|
data={paged.rows}
|
||||||
itemCount={paged.itemCount}
|
itemCount={paged.itemCount}
|
||||||
pageIndex={paged.pageIndex}
|
pageIndex={paged.pageIndex}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { Badge, Button, Text } from '@mantine/core';
|
import { Badge, Button, Text } from '@mantine/core';
|
||||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { StatusBadge } from '@ema-platform/ui';
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
|
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
|
||||||
|
|
||||||
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
const STATUS_TONE: Record<ExamIncidentStatus, StatusTone> = {
|
||||||
OPEN: 'red',
|
OPEN: 'danger',
|
||||||
UNDER_REVIEW: 'yellow',
|
UNDER_REVIEW: 'warning',
|
||||||
RESOLVED: 'teal',
|
RESOLVED: 'success',
|
||||||
DISMISSED: 'gray',
|
DISMISSED: 'neutral',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function examIncidentColumns(
|
export function examIncidentColumns(
|
||||||
@@ -56,13 +58,12 @@ export function examIncidentColumns(
|
|||||||
{
|
{
|
||||||
header: t('exam.incidents.status'),
|
header: t('exam.incidents.status'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<StatusBadge
|
||||||
|
tone={STATUS_TONE[row.original.status] ?? 'neutral'}
|
||||||
|
label={t(`exam.incidentStatus.${row.original.status}`)}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={STATUS_COLOR[row.original.status] ?? 'gray'}
|
/>
|
||||||
>
|
|
||||||
{t(`exam.incidentStatus.${row.original.status}`)}
|
|
||||||
</Badge>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +40,7 @@ import {
|
|||||||
IconCheck,
|
IconCheck,
|
||||||
IconX,
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import { extractErrorMessage } from '@ema-platform/api';
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import {
|
import {
|
||||||
@@ -57,16 +58,20 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
|||||||
import { PageLoader } from '@ema-platform/ui';
|
import { PageLoader } from '@ema-platform/ui';
|
||||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_TONE: Record<string, StatusTone> = {
|
||||||
PENDING: "gray",
|
PENDING: 'neutral',
|
||||||
ACTIVE: "blue",
|
ACTIVE: 'info',
|
||||||
COMPLETED: "teal",
|
COMPLETED: 'success',
|
||||||
CANCELLED: "red",
|
CANCELLED: 'danger',
|
||||||
POSTPONED: "orange",
|
POSTPONED: 'pending',
|
||||||
PUBLISHED: "green",
|
PUBLISHED: 'success',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
|
const FORM_LABEL: Record<string, string> = {
|
||||||
|
ESSAY: "Essay",
|
||||||
|
CHOICE: "Choice",
|
||||||
|
BOTH: "Both",
|
||||||
|
};
|
||||||
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
|
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
|
||||||
const ADMIN_LABEL: Record<string, string> = {
|
const ADMIN_LABEL: Record<string, string> = {
|
||||||
OFFLINE: "Offline",
|
OFFLINE: "Offline",
|
||||||
@@ -171,7 +176,12 @@ export function ExamDetailPage() {
|
|||||||
notify.error(
|
notify.error(
|
||||||
key.startsWith('insufficient_approved_questions')
|
key.startsWith('insufficient_approved_questions')
|
||||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||||
: key,
|
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||||
|
? t('exam.cannotReachCuttingPoint', {
|
||||||
|
max: key.split(':')[1]?.split('/')[0] ?? '',
|
||||||
|
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
|
||||||
|
})
|
||||||
|
: key,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -187,18 +197,35 @@ export function ExamDetailPage() {
|
|||||||
notify.error(
|
notify.error(
|
||||||
key.startsWith('question_not_approved')
|
key.startsWith('question_not_approved')
|
||||||
? t('question.qc.onlyApprovedUsable')
|
? t('question.qc.onlyApprovedUsable')
|
||||||
: key,
|
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||||
|
? t('exam.cannotReachCuttingPoint', {
|
||||||
|
max: key.split(':')[1]?.split('/')[0] ?? '',
|
||||||
|
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
|
||||||
|
})
|
||||||
|
: key,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handlePrint = async () => {
|
const handlePrint = async () => {
|
||||||
const total = (exam.questions ?? []).reduce(
|
// The reachable max depends on the evaluation method, not the raw point
|
||||||
(s, q) => s + Number(q.points),
|
// sum — mirrors RecordResultModal's grading math so "can this paper pass"
|
||||||
0,
|
// means the same thing here as it does at marking time. Cutting point can
|
||||||
);
|
// be raised after the paper was assembled (edit modal, no re-check on
|
||||||
if (total < Number(exam.cuttingPoint)) {
|
// save), so this still needs to run even though assignment now enforces
|
||||||
|
// it too.
|
||||||
|
const questions = exam.questions ?? [];
|
||||||
|
const total = questions.reduce((s, q) => s + Number(q.points), 0);
|
||||||
|
const reachableMax =
|
||||||
|
exam.evaluationMethod === 'AVERAGE'
|
||||||
|
? questions.length
|
||||||
|
? total / questions.length
|
||||||
|
: 0
|
||||||
|
: exam.evaluationMethod === 'PERCENTAGE'
|
||||||
|
? 100
|
||||||
|
: total;
|
||||||
|
if (reachableMax < Number(exam.cuttingPoint)) {
|
||||||
notify.error(
|
notify.error(
|
||||||
`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
|
`This paper cannot reach the passing mark under its ${EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} evaluation (max ${reachableMax}, pass mark ${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -232,7 +259,23 @@ export function ExamDetailPage() {
|
|||||||
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
|
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
|
||||||
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ""}
|
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ""}
|
||||||
${q.form === "ESSAY" ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ""}
|
${q.form === "ESSAY" ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ""}
|
||||||
${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("") : ""}
|
${
|
||||||
|
q.form === "CHOICE"
|
||||||
|
? q.options && q.options.length
|
||||||
|
? q.options
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map(
|
||||||
|
(o, oi) =>
|
||||||
|
`<p style="margin: 4px 0; font-size: 13px;">${String.fromCharCode(65 + oi)}. ${o.text[locale] || o.text.en}</p>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
// No options on record (legacy question, or options relation
|
||||||
|
// wasn't loaded) — fall back to blank lines rather than
|
||||||
|
// printing nothing.
|
||||||
|
: ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
</div>`;
|
</div>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
@@ -247,7 +290,20 @@ export function ExamDetailPage() {
|
|||||||
.header p { margin: 2px 0; font-size: 13px; color: #555; }
|
.header p { margin: 2px 0; font-size: 13px; color: #555; }
|
||||||
.directions { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; }
|
.directions { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; }
|
||||||
.directions strong { display: block; margin-bottom: 4px; }
|
.directions strong { display: block; margin-bottom: 4px; }
|
||||||
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
|
.footer { margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center; }
|
||||||
|
/* Pinned to the bottom of every printed page (not just after the
|
||||||
|
last question) — @page's bottom margin leaves room for it so it
|
||||||
|
never overlaps question text on the last page. */
|
||||||
|
@media print {
|
||||||
|
@page { margin: 20mm 20mm 28mm 20mm; }
|
||||||
|
/* @page's margin already insets content from the physical page
|
||||||
|
edge — body's own 40px padding (needed on-screen, for the
|
||||||
|
preview tab before printing) would double up with it here,
|
||||||
|
wasting real page height on every side and fitting noticeably
|
||||||
|
fewer questions per page than the paper actually has room for. */
|
||||||
|
body { -webkit-print-color-adjust: exact; padding: 0; max-width: none; }
|
||||||
|
.footer { position: fixed; bottom: 0; left: 0; right: 0; margin-top: 0; }
|
||||||
|
}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
|
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
|
||||||
@@ -258,7 +314,7 @@ export function ExamDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""}
|
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""}
|
||||||
${qHtml}
|
${qHtml}
|
||||||
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
|
<div class="footer">
|
||||||
Generated by EMA — Ethiopian Maritime Authority
|
Generated by EMA — Ethiopian Maritime Authority
|
||||||
</div>
|
</div>
|
||||||
</body></html>
|
</body></html>
|
||||||
@@ -290,7 +346,7 @@ export function ExamDetailPage() {
|
|||||||
<IconArrowLeft size={18} />
|
<IconArrowLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<div>
|
<div>
|
||||||
<Title order={3}>{exam.title[locale]}</Title>
|
<Title order={2}>{exam.title[locale]}</Title>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
@@ -313,14 +369,13 @@ export function ExamDetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Status badge */}
|
{/* Status badge */}
|
||||||
<Badge
|
<StatusBadge
|
||||||
|
tone={STATUS_TONE[exam.status]}
|
||||||
|
label={t(`exam.status.${exam.status}`)}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={STATUS_COLOR[exam.status]}
|
|
||||||
style={{ width: "fit-content" }}
|
style={{ width: "fit-content" }}
|
||||||
>
|
/>
|
||||||
{t(`exam.status.${exam.status}`)}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{/* Exam Info */}
|
{/* Exam Info */}
|
||||||
<Paper withBorder radius="lg" p="lg">
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ActionIcon, Group } from "@mantine/core";
|
import { ActionIcon, Menu } from "@mantine/core";
|
||||||
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
|
import {
|
||||||
|
IconDetails,
|
||||||
|
IconDotsVertical,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
IconToggleRight,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||||
@@ -11,40 +17,55 @@ export function examActionsColumn(
|
|||||||
onEdit: (exam: Exam) => void;
|
onEdit: (exam: Exam) => void;
|
||||||
onDelete: (exam: Exam) => void;
|
onDelete: (exam: Exam) => void;
|
||||||
onDetails: (exam: Exam) => void;
|
onDetails: (exam: Exam) => void;
|
||||||
|
onOpenStatusChange: (exam: Exam) => void;
|
||||||
|
changingStatusId?: string | null;
|
||||||
},
|
},
|
||||||
): AdvancedColumn<Exam> {
|
): AdvancedColumn<Exam> {
|
||||||
return {
|
return {
|
||||||
header: t("exam.columns.actions"),
|
header: t("exam.columns.actions", "Actions"),
|
||||||
align: "right",
|
align: "right",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Group gap="xs">
|
<Menu shadow="md" width={180} position="bottom-end">
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="blue"
|
color="gray"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlers.onEdit(row.original)}
|
loading={handlers.changingStatusId === row.original.id}
|
||||||
>
|
>
|
||||||
<IconEdit size={14} />
|
<IconDotsVertical size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
</Menu.Target>
|
||||||
variant="subtle"
|
<Menu.Dropdown>
|
||||||
color="red"
|
<Menu.Item
|
||||||
size="sm"
|
leftSection={<IconDetails size={14} />}
|
||||||
onClick={() => handlers.onDelete(row.original)}
|
onClick={() => handlers.onDetails(row.original)}
|
||||||
>
|
>
|
||||||
<IconTrash size={14} />
|
{t("exam.action.details", "Details")}
|
||||||
</ActionIcon>
|
</Menu.Item>
|
||||||
</RequirePermission>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||||
<ActionIcon
|
<Menu.Item
|
||||||
variant="subtle"
|
leftSection={<IconEdit size={14} />}
|
||||||
color="red"
|
onClick={() => handlers.onEdit(row.original)}
|
||||||
size="sm"
|
>
|
||||||
onClick={() => handlers.onDetails(row.original)}
|
{t("exam.action.edit", "Edit")}
|
||||||
>
|
</Menu.Item>
|
||||||
<IconDetails size={14} />
|
<Menu.Item
|
||||||
</ActionIcon>
|
leftSection={<IconToggleRight size={14} />}
|
||||||
</Group>
|
onClick={() => handlers.onOpenStatusChange(row.original)}
|
||||||
|
>
|
||||||
|
{t("exam.form.status")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconTrash size={14} />}
|
||||||
|
onClick={() => handlers.onDelete(row.original)}
|
||||||
|
>
|
||||||
|
{t("exam.action.delete", "Delete")}
|
||||||
|
</Menu.Item>
|
||||||
|
</RequirePermission>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { Badge, Text } from "@mantine/core";
|
import { Badge, Text } from "@mantine/core";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||||
|
import { StatusBadge } from '@ema-platform/ui';
|
||||||
import type { Exam } from "../../types/exam";
|
import type { Exam } from "../../types/exam";
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_TONE: Record<string, StatusTone> = {
|
||||||
PENDING: "gray",
|
PENDING: 'neutral',
|
||||||
ACTIVE: "blue",
|
ACTIVE: 'info',
|
||||||
COMPLETED: "teal",
|
COMPLETED: 'success',
|
||||||
CANCELLED: "red",
|
CANCELLED: 'danger',
|
||||||
POSTPONED: "orange",
|
POSTPONED: 'pending',
|
||||||
PUBLISHED: "green",
|
PUBLISHED: 'success',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function examColumns(
|
export function examColumns(
|
||||||
@@ -72,9 +74,12 @@ export function examColumns(
|
|||||||
{
|
{
|
||||||
header: t("exam.columns.status"),
|
header: t("exam.columns.status"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
<StatusBadge
|
||||||
{t(`exam.status.${row.original.status}`)}
|
tone={STATUS_TONE[row.original.status]}
|
||||||
</Badge>
|
label={t(`exam.status.${row.original.status}`)}
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Title,
|
|
||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -10,7 +9,6 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
Card,
|
Card,
|
||||||
Alert,
|
|
||||||
Select,
|
Select,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -26,6 +24,7 @@ import {
|
|||||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||||
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||||
|
import { RANK_KEY_OPTIONS } from "../../../certification/types/certification";
|
||||||
import {
|
import {
|
||||||
useGetExamsQuery,
|
useGetExamsQuery,
|
||||||
useCreateExamMutation,
|
useCreateExamMutation,
|
||||||
@@ -35,6 +34,7 @@ import {
|
|||||||
import type { Exam } from "../../types/exam";
|
import type { Exam } from "../../types/exam";
|
||||||
import { examColumns } from "./columns";
|
import { examColumns } from "./columns";
|
||||||
import { examActionsColumn } from "./actions";
|
import { examActionsColumn } from "./actions";
|
||||||
|
import { ErrorState, PageHeader } from '@ema-platform/ui';
|
||||||
|
|
||||||
function ExamForm({
|
function ExamForm({
|
||||||
editing,
|
editing,
|
||||||
@@ -77,21 +77,23 @@ function ExamForm({
|
|||||||
editing?.cuttingPoint ?? 0,
|
editing?.cuttingPoint ?? 0,
|
||||||
);
|
);
|
||||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>("basic");
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (
|
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
|
||||||
!certificationId ||
|
setActiveTab("basic");
|
||||||
!titleEn ||
|
notify.error(t("exam.form.fillRequiredBasic"));
|
||||||
!titleAm ||
|
return;
|
||||||
!date ||
|
}
|
||||||
!type ||
|
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
||||||
!form ||
|
setActiveTab("basic");
|
||||||
!venue ||
|
notify.error(t("exam.form.directionBothLanguages"));
|
||||||
!adminMethod ||
|
return;
|
||||||
!evalMethod
|
}
|
||||||
) {
|
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
|
||||||
notify.error("Please fill all required fields");
|
setActiveTab("settings");
|
||||||
|
notify.error(t("exam.form.fillRequiredSettings"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSubmit(
|
onSubmit(
|
||||||
@@ -121,7 +123,7 @@ function ExamForm({
|
|||||||
return (
|
return (
|
||||||
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
<Tabs value={activeTab} onChange={setActiveTab} variant="outline" radius="md">
|
||||||
<Tabs.List mb="md">
|
<Tabs.List mb="md">
|
||||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
||||||
{t("exam.form.basicInfo")}
|
{t("exam.form.basicInfo")}
|
||||||
@@ -247,11 +249,18 @@ function ExamForm({
|
|||||||
data={[
|
data={[
|
||||||
{ value: "ESSAY", label: t("exam.form.essay") },
|
{ value: "ESSAY", label: t("exam.form.essay") },
|
||||||
{ value: "CHOICE", label: t("exam.form.choice") },
|
{ value: "CHOICE", label: t("exam.form.choice") },
|
||||||
|
{ value: "BOTH", label: t("exam.form.both") },
|
||||||
]}
|
]}
|
||||||
value={form}
|
value={form}
|
||||||
onChange={setForm}
|
onChange={setForm}
|
||||||
size="sm"
|
size="sm"
|
||||||
required
|
required
|
||||||
|
disabled={adminMethod === "ONLINE"}
|
||||||
|
description={
|
||||||
|
adminMethod === "ONLINE"
|
||||||
|
? t("exam.form.onlineChoiceOnlyHint")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label={t("exam.detail.administration")}
|
label={t("exam.detail.administration")}
|
||||||
@@ -261,7 +270,14 @@ function ExamForm({
|
|||||||
{ value: "ONLINE", label: t("exam.form.online") },
|
{ value: "ONLINE", label: t("exam.form.online") },
|
||||||
]}
|
]}
|
||||||
value={adminMethod}
|
value={adminMethod}
|
||||||
onChange={setAdminMethod}
|
onChange={(value) => {
|
||||||
|
setAdminMethod(value);
|
||||||
|
// Online exams are graded automatically, and that only
|
||||||
|
// has an answer model for CHOICE — matches the backend
|
||||||
|
// rule (online_exam_requires_choice_form), not just a
|
||||||
|
// UI nicety.
|
||||||
|
if (value === "ONLINE") setForm("CHOICE");
|
||||||
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -291,12 +307,22 @@ function ExamForm({
|
|||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t("exam.form.cuttingPoint")}
|
label={t("exam.form.cuttingPoint")}
|
||||||
placeholder={t("exam.form.cuttingPointPlaceholder")}
|
placeholder={
|
||||||
|
evalMethod === "PERCENTAGE"
|
||||||
|
? t("exam.form.cuttingPointPercentagePlaceholder")
|
||||||
|
: t("exam.form.cuttingPointPlaceholder")
|
||||||
|
}
|
||||||
value={cuttingPoint}
|
value={cuttingPoint}
|
||||||
onChange={(v) => setCuttingPoint(Number(v))}
|
onChange={(v) => setCuttingPoint(Number(v))}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={evalMethod === "PERCENTAGE" ? 100 : undefined}
|
||||||
size="sm"
|
size="sm"
|
||||||
required
|
withAsterisk
|
||||||
|
description={
|
||||||
|
evalMethod === "PERCENTAGE"
|
||||||
|
? t("exam.form.cuttingPointPercentageHint")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
{editing && (
|
{editing && (
|
||||||
@@ -353,10 +379,24 @@ export function ExamPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
|
||||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
|
const [changingStatusId, setChangingStatusId] = useState<string | null>(null);
|
||||||
|
const [statusTarget, setStatusTarget] = useState<Exam | null>(null);
|
||||||
|
const [pendingStatus, setPendingStatus] = useState<Exam["status"] | null>(null);
|
||||||
|
const [statusOpened, { open: openStatus, close: closeStatus }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
// Rank in the label: an exam inherits its STCW rank from the certification
|
||||||
|
// it is created under (Certification.rankKey), so the officer sees which
|
||||||
|
// rank a sitting will serve at the moment they pick the subject.
|
||||||
const certOptions = certifications
|
const certOptions = certifications
|
||||||
.filter((c) => c.isActive)
|
.filter((c) => c.isActive)
|
||||||
.map((c) => ({ value: c.id, label: c.name[locale] }));
|
.map((c) => {
|
||||||
|
const rank = RANK_KEY_OPTIONS.find((r) => r.value === c.rankKey)?.label;
|
||||||
|
return {
|
||||||
|
value: c.id,
|
||||||
|
label: rank ? `${c.name[locale]} — ${rank}` : c.name[locale],
|
||||||
|
};
|
||||||
|
});
|
||||||
const getCertName = (id: string) =>
|
const getCertName = (id: string) =>
|
||||||
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
||||||
|
|
||||||
@@ -403,6 +443,21 @@ export function ExamPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChangeStatus = async () => {
|
||||||
|
if (!statusTarget || !pendingStatus) return;
|
||||||
|
setChangingStatusId(statusTarget.id);
|
||||||
|
try {
|
||||||
|
await updateExam({ id: statusTarget.id, status: pendingStatus }).unwrap();
|
||||||
|
notify.success(t("exam.updated"));
|
||||||
|
closeStatus();
|
||||||
|
setStatusTarget(null);
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
} finally {
|
||||||
|
setChangingStatusId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
try {
|
try {
|
||||||
@@ -416,13 +471,7 @@ export function ExamPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isError)
|
if (isError)
|
||||||
return (
|
return <ErrorState title={t("exam.loadError")} onRetry={refetch} />;
|
||||||
<Alert
|
|
||||||
icon={<IconInfoCircle size={16} />}
|
|
||||||
color="red"
|
|
||||||
title={t("exam.loadError")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
|
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
|
||||||
@@ -436,6 +485,12 @@ export function ExamPage() {
|
|||||||
openDelete();
|
openDelete();
|
||||||
},
|
},
|
||||||
onDetails: (exam) => navigate(`/exams/${exam.id}`),
|
onDetails: (exam) => navigate(`/exams/${exam.id}`),
|
||||||
|
onOpenStatusChange: (exam) => {
|
||||||
|
setStatusTarget(exam);
|
||||||
|
setPendingStatus(exam.status);
|
||||||
|
openStatus();
|
||||||
|
},
|
||||||
|
changingStatusId,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -443,26 +498,25 @@ export function ExamPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<PageHeader
|
||||||
<div>
|
title={t("exam.title")}
|
||||||
<Title order={2}>{t("exam.title")}</Title>
|
subtitle={t("exam.subtitle")}
|
||||||
<Text fz="sm" c="dimmed">
|
noMargin
|
||||||
{t("exam.subtitle")}
|
action={
|
||||||
</Text>
|
!showForm && (
|
||||||
</div>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||||
{!showForm && (
|
<Button
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
variant="light"
|
||||||
<Button
|
leftSection={<IconPlus size={16} />}
|
||||||
variant="light"
|
onClick={() => setShowForm(true)}
|
||||||
leftSection={<IconPlus size={16} />}
|
size="sm"
|
||||||
onClick={() => setShowForm(true)}
|
>
|
||||||
size="sm"
|
{t("exam.add")}
|
||||||
>
|
</Button>
|
||||||
{t("exam.add")}
|
</RequirePermission>
|
||||||
</Button>
|
)
|
||||||
</RequirePermission>
|
}
|
||||||
)}
|
/>
|
||||||
</Group>
|
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<ExamForm
|
<ExamForm
|
||||||
@@ -511,6 +565,47 @@ export function ExamPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Quick status change — not the full edit form */}
|
||||||
|
<Modal
|
||||||
|
opened={statusOpened}
|
||||||
|
onClose={closeStatus}
|
||||||
|
title={t("exam.form.status")}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{statusTarget?.title?.[locale]}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
label={t("exam.form.status")}
|
||||||
|
data={[
|
||||||
|
{ value: "PENDING", label: t("exam.form.pending") },
|
||||||
|
{ value: "ACTIVE", label: t("exam.form.active") },
|
||||||
|
{ value: "COMPLETED", label: t("exam.form.completed") },
|
||||||
|
{ value: "CANCELLED", label: t("exam.form.cancelled") },
|
||||||
|
{ value: "POSTPONED", label: t("exam.form.postponed") },
|
||||||
|
{ value: "PUBLISHED", label: t("exam.form.published") },
|
||||||
|
]}
|
||||||
|
value={pendingStatus}
|
||||||
|
onChange={(value) => setPendingStatus(value as Exam["status"])}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="default" onClick={closeStatus} size="sm">
|
||||||
|
{t("exam.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleChangeStatus}
|
||||||
|
size="sm"
|
||||||
|
loading={changingStatusId === statusTarget?.id}
|
||||||
|
disabled={pendingStatus === statusTarget?.status}
|
||||||
|
>
|
||||||
|
{t("exam.update")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { EstimatedTime } from "../../question/types/question";
|
|||||||
import type { QuestionForm } from "../../question/types/question";
|
import type { QuestionForm } from "../../question/types/question";
|
||||||
export type { QuestionForm };
|
export type { QuestionForm };
|
||||||
|
|
||||||
|
export type ExamForm = QuestionForm | "BOTH";
|
||||||
|
|
||||||
export type ExamType = "WRITTEN" | "ORAL";
|
export type ExamType = "WRITTEN" | "ORAL";
|
||||||
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
|
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
|
||||||
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
|
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
|
||||||
@@ -15,11 +17,19 @@ export type ExamStatus =
|
|||||||
| "POSTPONED"
|
| "POSTPONED"
|
||||||
| "PUBLISHED";
|
| "PUBLISHED";
|
||||||
|
|
||||||
|
/** Only populated when the exam is fetched with `?i=questions,questions.options`. */
|
||||||
|
export interface QuestionOptionBrief {
|
||||||
|
id: string;
|
||||||
|
text: LocalePair;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuestionBrief {
|
export interface QuestionBrief {
|
||||||
id: string;
|
id: string;
|
||||||
title: LocalePair;
|
title: LocalePair;
|
||||||
form: QuestionForm;
|
form: QuestionForm;
|
||||||
points: number;
|
points: number;
|
||||||
|
options?: QuestionOptionBrief[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Exam {
|
export interface Exam {
|
||||||
@@ -31,7 +41,7 @@ export interface Exam {
|
|||||||
date: string;
|
date: string;
|
||||||
givenTime: EstimatedTime | null;
|
givenTime: EstimatedTime | null;
|
||||||
type: ExamType;
|
type: ExamType;
|
||||||
form: QuestionForm;
|
form: ExamForm;
|
||||||
venue: string;
|
venue: string;
|
||||||
administrationMethod: ExamAdministrationMethod;
|
administrationMethod: ExamAdministrationMethod;
|
||||||
evaluationMethod: ExamEvaluationMethod;
|
evaluationMethod: ExamEvaluationMethod;
|
||||||
@@ -55,7 +65,7 @@ export interface CreateExamPayload {
|
|||||||
date: string;
|
date: string;
|
||||||
givenTime: EstimatedTime;
|
givenTime: EstimatedTime;
|
||||||
type: ExamType;
|
type: ExamType;
|
||||||
form: QuestionForm;
|
form: ExamForm;
|
||||||
venue: string;
|
venue: string;
|
||||||
administrationMethod: ExamAdministrationMethod;
|
administrationMethod: ExamAdministrationMethod;
|
||||||
evaluationMethod: ExamEvaluationMethod;
|
evaluationMethod: ExamEvaluationMethod;
|
||||||
@@ -71,7 +81,7 @@ export interface UpdateExamPayload {
|
|||||||
date?: string;
|
date?: string;
|
||||||
givenTime?: EstimatedTime;
|
givenTime?: EstimatedTime;
|
||||||
type?: ExamType;
|
type?: ExamType;
|
||||||
form?: QuestionForm;
|
form?: ExamForm;
|
||||||
venue?: string;
|
venue?: string;
|
||||||
administrationMethod?: ExamAdministrationMethod;
|
administrationMethod?: ExamAdministrationMethod;
|
||||||
evaluationMethod?: ExamEvaluationMethod;
|
evaluationMethod?: ExamEvaluationMethod;
|
||||||
@@ -118,8 +128,14 @@ export interface ExamRegistration {
|
|||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
seafarerNumber: string | null;
|
seafarerNumber: string | null;
|
||||||
};
|
};
|
||||||
|
/** The candidate's online sitting, when one has been started. */
|
||||||
|
attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RegradeOutcome =
|
||||||
|
| { graded: true; resultId: string }
|
||||||
|
| { graded: false; reason: string };
|
||||||
|
|
||||||
export interface RecordAttendancePayload {
|
export interface RecordAttendancePayload {
|
||||||
registrationId: string;
|
registrationId: string;
|
||||||
status: AttendanceStatus;
|
status: AttendanceStatus;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Badge } from '@mantine/core';
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { StatusBadge } from '@ema-platform/ui';
|
||||||
import type { Item } from '../../api/item-api';
|
import type { Item } from '../../api/item-api';
|
||||||
|
|
||||||
const STATUS_COLORS: Record<Item['status'], string> = {
|
const STATUS_TONES: Record<Item['status'], StatusTone> = {
|
||||||
DRAFT: 'gray',
|
DRAFT: 'neutral',
|
||||||
ACTIVE: 'green',
|
ACTIVE: 'success',
|
||||||
ARCHIVED: 'orange',
|
ARCHIVED: 'pending',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
|
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
|
||||||
@@ -14,7 +15,7 @@ export function itemColumns(showDate: (date: string) => string): AdvancedColumn<
|
|||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge color={STATUS_COLORS[row.original.status]}>{row.original.status}</Badge>
|
<StatusBadge tone={STATUS_TONES[row.original.status]} label={row.original.status} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Stack, Title, Paper } from '@mantine/core';
|
import {Stack, Paper} from '@mantine/core';
|
||||||
import { ItemTable } from '../components/ItemTable';
|
import { ItemTable } from '../components/ItemTable';
|
||||||
|
import { PageHeader } from '@ema-platform/ui';
|
||||||
|
|
||||||
export function ItemPage() {
|
export function ItemPage() {
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Title order={2}>Items</Title>
|
<PageHeader title="Items" noMargin />
|
||||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||||
<ItemTable />
|
<ItemTable />
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
|
import { Button, Text, Tooltip } from '@mantine/core';
|
||||||
import { IconShieldCog } from '@tabler/icons-react';
|
import { IconShieldCog } from '@tabler/icons-react';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { StatusBadge } from '@ema-platform/ui';
|
||||||
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
|
|
||||||
const LICENSE_STATUS_COLORS: Record<string, string> = {
|
const LICENSE_STATUS_TONES: Record<string, StatusTone> = {
|
||||||
ACTIVE: 'green',
|
ACTIVE: 'success',
|
||||||
EXPIRED: 'yellow',
|
EXPIRED: 'warning',
|
||||||
SUSPENDED: 'orange',
|
SUSPENDED: 'pending',
|
||||||
CANCELLED: 'red',
|
CANCELLED: 'danger',
|
||||||
SUPERSEDED: 'gray',
|
SUPERSEDED: 'neutral',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
|
export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
|
||||||
@@ -70,13 +72,12 @@ export function licenseRegisterColumns(
|
|||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<StatusBadge
|
||||||
|
tone={LICENSE_STATUS_TONES[row.original.status] ?? 'neutral'}
|
||||||
|
label={row.original.status}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={LICENSE_STATUS_COLORS[row.original.status] ?? 'gray'}
|
/>
|
||||||
>
|
|
||||||
{row.original.status}
|
|
||||||
</Badge>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Textarea} from '@mantine/core';
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
@@ -151,21 +139,19 @@ export function LicenseRegisterPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Group justify="space-between" mb="md">
|
<PageHeader
|
||||||
<div>
|
title="Licence register"
|
||||||
<Title order={3}>Licence register</Title>
|
subtitle={`${data?.total ?? 0} issued licence${(data?.total ?? 0) === 1 ? '' : 's'}`}
|
||||||
<Text size="sm" c="dimmed">
|
action={
|
||||||
{data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'}
|
<TextInput
|
||||||
</Text>
|
placeholder="Certificate № or company"
|
||||||
</div>
|
leftSection={<IconSearch size={14} />}
|
||||||
<TextInput
|
value={search}
|
||||||
placeholder="Certificate № or company"
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
leftSection={<IconSearch size={14} />}
|
w={280}
|
||||||
value={search}
|
/>
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
}
|
||||||
w={280}
|
/>
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Card withBorder padding={0}>
|
<Card withBorder padding={0}>
|
||||||
<AdvancedTable<IssuedLicense>
|
<AdvancedTable<IssuedLicense>
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Button, Modal, Select, Stack, Text } from '@mantine/core';
|
||||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ModalFooter } from '@ema-platform/ui';
|
import { ModalFooter } from '@ema-platform/ui';
|
||||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
import { useGetEligibleExamsQuery } from '@ema-platform/api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
|
applicationId: string;
|
||||||
applicantName: string;
|
applicantName: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (payload: {
|
onConfirm: (payload: { examId: string; examDate?: string }) => void;
|
||||||
examId: string;
|
|
||||||
admissionNumber?: string;
|
|
||||||
examDate?: string;
|
|
||||||
}) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,33 +19,34 @@ interface Props {
|
|||||||
*
|
*
|
||||||
* Sessions are picked from the exam calendar rather than typed, because the
|
* Sessions are picked from the exam calendar rather than typed, because the
|
||||||
* candidate joins a scheduled sitting — this is an assignment, not the creation
|
* candidate joins a scheduled sitting — this is an assignment, not the creation
|
||||||
* of a per-candidate appointment.
|
* of a per-candidate appointment. Scoped to sittings whose certification
|
||||||
|
* matches this application's rank, so a Chief Mate candidate cannot be seated
|
||||||
|
* into an OOW Deck sitting by accident.
|
||||||
*/
|
*/
|
||||||
export function ScheduleExamModal({
|
export function ScheduleExamModal({
|
||||||
opened,
|
opened,
|
||||||
|
applicationId,
|
||||||
applicantName,
|
applicantName,
|
||||||
loading,
|
loading,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: exams, isLoading } = useGetExamsQuery(undefined, { skip: !opened });
|
const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
|
||||||
const [examId, setExamId] = useState<string | null>(null);
|
const [examId, setExamId] = useState<string | null>(null);
|
||||||
const [admissionNumber, setAdmissionNumber] = useState('');
|
|
||||||
|
|
||||||
const options = (exams?.items ?? []).map((exam) => ({
|
const options = (exams ?? []).map((exam) => ({
|
||||||
value: exam.id,
|
value: exam.id,
|
||||||
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
|
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' — '),
|
.join(' — '),
|
||||||
}));
|
}));
|
||||||
const selected = exams?.items?.find((exam) => exam.id === examId);
|
const selected = exams?.find((exam) => exam.id === examId);
|
||||||
|
|
||||||
function confirm() {
|
function confirm() {
|
||||||
if (!examId) return;
|
if (!examId) return;
|
||||||
onConfirm({
|
onConfirm({
|
||||||
examId,
|
examId,
|
||||||
admissionNumber: admissionNumber.trim() || undefined,
|
|
||||||
examDate: selected?.date ? String(selected.date) : undefined,
|
examDate: selected?.date ? String(selected.date) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -72,7 +70,7 @@ export function ScheduleExamModal({
|
|||||||
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
|
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
|
||||||
{t(
|
{t(
|
||||||
'review.scheduleExam.noSessions',
|
'review.scheduleExam.noSessions',
|
||||||
'No exam sessions exist yet. Create one in the Exams area first.',
|
'No exam sessions for this rank exist yet. Create one in the Exams area first.',
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
@@ -88,15 +86,12 @@ export function ScheduleExamModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextInput
|
<Text size="xs" c="dimmed">
|
||||||
label={t('review.scheduleExam.admissionNumber', 'Admission number')}
|
{t(
|
||||||
description={t(
|
|
||||||
'review.scheduleExam.admissionHint',
|
'review.scheduleExam.admissionHint',
|
||||||
'Leave blank to let the system issue one.',
|
'An admission number is issued automatically when the candidate is seated.',
|
||||||
)}
|
)}
|
||||||
value={admissionNumber}
|
</Text>
|
||||||
onChange={(e) => setAdmissionNumber(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={onClose}>
|
<Button variant="default" onClick={onClose}>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +71,7 @@ import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
|
|||||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
|
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
|
||||||
import { licenseQueueColumns } from "./columns";
|
import { licenseQueueColumns } from "./columns";
|
||||||
import { licenseQueueActionsColumn } from "./actions";
|
import { licenseQueueActionsColumn } from "./actions";
|
||||||
|
import { PageHeader } from '@ema-platform/ui';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
@@ -501,16 +501,11 @@ export function LicenseQueuePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
||||||
<Group justify="space-between" mb="md">
|
<PageHeader
|
||||||
<div>
|
title={queueTitle}
|
||||||
<Title order={3}>{queueTitle}</Title>
|
subtitle={typeCode ? t(`nav.type${typeCode}`, { defaultValue: typeCode }) : undefined}
|
||||||
{typeCode && (
|
action={
|
||||||
<Text size="sm" c="dimmed">
|
<Group gap="xs">
|
||||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Group gap="xs">
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
size="xs"
|
size="xs"
|
||||||
value={density}
|
value={density}
|
||||||
@@ -534,8 +529,9 @@ export function LicenseQueuePage() {
|
|||||||
>
|
>
|
||||||
{t("queue.export", "Export CSV")}
|
{t("queue.export", "Export CSV")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Saved views, counted. */}
|
{/* Saved views, counted. */}
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
useLocalized,
|
useLocalized,
|
||||||
useApproveDocumentsMutation,
|
useApproveDocumentsMutation,
|
||||||
useAssignApplicationMutation,
|
useAssignApplicationMutation,
|
||||||
|
useClaimApplicationMutation,
|
||||||
useCompleteReviewMutation,
|
useCompleteReviewMutation,
|
||||||
useConfirmPaymentMutation,
|
useConfirmPaymentMutation,
|
||||||
useScheduleIssuanceMutation,
|
useScheduleIssuanceMutation,
|
||||||
@@ -198,6 +199,7 @@ export function LicenseReviewPage() {
|
|||||||
[requirements],
|
[requirements],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [claimApplication] = useClaimApplicationMutation();
|
||||||
const [completeReview] = useCompleteReviewMutation();
|
const [completeReview] = useCompleteReviewMutation();
|
||||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||||
const [approveDocuments] = useApproveDocumentsMutation();
|
const [approveDocuments] = useApproveDocumentsMutation();
|
||||||
@@ -531,8 +533,12 @@ export function LicenseReviewPage() {
|
|||||||
try {
|
try {
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
case "claim":
|
case "claim":
|
||||||
// Claim is fired from the queue in practice; kept here for the case
|
// Usually fired from the queue, but an officer can also open an
|
||||||
// where an officer opens an unclaimed application directly.
|
// unclaimed application directly and claim it from here.
|
||||||
|
await run(
|
||||||
|
() => claimApplication(id).unwrap(),
|
||||||
|
t("review.done.claim", "Application claimed"),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "complete-review":
|
case "complete-review":
|
||||||
await run(
|
await run(
|
||||||
@@ -724,7 +730,7 @@ export function LicenseReviewPage() {
|
|||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<div>
|
<div>
|
||||||
<Title order={3}>{headerName}</Title>
|
<Title order={2}>{headerName}</Title>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{app.applicationNumber}
|
{app.applicationNumber}
|
||||||
@@ -1156,6 +1162,7 @@ export function LicenseReviewPage() {
|
|||||||
|
|
||||||
<ScheduleExamModal
|
<ScheduleExamModal
|
||||||
opened={scheduleExamOpen}
|
opened={scheduleExamOpen}
|
||||||
|
applicationId={id}
|
||||||
applicantName={
|
applicantName={
|
||||||
app.companyName ||
|
app.companyName ||
|
||||||
applicantFullName ||
|
applicantFullName ||
|
||||||
|
|||||||
@@ -1,23 +1,9 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import {
|
import {Stack, Group, Button, Paper, Text, Grid, Modal, ActionIcon, Tooltip, Loader, Center, Alert} from '@mantine/core';
|
||||||
Stack,
|
|
||||||
Title,
|
|
||||||
Group,
|
|
||||||
Button,
|
|
||||||
Paper,
|
|
||||||
Text,
|
|
||||||
Grid,
|
|
||||||
Modal,
|
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
Loader,
|
|
||||||
Center,
|
|
||||||
Alert,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui';
|
import { ModalFooter, notify, PageHeader, PageLoader, useErrorHandler } from '@ema-platform/ui';
|
||||||
import { LocationTree } from '../components/LocationTree';
|
import { LocationTree } from '../components/LocationTree';
|
||||||
import { LocationDetail } from '../components/LocationDetail';
|
import { LocationDetail } from '../components/LocationDetail';
|
||||||
import { LocationForm } from '../components/LocationForm';
|
import { LocationForm } from '../components/LocationForm';
|
||||||
@@ -108,9 +94,11 @@ export function LocationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between">
|
<PageHeader
|
||||||
<Title order={2}>{t('location.title')}</Title>
|
title={t('location.title')}
|
||||||
<Group gap="sm">
|
noMargin
|
||||||
|
action={
|
||||||
|
<Group gap="sm">
|
||||||
{locationTypes.length > 0 && (
|
{locationTypes.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -135,8 +123,9 @@ export function LocationPage() {
|
|||||||
<IconSettings size={20} />
|
<IconSettings size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{locationTypes.length === 0 && (
|
{locationTypes.length === 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {Badge, Card, Center, Container, Grid, Group, Loader, SimpleGrid, Stack, Text} from '@mantine/core';
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
Center,
|
|
||||||
Container,
|
|
||||||
Grid,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconChevronRight } from '@tabler/icons-react';
|
import { IconChevronRight } from '@tabler/icons-react';
|
||||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
@@ -74,12 +62,10 @@ export function LogisticsHeadDashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Title order={3} mb="xs">
|
<PageHeader
|
||||||
Logistics overview
|
title="Logistics overview"
|
||||||
</Title>
|
subtitle="Licence applications currently in the department."
|
||||||
<Text size="sm" c="dimmed" mb="lg">
|
/>
|
||||||
Licence applications currently in the department.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { Badge, Text } from '@mantine/core';
|
import { Badge, Text } from '@mantine/core';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { StatusBadge } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
seaServiceDays,
|
seaServiceDays,
|
||||||
type MedicalCertificate,
|
type MedicalCertificate,
|
||||||
@@ -18,10 +20,10 @@ export function ownerName(profile?: SeafarerProfileSummary): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
|
const STATUS_TONE: Record<SeafarerRecordStatus, StatusTone> = {
|
||||||
SUBMITTED: 'yellow',
|
SUBMITTED: 'warning',
|
||||||
VERIFIED: 'teal',
|
VERIFIED: 'success',
|
||||||
REJECTED: 'red',
|
REJECTED: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Only meaningful now the queue can show ruled records too. */
|
/** Only meaningful now the queue can show ruled records too. */
|
||||||
@@ -33,9 +35,12 @@ function statusColumn<T extends { status: SeafarerRecordStatus }>(
|
|||||||
label: t('recordVerification.columns.status', 'Status'),
|
label: t('recordVerification.columns.status', 'Status'),
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
<StatusBadge
|
||||||
{t(`recordVerification.status.${row.original.status}`, row.original.status)}
|
tone={STATUS_TONE[row.original.status]}
|
||||||
</Badge>
|
label={t(`recordVerification.status.${row.original.status}`, row.original.status)}
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,8 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {Badge, Button, Center, Container, Group, Loader, Modal, Paper, SegmentedControl, Stack, Text, Textarea} from '@mantine/core';
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Paper,
|
|
||||||
SegmentedControl,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
|
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
|
||||||
import {
|
import { AdvancedTable, notify, PageHeader, PdfPreviewModal, type AdvancedColumn } from '@ema-platform/ui';
|
||||||
AdvancedTable,
|
|
||||||
notify,
|
|
||||||
PdfPreviewModal,
|
|
||||||
type AdvancedColumn,
|
|
||||||
} from '@ema-platform/ui';
|
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
@@ -198,20 +179,21 @@ export type VerificationKind = 'medical' | 'sea-service';
|
|||||||
*/
|
*/
|
||||||
export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) {
|
export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isMedical = kind === 'medical';
|
||||||
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
|
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
|
||||||
const {
|
const {
|
||||||
data: pendingMedical,
|
data: pendingMedical,
|
||||||
isLoading: loadingMedical,
|
isLoading: loadingMedical,
|
||||||
isFetching: fetchingMedical,
|
isFetching: fetchingMedical,
|
||||||
refetch: refetchMedical,
|
refetch: refetchMedical,
|
||||||
} = useGetPendingMedicalQuery(filter);
|
} = useGetPendingMedicalQuery(filter, { skip: !isMedical });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: pendingSeaService,
|
data: pendingSeaService,
|
||||||
isLoading: loadingSeaService,
|
isLoading: loadingSeaService,
|
||||||
isFetching: fetchingSeaService,
|
isFetching: fetchingSeaService,
|
||||||
refetch: refetchSeaService,
|
refetch: refetchSeaService,
|
||||||
} = useGetPendingSeaServiceQuery(filter);
|
} = useGetPendingSeaServiceQuery(filter, { skip: isMedical });
|
||||||
|
|
||||||
const [verifyMedical, { isLoading: rulingMedical }] =
|
const [verifyMedical, { isLoading: rulingMedical }] =
|
||||||
useVerifyMedicalCertificateMutation();
|
useVerifyMedicalCertificateMutation();
|
||||||
@@ -372,26 +354,28 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
|||||||
[rulingSeaService, rule, verifySeaService, showDate, t],
|
[rulingSeaService, rule, verifySeaService, showDate, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isMedical = kind === 'medical';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Title order={3} mb={4}>
|
<PageHeader
|
||||||
{isMedical
|
title={
|
||||||
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
isMedical
|
||||||
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
|
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
||||||
</Title>
|
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
}
|
||||||
{isMedical
|
subtitle={
|
||||||
? t(
|
isMedical
|
||||||
'recordVerification.medicalSubtitle',
|
? t(
|
||||||
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
|
'recordVerification.medicalSubtitle',
|
||||||
)
|
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
|
||||||
: t(
|
)
|
||||||
'recordVerification.seaServiceSubtitle',
|
: t(
|
||||||
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
|
'recordVerification.seaServiceSubtitle',
|
||||||
)}
|
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
|
||||||
</Text>
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{statusFilter}
|
||||||
|
|
||||||
{isMedical ? (
|
{isMedical ? (
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
@@ -408,7 +392,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
|||||||
onPageSizeChange={handleMedicalPageSizeChange}
|
onPageSizeChange={handleMedicalPageSizeChange}
|
||||||
refresh={refetchMedical}
|
refresh={refetchMedical}
|
||||||
isLoading={loadingMedical || fetchingMedical}
|
isLoading={loadingMedical || fetchingMedical}
|
||||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
emptyText={emptyText}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
@@ -425,7 +409,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
|||||||
onPageSizeChange={handleSeaServicePageSizeChange}
|
onPageSizeChange={handleSeaServicePageSizeChange}
|
||||||
refresh={refetchSeaService}
|
refresh={refetchSeaService}
|
||||||
isLoading={loadingSeaService || fetchingSeaService}
|
isLoading={loadingSeaService || fetchingSeaService}
|
||||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
emptyText={emptyText}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {Alert, Badge, Button, Center, Group, Loader, Modal, NumberInput, Paper, Stack, Switch, Text, TextInput, ThemeIcon, Tooltip} from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
NumberInput,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconLock,
|
IconLock,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import {
|
import { AdvancedTable, ModalFooter, notify, PageHeader, PageLoader, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||||
notify,
|
|
||||||
ModalFooter,
|
|
||||||
AdvancedTable,
|
|
||||||
useServerTable,
|
|
||||||
type AdvancedColumn,
|
|
||||||
PageLoader,
|
|
||||||
} from '@ema-platform/ui';
|
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
useLocalized,
|
useLocalized,
|
||||||
@@ -92,20 +68,19 @@ export function PaymentConfigPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-start">
|
<PageHeader
|
||||||
<div>
|
title={t('paymentConfig.title', 'Payment configuration')}
|
||||||
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
|
subtitle={t(
|
||||||
<Text size="sm" c="dimmed" mt={4}>
|
'paymentConfig.subtitle',
|
||||||
{t(
|
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||||
'paymentConfig.subtitle',
|
)}
|
||||||
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
noMargin
|
||||||
)}
|
action={
|
||||||
</Text>
|
<ThemeIcon size="xl" radius="md" variant="light">
|
||||||
</div>
|
<IconCreditCard size={22} />
|
||||||
<ThemeIcon size="xl" radius="md" variant="light">
|
</ThemeIcon>
|
||||||
<IconCreditCard size={22} />
|
}
|
||||||
</ThemeIcon>
|
/>
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
IconLock,
|
IconLock,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconPhone,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconShieldLock,
|
IconShieldLock,
|
||||||
IconSun,
|
IconSun,
|
||||||
@@ -42,7 +41,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber } from '@ema-platform/ui';
|
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
||||||
import type { AuthUser } from '@ema-platform/auth';
|
import type { AuthUser } from '@ema-platform/auth';
|
||||||
@@ -136,6 +135,9 @@ export function ProfilePage() {
|
|||||||
register: registerProfile,
|
register: registerProfile,
|
||||||
handleSubmit: handleProfileSubmit,
|
handleSubmit: handleProfileSubmit,
|
||||||
reset: resetProfile,
|
reset: resetProfile,
|
||||||
|
watch: watchProfile,
|
||||||
|
setValue: setValueProfile,
|
||||||
|
trigger: triggerProfile,
|
||||||
formState: { errors: profileErrors },
|
formState: { errors: profileErrors },
|
||||||
} = useForm<ProfileValues>({
|
} = useForm<ProfileValues>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
@@ -247,7 +249,7 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg" maw={900}>
|
<Stack gap="lg" maw={900}>
|
||||||
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
|
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} noMargin />
|
||||||
|
|
||||||
{/* Profile summary */}
|
{/* Profile summary */}
|
||||||
<Paper p="lg" shadow="sm" radius="lg" withBorder>
|
<Paper p="lg" shadow="sm" radius="lg" withBorder>
|
||||||
@@ -372,11 +374,12 @@ export function ProfilePage() {
|
|||||||
error={profileErrors.email?.message}
|
error={profileErrors.email?.message}
|
||||||
{...registerProfile('email')}
|
{...registerProfile('email')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<PhoneInput
|
||||||
label={t('profile.fields.phone')}
|
label={t('profile.fields.phone')}
|
||||||
leftSection={<IconPhone size={18} />}
|
value={watchProfile('phoneNumber') || ''}
|
||||||
|
onChange={(val) => setValueProfile('phoneNumber', val, { shouldValidate: !!profileErrors.phoneNumber })}
|
||||||
|
onBlur={() => triggerProfile('phoneNumber')}
|
||||||
error={profileErrors.phoneNumber?.message}
|
error={profileErrors.phoneNumber?.message}
|
||||||
{...registerProfile('phoneNumber')}
|
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { baseApi } from '@ema-platform/api';
|
import { baseApi } from '@ema-platform/api';
|
||||||
import type {
|
import type {
|
||||||
Question,
|
Question,
|
||||||
|
QuestionOption,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
CreateQuestionPayload,
|
CreateQuestionPayload,
|
||||||
UpdateQuestionPayload,
|
UpdateQuestionPayload,
|
||||||
ReviewQuestionPayload,
|
ReviewQuestionPayload,
|
||||||
|
SetQuestionOptionsPayload,
|
||||||
} from '../types/question';
|
} from '../types/question';
|
||||||
|
|
||||||
const questionApi = baseApi.injectEndpoints({
|
const questionApi = baseApi.injectEndpoints({
|
||||||
@@ -17,6 +19,11 @@ const questionApi = baseApi.injectEndpoints({
|
|||||||
query: (id) => `/questions/${id}`,
|
query: (id) => `/questions/${id}`,
|
||||||
providesTags: ['Api'],
|
providesTags: ['Api'],
|
||||||
}),
|
}),
|
||||||
|
/** Same question, with `options` populated — the MCQ authoring editor. */
|
||||||
|
getQuestionWithOptions: builder.query<Question, string>({
|
||||||
|
query: (id) => `/questions/${id}?i=options`,
|
||||||
|
providesTags: ['Api'],
|
||||||
|
}),
|
||||||
createQuestion: builder.mutation<Question, CreateQuestionPayload>({
|
createQuestion: builder.mutation<Question, CreateQuestionPayload>({
|
||||||
query: (body) => ({ url: '/questions', method: 'POST', body }),
|
query: (body) => ({ url: '/questions', method: 'POST', body }),
|
||||||
invalidatesTags: ['Api'],
|
invalidatesTags: ['Api'],
|
||||||
@@ -47,6 +54,15 @@ const questionApi = baseApi.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
invalidatesTags: ['Api'],
|
invalidatesTags: ['Api'],
|
||||||
}),
|
}),
|
||||||
|
/** Full replace of a CHOICE question's options + correct-answer set (Phase 2). */
|
||||||
|
setQuestionOptions: builder.mutation<QuestionOption[], SetQuestionOptionsPayload>({
|
||||||
|
query: ({ id, ...body }) => ({
|
||||||
|
url: `/questions/${id}/options`,
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: ['Api'],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@@ -54,9 +70,11 @@ const questionApi = baseApi.injectEndpoints({
|
|||||||
export const {
|
export const {
|
||||||
useGetQuestionsQuery,
|
useGetQuestionsQuery,
|
||||||
useGetQuestionQuery,
|
useGetQuestionQuery,
|
||||||
|
useGetQuestionWithOptionsQuery,
|
||||||
useCreateQuestionMutation,
|
useCreateQuestionMutation,
|
||||||
useUpdateQuestionMutation,
|
useUpdateQuestionMutation,
|
||||||
useDeleteQuestionMutation,
|
useDeleteQuestionMutation,
|
||||||
useSubmitQuestionMutation,
|
useSubmitQuestionMutation,
|
||||||
useReviewQuestionMutation,
|
useReviewQuestionMutation,
|
||||||
|
useSetQuestionOptionsMutation,
|
||||||
} = questionApi;
|
} = questionApi;
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text, TextInput } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||||
|
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||||
|
import type { BilingualValue } from '@ema-platform/ui';
|
||||||
|
import {
|
||||||
|
useGetQuestionWithOptionsQuery,
|
||||||
|
useSetQuestionOptionsMutation,
|
||||||
|
} from '../api/question-api';
|
||||||
|
|
||||||
|
interface DraftOption {
|
||||||
|
text: BilingualValue;
|
||||||
|
isCorrect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCQ options + correct-answer editor for a CHOICE-form question (Phase 2).
|
||||||
|
*
|
||||||
|
* Only reachable while editing an already-created question — options attach
|
||||||
|
* to a question id, matching the backend's `PUT /questions/:id/options`
|
||||||
|
* full-replace endpoint. Nothing here is ever shown to a candidate; this is
|
||||||
|
* the authoring side only.
|
||||||
|
*/
|
||||||
|
export function QuestionOptionsEditor({ questionId }: { questionId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
|
const { data: question, isFetching } = useGetQuestionWithOptionsQuery(questionId);
|
||||||
|
const [setOptions, { isLoading: isSaving }] = useSetQuestionOptionsMutation();
|
||||||
|
const [draft, setDraft] = useState<DraftOption[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!question) return;
|
||||||
|
const existing = question.options ?? [];
|
||||||
|
setDraft(
|
||||||
|
existing.length
|
||||||
|
? existing
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((o) => ({ text: o.text, isCorrect: false }))
|
||||||
|
: [
|
||||||
|
{ text: { en: '', am: '' }, isCorrect: false },
|
||||||
|
{ text: { en: '', am: '' }, isCorrect: false },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
// isCorrect never comes back from the API by design — an examiner
|
||||||
|
// re-editing options re-marks the correct one(s) rather than us
|
||||||
|
// pretending to know what they were.
|
||||||
|
}, [question]);
|
||||||
|
|
||||||
|
const updateField = (index: number, lang: keyof BilingualValue, value: string) => {
|
||||||
|
setDraft((prev) =>
|
||||||
|
prev.map((o, i) => (i === index ? { ...o, text: { ...o.text, [lang]: value } } : o)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCorrect = (index: number) => {
|
||||||
|
setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, isCorrect: !o.isCorrect } : o)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addOption = () => {
|
||||||
|
setDraft((prev) => [...prev, { text: { en: '', am: '' }, isCorrect: false }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOption = (index: number) => {
|
||||||
|
setDraft((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (draft.length < 2) {
|
||||||
|
notify.error(t('question.options.needAtLeastTwo'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!draft.some((o) => o.isCorrect)) {
|
||||||
|
notify.error(t('question.options.needOneCorrect'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft.some((o) => !o.text.en.trim() || !o.text.am.trim())) {
|
||||||
|
notify.error(t('question.options.textRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setOptions({ id: questionId, options: draft }).unwrap();
|
||||||
|
notify.success(t('question.options.saved'));
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFetching) return <Loader size="sm" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Alert icon={<IconInfoCircle size={15} />} color="blue" variant="light">
|
||||||
|
{t('question.options.hint')}
|
||||||
|
</Alert>
|
||||||
|
{draft.map((option, index) => (
|
||||||
|
<Group key={index} gap="xs" wrap="nowrap" align="center">
|
||||||
|
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
|
||||||
|
<Stack gap={6} style={{ flex: 1 }}>
|
||||||
|
<TextInput
|
||||||
|
label={t('question.options.optionEn', { number: index + 1 })}
|
||||||
|
value={option.text.en}
|
||||||
|
onChange={(e) => updateField(index, 'en', e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t('question.options.optionAm', { number: index + 1 })}
|
||||||
|
value={option.text.am}
|
||||||
|
onChange={(e) => updateField(index, 'am', e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Checkbox
|
||||||
|
label={t('question.options.correct')}
|
||||||
|
checked={option.isCorrect}
|
||||||
|
onChange={() => toggleCorrect(index)}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
disabled={draft.length <= 2}
|
||||||
|
onClick={() => removeOption(index)}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconPlus size={14} />} onClick={addOption}>
|
||||||
|
{t('question.options.addOption')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" loading={isSaving} onClick={handleSave}>
|
||||||
|
{t('question.options.save')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Text fz="xs" c="dimmed">{t('question.options.replaceNotice')}</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ActionIcon, Button, Group } from '@mantine/core';
|
import { ActionIcon, Menu } from '@mantine/core';
|
||||||
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
import {
|
||||||
|
IconDotsVertical,
|
||||||
|
IconEdit,
|
||||||
|
IconGavel,
|
||||||
|
IconSend,
|
||||||
|
IconTrash,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
@@ -21,52 +27,64 @@ export function questionActionsColumn(
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const q = row.original;
|
const q = row.original;
|
||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Menu shadow="md" width={180} position="bottom-end">
|
||||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
<Menu.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
loading={handlers.isSubmittingReview}
|
||||||
|
>
|
||||||
|
<IconDotsVertical size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||||
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconSend size={14} />}
|
||||||
|
onClick={() => handlers.onSubmitForApproval(q)}
|
||||||
|
>
|
||||||
|
{t('question.qc.submit')}
|
||||||
|
</Menu.Item>
|
||||||
|
</RequirePermission>
|
||||||
|
)}
|
||||||
|
{q.status === 'PENDING_APPROVAL' && (
|
||||||
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
||||||
|
<Menu.Item color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
|
||||||
|
{t('question.qc.approve')}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
|
||||||
|
{t('question.qc.reject')}
|
||||||
|
</Menu.Item>
|
||||||
|
</RequirePermission>
|
||||||
|
)}
|
||||||
|
{q.status === 'APPROVED' && (
|
||||||
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
||||||
|
<Menu.Item
|
||||||
|
color="dark"
|
||||||
|
leftSection={<IconGavel size={14} />}
|
||||||
|
onClick={() => handlers.onReview(q, 'RETIRED')}
|
||||||
|
>
|
||||||
|
{t('question.qc.retire')}
|
||||||
|
</Menu.Item>
|
||||||
|
</RequirePermission>
|
||||||
|
)}
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||||
<Button
|
<Menu.Divider />
|
||||||
size="compact-xs"
|
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => handlers.onEdit(q)}>
|
||||||
variant="light"
|
{t('question.action.edit', 'Edit')}
|
||||||
leftSection={<IconSend size={12} />}
|
</Menu.Item>
|
||||||
loading={handlers.isSubmittingReview}
|
<Menu.Item
|
||||||
onClick={() => handlers.onSubmitForApproval(q)}
|
color="red"
|
||||||
|
leftSection={<IconTrash size={14} />}
|
||||||
|
onClick={() => handlers.onDelete(q)}
|
||||||
>
|
>
|
||||||
{t('question.qc.submit')}
|
{t('question.action.delete', 'Delete')}
|
||||||
</Button>
|
</Menu.Item>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
)}
|
</Menu.Dropdown>
|
||||||
{q.status === 'PENDING_APPROVAL' && (
|
</Menu>
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
|
||||||
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
|
|
||||||
{t('question.qc.approve')}
|
|
||||||
</Button>
|
|
||||||
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
|
|
||||||
{t('question.qc.reject')}
|
|
||||||
</Button>
|
|
||||||
</RequirePermission>
|
|
||||||
)}
|
|
||||||
{q.status === 'APPROVED' && (
|
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="dark"
|
|
||||||
leftSection={<IconGavel size={12} />}
|
|
||||||
onClick={() => handlers.onReview(q, 'RETIRED')}
|
|
||||||
>
|
|
||||||
{t('question.qc.retire')}
|
|
||||||
</Button>
|
|
||||||
</RequirePermission>
|
|
||||||
)}
|
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
|
||||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
|
|
||||||
<IconEdit size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
|
|
||||||
<IconTrash size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</RequirePermission>
|
|
||||||
</Group>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Title,
|
Title,
|
||||||
@@ -13,14 +13,30 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from '@mantine/core';
|
Checkbox,
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
ActionIcon,
|
||||||
import { useTranslation } from 'react-i18next';
|
} from "@mantine/core";
|
||||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
import { useTranslation } from "react-i18next";
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import {
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
IconPlus,
|
||||||
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
IconInfoCircle,
|
||||||
|
IconTrash,
|
||||||
|
IconGripVertical,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import {
|
||||||
|
AdvancedColumn,
|
||||||
|
AdvancedTable,
|
||||||
|
ErrorState,
|
||||||
|
ModalFooter,
|
||||||
|
notify,
|
||||||
|
PageHeader,
|
||||||
|
useErrorHandler,
|
||||||
|
useServerTable,
|
||||||
|
} from "@ema-platform/ui";
|
||||||
|
import { extractErrorMessage } from "@ema-platform/api";
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||||
|
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||||
import {
|
import {
|
||||||
useGetQuestionsQuery,
|
useGetQuestionsQuery,
|
||||||
useCreateQuestionMutation,
|
useCreateQuestionMutation,
|
||||||
@@ -28,10 +44,92 @@ import {
|
|||||||
useDeleteQuestionMutation,
|
useDeleteQuestionMutation,
|
||||||
useSubmitQuestionMutation,
|
useSubmitQuestionMutation,
|
||||||
useReviewQuestionMutation,
|
useReviewQuestionMutation,
|
||||||
} from '../../api/question-api';
|
useSetQuestionOptionsMutation,
|
||||||
import type { Question, QuestionForm } from '../../types/question';
|
} from "../../api/question-api";
|
||||||
import { questionColumns } from './columns';
|
import type {
|
||||||
import { questionActionsColumn } from './actions';
|
Question,
|
||||||
|
QuestionForm,
|
||||||
|
QuestionOptionInput,
|
||||||
|
} from "../../types/question";
|
||||||
|
import { QuestionOptionsEditor } from "../../components/QuestionOptionsEditor";
|
||||||
|
import { questionColumns } from "./columns";
|
||||||
|
import { questionActionsColumn } from "./actions";
|
||||||
|
|
||||||
|
type DraftOption = { textEn: string; textAm: string; isCorrect: boolean };
|
||||||
|
|
||||||
|
const BLANK_DRAFT_OPTIONS: DraftOption[] = [
|
||||||
|
{ textEn: "", textAm: "", isCorrect: false },
|
||||||
|
{ textEn: "", textAm: "", isCorrect: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for a brand-new CHOICE question, entered inline in the same
|
||||||
|
* modal — no question id exists yet, so this is pure local state, only
|
||||||
|
* turned into a real setOptions() call once the question itself is
|
||||||
|
* created (see QuestionPage.handleSubmit).
|
||||||
|
*/
|
||||||
|
function InlineOptionsEditor({
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
options: DraftOption[];
|
||||||
|
onChange: (options: DraftOption[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const update = (index: number, patch: Partial<DraftOption>) =>
|
||||||
|
onChange(options.map((o, i) => (i === index ? { ...o, ...patch } : o)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<Group key={index} gap="xs" wrap="nowrap" align="center">
|
||||||
|
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
|
||||||
|
<Stack gap={6} style={{ flex: 1 }}>
|
||||||
|
<TextInput
|
||||||
|
label={t("question.options.optionEn", { number: index + 1 })}
|
||||||
|
value={option.textEn}
|
||||||
|
onChange={(e) => update(index, { textEn: e.currentTarget.value })}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t("question.options.optionAm", { number: index + 1 })}
|
||||||
|
value={option.textAm}
|
||||||
|
onChange={(e) => update(index, { textAm: e.currentTarget.value })}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Checkbox
|
||||||
|
label={t("question.options.correct")}
|
||||||
|
checked={option.isCorrect}
|
||||||
|
onChange={() => update(index, { isCorrect: !option.isCorrect })}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
disabled={options.length <= 2}
|
||||||
|
onClick={() => onChange(options.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconPlus size={14} />}
|
||||||
|
onClick={() =>
|
||||||
|
onChange([...options, { textEn: "", textAm: "", isCorrect: false }])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("question.options.addOption")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function QuestionForm({
|
function QuestionForm({
|
||||||
editing,
|
editing,
|
||||||
@@ -43,58 +141,181 @@ function QuestionForm({
|
|||||||
editing: Question | null;
|
editing: Question | null;
|
||||||
certOptions: { value: string; label: string }[];
|
certOptions: { value: string; label: string }[];
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
onSubmit: (values: {
|
onSubmit: (
|
||||||
certificationId: string;
|
values: {
|
||||||
titleEn: string;
|
certificationId: string;
|
||||||
titleAm: string;
|
titleEn: string;
|
||||||
form: string;
|
titleAm: string;
|
||||||
points: number;
|
form: string;
|
||||||
days: number;
|
points: number;
|
||||||
hours: number;
|
days: number;
|
||||||
minutes: number;
|
hours: number;
|
||||||
}, isEdit: boolean) => void;
|
minutes: number;
|
||||||
|
draftOptions: DraftOption[];
|
||||||
|
},
|
||||||
|
isEdit: boolean,
|
||||||
|
) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
|
const [certificationId, setCertificationId] = useState<string | null>(
|
||||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
|
editing?.certificationId ?? null,
|
||||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
|
);
|
||||||
|
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
|
||||||
|
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
|
||||||
|
|
||||||
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
||||||
const [points, setPoints] = useState<number>(editing?.points ?? 0);
|
const [points, setPoints] = useState<number>(editing?.points ?? 0);
|
||||||
const [days, setDays] = useState(editing?.time?.days ?? 0);
|
const [days, setDays] = useState(editing?.time?.days ?? 0);
|
||||||
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
|
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
|
||||||
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
|
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
|
||||||
|
const [draftOptions, setDraftOptions] =
|
||||||
|
useState<DraftOption[]>(BLANK_DRAFT_OPTIONS);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!certificationId || !titleEn || !titleAm || !form) {
|
if (!certificationId || !titleEn || !titleAm || !form) {
|
||||||
notify.error('Please fill all required fields');
|
notify.error("Please fill all required fields");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSubmit({
|
if (!editing && form === "CHOICE") {
|
||||||
certificationId, titleEn, titleAm, form, points, days, hours, minutes
|
if (draftOptions.length < 2) {
|
||||||
}, !!editing);
|
notify.error(t("question.options.needAtLeastTwo"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!draftOptions.some((o) => o.isCorrect)) {
|
||||||
|
notify.error(t("question.options.needOneCorrect"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draftOptions.some((o) => !o.textEn.trim() || !o.textAm.trim())) {
|
||||||
|
notify.error(t("question.options.textRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSubmit(
|
||||||
|
{
|
||||||
|
certificationId,
|
||||||
|
titleEn,
|
||||||
|
titleAm,
|
||||||
|
form,
|
||||||
|
points,
|
||||||
|
days,
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
draftOptions: !editing && form === "CHOICE" ? draftOptions : [],
|
||||||
|
},
|
||||||
|
!!editing,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal opened onClose={onCancel} title={editing ? t('question.update') : t('question.addQuestion')} size="lg">
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onCancel}
|
||||||
|
title={editing ? t("question.update") : t("question.addQuestion")}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
<Select
|
||||||
<TextInput label={t('question.form.titleEn')} placeholder={t('question.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
|
label={t("question.form.certification")}
|
||||||
<TextInput label={t('question.form.titleAm')} placeholder={t('question.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
|
placeholder={t("question.form.selectCertification")}
|
||||||
<Select label={t('question.form.form')} placeholder={t('question.form.selectForm')} data={[{ value: 'ESSAY', label: t('question.form.essay') }, { value: 'CHOICE', label: t('question.form.choice') }]} value={form} onChange={setForm} size="sm" required />
|
data={certOptions}
|
||||||
<NumberInput label={t('question.form.points')} placeholder={t('question.form.pointsPlaceholder')} value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required />
|
value={certificationId}
|
||||||
<Text fz="sm" fw={500}>{t('question.form.timeAllowed')}</Text>
|
onChange={setCertificationId}
|
||||||
|
size="sm"
|
||||||
|
searchable
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t("question.form.titleEn")}
|
||||||
|
placeholder={t("question.form.titleEnPlaceholder")}
|
||||||
|
value={titleEn}
|
||||||
|
onChange={(e) => setTitleEn(e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t("question.form.titleAm")}
|
||||||
|
placeholder={t("question.form.titleAmPlaceholder")}
|
||||||
|
value={titleAm}
|
||||||
|
onChange={(e) => setTitleAm(e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t("question.form.form")}
|
||||||
|
placeholder={t("question.form.selectForm")}
|
||||||
|
data={[
|
||||||
|
{ value: "ESSAY", label: t("question.form.essay") },
|
||||||
|
{ value: "CHOICE", label: t("question.form.choice") },
|
||||||
|
]}
|
||||||
|
value={form}
|
||||||
|
onChange={setForm}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label={t("question.form.points")}
|
||||||
|
placeholder={t("question.form.pointsPlaceholder")}
|
||||||
|
value={points}
|
||||||
|
onChange={(v) => setPoints(Number(v))}
|
||||||
|
min={0}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{t("question.form.timeAllowed")}
|
||||||
|
</Text>
|
||||||
<Group gap="sm" grow>
|
<Group gap="sm" grow>
|
||||||
<NumberInput label={t('question.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
|
<NumberInput
|
||||||
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
|
label={t("question.form.days")}
|
||||||
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
value={days}
|
||||||
|
onChange={(v) => setDays(Number(v))}
|
||||||
|
min={0}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label={t("question.form.hours")}
|
||||||
|
value={hours}
|
||||||
|
onChange={(v) => setHours(Number(v))}
|
||||||
|
min={0}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label={t("question.form.minutes")}
|
||||||
|
value={minutes}
|
||||||
|
onChange={(v) => setMinutes(Number(v))}
|
||||||
|
min={0}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
{editing && form === "CHOICE" && (
|
||||||
|
<>
|
||||||
|
<Text fz="sm" fw={500} mt="sm">
|
||||||
|
{t("question.options.title")}
|
||||||
|
</Text>
|
||||||
|
<QuestionOptionsEditor questionId={editing.id} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!editing && form === "CHOICE" && (
|
||||||
|
<>
|
||||||
|
<Text fz="sm" fw={500} mt="sm">
|
||||||
|
{t("question.options.title")}
|
||||||
|
</Text>
|
||||||
|
<InlineOptionsEditor
|
||||||
|
options={draftOptions}
|
||||||
|
onChange={setDraftOptions}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
|
<Button variant="default" onClick={onCancel} size="sm">
|
||||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
|
{t("question.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||||
|
{editing ? t("question.update") : t("question.create")}
|
||||||
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
@@ -104,7 +325,7 @@ function QuestionForm({
|
|||||||
|
|
||||||
export function QuestionPage() {
|
export function QuestionPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as "en" | "am";
|
||||||
const { handleError } = useErrorHandler();
|
const { handleError } = useErrorHandler();
|
||||||
const { data: certRes } = useGetCertificationsQuery();
|
const { data: certRes } = useGetCertificationsQuery();
|
||||||
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
|
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
|
||||||
@@ -112,7 +333,10 @@ export function QuestionPage() {
|
|||||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||||
const [deleteQ] = useDeleteQuestionMutation();
|
const [deleteQ] = useDeleteQuestionMutation();
|
||||||
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
|
const [setOptions, { isLoading: isSavingOptions }] =
|
||||||
|
useSetQuestionOptionsMutation();
|
||||||
|
const [submitQ, { isLoading: isSubmittingReview }] =
|
||||||
|
useSubmitQuestionMutation();
|
||||||
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
|
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
|
||||||
|
|
||||||
const certifications = certRes?.items ?? [];
|
const certifications = certRes?.items ?? [];
|
||||||
@@ -121,60 +345,115 @@ export function QuestionPage() {
|
|||||||
const [editing, setEditing] = useState<Question | null>(null);
|
const [editing, setEditing] = useState<Question | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
|
||||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||||
|
useDisclosure(false);
|
||||||
const [certFilter, setCertFilter] = useState<string | null>(null);
|
const [certFilter, setCertFilter] = useState<string | null>(null);
|
||||||
const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
|
const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
|
||||||
const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED');
|
const [reviewOutcome, setReviewOutcome] = useState<
|
||||||
const [reviewRemark, setReviewRemark] = useState('');
|
"APPROVED" | "REJECTED" | "RETIRED"
|
||||||
|
>("APPROVED");
|
||||||
|
const [reviewRemark, setReviewRemark] = useState("");
|
||||||
|
|
||||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
const certOptions = certifications
|
||||||
|
.filter((c) => c.isActive)
|
||||||
|
.map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||||
|
|
||||||
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
|
const filtered = questions.filter(
|
||||||
|
(q) => !certFilter || q.certificationId === certFilter,
|
||||||
|
);
|
||||||
const page = paginate(filtered);
|
const page = paginate(filtered);
|
||||||
|
|
||||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
const getCertName = (id: string) =>
|
||||||
|
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
||||||
|
|
||||||
const resetForm = () => { setEditing(null); setShowForm(false); };
|
const resetForm = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (values: {
|
const handleSubmit = async (
|
||||||
certificationId: string; titleEn: string; titleAm: string;
|
values: {
|
||||||
form: string; points: number; days: number; hours: number; minutes: number;
|
certificationId: string;
|
||||||
}, isEdit: boolean) => {
|
titleEn: string;
|
||||||
|
titleAm: string;
|
||||||
|
form: string;
|
||||||
|
points: number;
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
draftOptions: DraftOption[];
|
||||||
|
},
|
||||||
|
isEdit: boolean,
|
||||||
|
) => {
|
||||||
const title = { en: values.titleEn, am: values.titleAm };
|
const title = { en: values.titleEn, am: values.titleAm };
|
||||||
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
|
const time = {
|
||||||
|
days: values.days,
|
||||||
|
hours: values.hours,
|
||||||
|
minutes: values.minutes,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (isEdit && editing) {
|
if (isEdit && editing) {
|
||||||
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
|
await updateQ({
|
||||||
notify.success(t('question.updated'));
|
id: editing.id,
|
||||||
|
certificationId: values.certificationId,
|
||||||
|
title,
|
||||||
|
form: values.form as QuestionForm,
|
||||||
|
points: values.points,
|
||||||
|
time,
|
||||||
|
}).unwrap();
|
||||||
|
notify.success(t("question.updated"));
|
||||||
} else {
|
} else {
|
||||||
await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap();
|
const created = await createQ({
|
||||||
notify.success(t('question.created'));
|
certificationId: values.certificationId,
|
||||||
|
title,
|
||||||
|
description: { en: "", am: "" },
|
||||||
|
form: values.form as QuestionForm,
|
||||||
|
points: values.points,
|
||||||
|
time,
|
||||||
|
}).unwrap();
|
||||||
|
// The question needs an id to attach options to — this is the second
|
||||||
|
// half of one "create" action from the user's point of view, not a
|
||||||
|
// separate edit step, so it happens right here rather than waiting
|
||||||
|
// for them to reopen the question later.
|
||||||
|
if (values.form === "CHOICE" && values.draftOptions.length) {
|
||||||
|
const options: QuestionOptionInput[] = values.draftOptions.map(
|
||||||
|
(o) => ({
|
||||||
|
text: { en: o.textEn, am: o.textAm },
|
||||||
|
isCorrect: o.isCorrect,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await setOptions({ id: created.id, options }).unwrap();
|
||||||
|
}
|
||||||
|
notify.success(t("question.created"));
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch {
|
} catch {
|
||||||
notify.error(t('question.error'));
|
notify.error(t("question.error"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitForApproval = async (question: Question) => {
|
const handleSubmitForApproval = async (question: Question) => {
|
||||||
try {
|
try {
|
||||||
await submitQ(question.id).unwrap();
|
await submitQ(question.id).unwrap();
|
||||||
notify.success(t('question.qc.submitted'));
|
notify.success(t("question.qc.submitted"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
notify.error(extractErrorMessage(error, t("question.qc.error")));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openReview = (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => {
|
const openReview = (
|
||||||
|
question: Question,
|
||||||
|
outcome: "APPROVED" | "REJECTED" | "RETIRED",
|
||||||
|
) => {
|
||||||
setReviewTarget(question);
|
setReviewTarget(question);
|
||||||
setReviewOutcome(outcome);
|
setReviewOutcome(outcome);
|
||||||
setReviewRemark('');
|
setReviewRemark("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReview = async () => {
|
const handleReview = async () => {
|
||||||
if (!reviewTarget) return;
|
if (!reviewTarget) return;
|
||||||
if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) {
|
if (reviewOutcome !== "APPROVED" && !reviewRemark.trim()) {
|
||||||
notify.error(t('question.qc.remarkRequired'));
|
notify.error(t("question.qc.remarkRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -183,10 +462,10 @@ export function QuestionPage() {
|
|||||||
outcome: reviewOutcome,
|
outcome: reviewOutcome,
|
||||||
remark: reviewRemark.trim() || undefined,
|
remark: reviewRemark.trim() || undefined,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
notify.success(t('question.qc.reviewed'));
|
notify.success(t("question.qc.reviewed"));
|
||||||
setReviewTarget(null);
|
setReviewTarget(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
notify.error(extractErrorMessage(error, t("question.qc.error")));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,7 +473,7 @@ export function QuestionPage() {
|
|||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
try {
|
try {
|
||||||
await deleteQ(deleteTarget.id).unwrap();
|
await deleteQ(deleteTarget.id).unwrap();
|
||||||
notify.success(t('question.deleted'));
|
notify.success(t("question.deleted"));
|
||||||
closeDelete();
|
closeDelete();
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -202,7 +481,8 @@ export function QuestionPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
|
if (isError)
|
||||||
|
return <ErrorState title={t("question.loadError")} onRetry={refetch} />;
|
||||||
|
|
||||||
const columns: AdvancedColumn<Question>[] = [
|
const columns: AdvancedColumn<Question>[] = [
|
||||||
...questionColumns(t, { locale, getCertName }),
|
...questionColumns(t, { locale, getCertName }),
|
||||||
@@ -210,99 +490,146 @@ export function QuestionPage() {
|
|||||||
isSubmittingReview,
|
isSubmittingReview,
|
||||||
onSubmitForApproval: handleSubmitForApproval,
|
onSubmitForApproval: handleSubmitForApproval,
|
||||||
onReview: openReview,
|
onReview: openReview,
|
||||||
onEdit: (q) => { setEditing(q); setShowForm(true); },
|
onEdit: (q) => {
|
||||||
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
|
setEditing(q);
|
||||||
|
setShowForm(true);
|
||||||
|
},
|
||||||
|
onDelete: (q) => {
|
||||||
|
setDeleteTarget(q);
|
||||||
|
openDelete();
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<PageHeader
|
||||||
<Title order={2}>{t('question.title')}</Title>
|
title={t("question.title")}
|
||||||
{!showForm && (
|
noMargin
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
action={
|
||||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
!showForm && (
|
||||||
{t('question.addQuestion')}
|
<RequirePermission
|
||||||
</Button>
|
anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]}
|
||||||
</RequirePermission>
|
hideOnly
|
||||||
)}
|
>
|
||||||
</Group>
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t("question.addQuestion")}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<QuestionForm
|
<QuestionForm
|
||||||
editing={editing}
|
editing={editing}
|
||||||
certOptions={certOptions}
|
certOptions={certOptions}
|
||||||
isSubmitting={isCreating || isUpdating}
|
isSubmitting={isCreating || isUpdating || isSavingOptions}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={resetForm}
|
onCancel={resetForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card withBorder padding={0}>
|
<AdvancedTable
|
||||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
columns={columns}
|
||||||
<Text fw={600}>{t('question.pool')}</Text>
|
data={page.rows}
|
||||||
|
title={t("question.pool")}
|
||||||
|
tableName={t("question.title")}
|
||||||
|
toolbar={
|
||||||
<Select
|
<Select
|
||||||
placeholder={t('question.filterByCertification')}
|
placeholder={t("question.filterByCertification")}
|
||||||
data={[{ value: '', label: 'All' }, ...certOptions]}
|
data={[{ value: "", label: "All" }, ...certOptions]}
|
||||||
value={certFilter}
|
value={certFilter}
|
||||||
onChange={(v) => { setCertFilter(v ?? null); setPageIndex(0); }}
|
onChange={(v) => {
|
||||||
|
setCertFilter(v ?? null);
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ width: 280 }}
|
style={{ width: 280 }}
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</Group>
|
}
|
||||||
<AdvancedTable
|
itemCount={page.itemCount}
|
||||||
columns={columns}
|
pageIndex={page.pageIndex}
|
||||||
data={page.rows}
|
onPageChange={setPageIndex}
|
||||||
tableName={t('question.title')}
|
pageSize={pageSize}
|
||||||
itemCount={page.itemCount}
|
onPageSizeChange={setPageSize}
|
||||||
pageIndex={page.pageIndex}
|
refresh={refetch}
|
||||||
onPageChange={setPageIndex}
|
isLoading={isFetching}
|
||||||
pageSize={pageSize}
|
emptyText={t("question.noQuestions")}
|
||||||
onPageSizeChange={setPageSize}
|
/>
|
||||||
refresh={refetch}
|
|
||||||
isLoading={isFetching}
|
|
||||||
emptyText={t('question.noQuestions')}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(reviewTarget)}
|
opened={Boolean(reviewTarget)}
|
||||||
onClose={() => setReviewTarget(null)}
|
onClose={() => setReviewTarget(null)}
|
||||||
title={t('question.qc.reviewTitle')}
|
title={t("question.qc.reviewTitle")}
|
||||||
size="md"
|
size="md"
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text>
|
<Text fz="sm" fw={500}>
|
||||||
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text>
|
{reviewTarget?.title?.[locale]}
|
||||||
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content">
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{t("question.qc.onlyApprovedUsable")}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color={
|
||||||
|
reviewOutcome === "APPROVED"
|
||||||
|
? "teal"
|
||||||
|
: reviewOutcome === "REJECTED"
|
||||||
|
? "red"
|
||||||
|
: "dark"
|
||||||
|
}
|
||||||
|
w="fit-content"
|
||||||
|
>
|
||||||
{t(`question.qc.${reviewOutcome}`)}
|
{t(`question.qc.${reviewOutcome}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Textarea
|
<Textarea
|
||||||
label={t('question.qc.remark')}
|
label={t("question.qc.remark")}
|
||||||
minRows={3}
|
minRows={3}
|
||||||
autosize
|
autosize
|
||||||
value={reviewRemark}
|
value={reviewRemark}
|
||||||
onChange={(e) => setReviewRemark(e.currentTarget.value)}
|
onChange={(e) => setReviewRemark(e.currentTarget.value)}
|
||||||
required={reviewOutcome !== 'APPROVED'}
|
required={reviewOutcome !== "APPROVED"}
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" size="sm" onClick={() => setReviewTarget(null)}>
|
<Button
|
||||||
{t('question.cancel')}
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setReviewTarget(null)}
|
||||||
|
>
|
||||||
|
{t("question.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" loading={isReviewing} onClick={handleReview}>
|
<Button size="sm" loading={isReviewing} onClick={handleReview}>
|
||||||
{t(`question.qc.${reviewOutcome === 'APPROVED' ? 'approve' : reviewOutcome === 'REJECTED' ? 'reject' : 'retire'}`)}
|
{t(
|
||||||
|
`question.qc.${reviewOutcome === "APPROVED" ? "approve" : reviewOutcome === "REJECTED" ? "reject" : "retire"}`,
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
<Modal
|
||||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
opened={deleteOpened}
|
||||||
|
onClose={closeDelete}
|
||||||
|
title={t("question.confirmDelete")}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Text mb="md">{t("question.deleteConfirmText")}</Text>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
|
<Button variant="default" onClick={closeDelete} size="sm">
|
||||||
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
|
{t("question.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onClick={handleDelete} size="sm">
|
||||||
|
{t("question.delete")}
|
||||||
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ export type QuestionStatus =
|
|||||||
| 'REJECTED'
|
| 'REJECTED'
|
||||||
| 'RETIRED';
|
| 'RETIRED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CHOICE option, as returned by the authoring/QC endpoints. Never carries
|
||||||
|
* a correctness flag — the API's own answer-key table is never joined into
|
||||||
|
* this response either, so there's nothing to accidentally serialize here.
|
||||||
|
*/
|
||||||
|
export interface QuestionOption {
|
||||||
|
id: string;
|
||||||
|
text: LocalePair;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Question {
|
export interface Question {
|
||||||
id: string;
|
id: string;
|
||||||
certificationId: string;
|
certificationId: string;
|
||||||
@@ -32,6 +43,8 @@ export interface Question {
|
|||||||
submittedAt: string | null;
|
submittedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** Only populated when explicitly requested (`?i=options`). */
|
||||||
|
options?: QuestionOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewQuestionPayload {
|
export interface ReviewQuestionPayload {
|
||||||
@@ -64,3 +77,13 @@ export interface UpdateQuestionPayload {
|
|||||||
points?: number;
|
points?: number;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuestionOptionInput {
|
||||||
|
text: LocalePair;
|
||||||
|
isCorrect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetQuestionOptionsPayload {
|
||||||
|
id: string;
|
||||||
|
options: QuestionOptionInput[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {Alert, Button, Center, Group, Loader, Modal, Paper, Select, Stack, Text, Textarea} from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconInfoCircle } from '@tabler/icons-react';
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { AdvancedTable, notify, useServerTable, PageLoader } from '@ema-platform/ui';
|
import { AdvancedTable, notify, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import { extractErrorMessage } from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
@@ -75,12 +62,11 @@ export function ExamAppealsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<div>
|
<PageHeader
|
||||||
<Title order={2}>{t('result.appeals.title')}</Title>
|
title={t('result.appeals.title')}
|
||||||
<Text fz="sm" c="dimmed">
|
subtitle={t('result.appeals.subtitle')}
|
||||||
{t('result.appeals.subtitle')}
|
noMargin
|
||||||
</Text>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
<Paper withBorder radius="md">
|
||||||
<AdvancedTable<ExamAppeal>
|
<AdvancedTable<ExamAppeal>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button, Group } from '@mantine/core';
|
import { ActionIcon, Menu } from '@mantine/core';
|
||||||
import { IconEye, IconTrash } from '@tabler/icons-react';
|
import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
@@ -13,6 +13,7 @@ export function resultActionsColumn(
|
|||||||
onQc: (result: Result, action: QcAction) => void;
|
onQc: (result: Result, action: QcAction) => void;
|
||||||
onViewDetail: (result: Result) => void;
|
onViewDetail: (result: Result) => void;
|
||||||
onDelete: (result: Result) => void;
|
onDelete: (result: Result) => void;
|
||||||
|
onPublish: (result: Result) => void;
|
||||||
},
|
},
|
||||||
): AdvancedColumn<Result> {
|
): AdvancedColumn<Result> {
|
||||||
return {
|
return {
|
||||||
@@ -21,49 +22,66 @@ export function resultActionsColumn(
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Menu shadow="md" width={180} position="bottom-end">
|
||||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
<Menu.Target>
|
||||||
<>
|
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
|
<IconDotsVertical size={16} />
|
||||||
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
</ActionIcon>
|
||||||
{t('result.review.moderate')}
|
</Menu.Target>
|
||||||
</Button>
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => handlers.onViewDetail(r)}>
|
||||||
|
{t('result.action.viewEdit')}
|
||||||
|
</Menu.Item>
|
||||||
|
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||||
|
<>
|
||||||
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
|
||||||
|
<Menu.Item color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
||||||
|
{t('result.review.moderate')}
|
||||||
|
</Menu.Item>
|
||||||
|
</RequirePermission>
|
||||||
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||||
|
<Menu.Item color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
||||||
|
{t('result.review.approve')}
|
||||||
|
</Menu.Item>
|
||||||
|
</RequirePermission>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||||
|
<RequirePermission
|
||||||
|
anyOf={[
|
||||||
|
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
|
||||||
|
LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT,
|
||||||
|
]}
|
||||||
|
hideOnly
|
||||||
|
>
|
||||||
|
<Menu.Item color="orange" onClick={() => handlers.onQc(r, 'return')}>
|
||||||
|
{t('result.review.return')}
|
||||||
|
</Menu.Item>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
)}
|
||||||
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
{r.reviewStatus === 'APPROVED' && (
|
||||||
{t('result.review.approve')}
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
|
||||||
</Button>
|
<Menu.Item
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconSend size={14} />}
|
||||||
|
onClick={() => handlers.onPublish(r)}
|
||||||
|
>
|
||||||
|
{t('result.review.publish')}
|
||||||
|
</Menu.Item>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</>
|
)}
|
||||||
)}
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
<Menu.Divider />
|
||||||
<RequirePermission
|
<Menu.Item
|
||||||
anyOf={[
|
color="red"
|
||||||
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
|
leftSection={<IconTrash size={14} />}
|
||||||
LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT,
|
onClick={() => handlers.onDelete(r)}
|
||||||
]}
|
>
|
||||||
hideOnly
|
{t('result.action.delete')}
|
||||||
>
|
</Menu.Item>
|
||||||
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
|
|
||||||
{t('result.review.return')}
|
|
||||||
</Button>
|
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
)}
|
</Menu.Dropdown>
|
||||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
|
</Menu>
|
||||||
{t('result.action.viewEdit')}
|
|
||||||
</Button>
|
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
leftSection={<IconTrash size={13} />}
|
|
||||||
onClick={() => handlers.onDelete(r)}
|
|
||||||
>
|
|
||||||
{t('result.action.delete')}
|
|
||||||
</Button>
|
|
||||||
</RequirePermission>
|
|
||||||
</Group>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
|
||||||
import { Badge, Box, Text } from '@mantine/core';
|
import { Badge, Box, Text } from '@mantine/core';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { StatusBadge } from '@ema-platform/ui';
|
||||||
import type { Result, ResultReviewStatus } from '../../types/result';
|
import type { Result, ResultReviewStatus } from '../../types/result';
|
||||||
|
|
||||||
export const STATUS_COLOR: Record<string, string> = {
|
export const STATUS_TONE: Record<string, StatusTone> = {
|
||||||
PASSED: 'teal',
|
PASSED: 'success',
|
||||||
FAILED: 'red',
|
FAILED: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
|
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
|
||||||
@@ -46,20 +48,22 @@ export function resultColumns(
|
|||||||
{
|
{
|
||||||
header: t('result.columns.status'),
|
header: t('result.columns.status'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<StatusBadge
|
||||||
|
tone={STATUS_TONE[row.original.status]}
|
||||||
|
label={t(`result.status.${row.original.status}`)}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={STATUS_COLOR[row.original.status]}
|
|
||||||
leftSection={
|
leftSection={
|
||||||
<Box
|
<Box
|
||||||
w={6}
|
w={6}
|
||||||
h={6}
|
h={6}
|
||||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
|
style={{
|
||||||
|
borderRadius: 999,
|
||||||
|
background: `var(--mantine-color-${STATUS_TONE_COLOR[STATUS_TONE[row.original.status]]}-6)`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
{t(`result.status.${row.original.status}`)}
|
|
||||||
</Badge>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,40 +1,9 @@
|
|||||||
import { useState, useCallback, type ElementType } from 'react';
|
import { useState, useCallback, type ElementType } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
|
||||||
Stack,
|
|
||||||
Title,
|
|
||||||
Group,
|
|
||||||
Table,
|
|
||||||
Badge,
|
|
||||||
Modal,
|
|
||||||
Text,
|
|
||||||
Paper,
|
|
||||||
Card,
|
|
||||||
Loader,
|
|
||||||
Center,
|
|
||||||
Alert,
|
|
||||||
Select,
|
|
||||||
SimpleGrid,
|
|
||||||
Divider,
|
|
||||||
Button,
|
|
||||||
ThemeIcon,
|
|
||||||
TextInput,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
|
||||||
IconInfoCircle,
|
import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||||
IconUser,
|
|
||||||
IconCertificate,
|
|
||||||
IconDeviceFloppy,
|
|
||||||
IconPlus,
|
|
||||||
IconClipboardList,
|
|
||||||
IconCircleCheck,
|
|
||||||
IconCircleX,
|
|
||||||
IconChartBar,
|
|
||||||
IconSearch,
|
|
||||||
IconSend,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import type { BilingualValue } from '@ema-platform/ui';
|
import type { BilingualValue } from '@ema-platform/ui';
|
||||||
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
||||||
@@ -53,7 +22,7 @@ import { useGetExamsQuery } from '../../../exam/api/exam-api';
|
|||||||
import { RecordResultModal } from '../../components/RecordResultModal';
|
import { RecordResultModal } from '../../components/RecordResultModal';
|
||||||
import type { Result, ResultBreakdown } from '../../types/result';
|
import type { Result, ResultBreakdown } from '../../types/result';
|
||||||
import type { Exam } from '../../../exam/types/exam';
|
import type { Exam } from '../../../exam/types/exam';
|
||||||
import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns';
|
import { resultColumns, STATUS_TONE, REVIEW_COLOR } from './columns';
|
||||||
import { resultActionsColumn, type QcAction } from './actions';
|
import { resultActionsColumn, type QcAction } from './actions';
|
||||||
|
|
||||||
function ResultStat({
|
function ResultStat({
|
||||||
@@ -126,6 +95,8 @@ export function ResultPage() {
|
|||||||
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
|
||||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||||
|
const [publishTarget, setPublishTarget] = useState<Result | null>(null);
|
||||||
|
const [publishOpened, { open: openPublish, close: closePublish }] = useDisclosure(false);
|
||||||
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
|
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
|
||||||
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
|
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
|
||||||
const [detailSaving, setDetailSaving] = useState(false);
|
const [detailSaving, setDetailSaving] = useState(false);
|
||||||
@@ -262,6 +233,19 @@ export function ResultPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */
|
||||||
|
const handleConfirmPublish = async () => {
|
||||||
|
if (!publishTarget) return;
|
||||||
|
try {
|
||||||
|
const outcome = await publishResults(publishTarget.examId).unwrap();
|
||||||
|
notify.success(t('result.review.publishedCount', outcome));
|
||||||
|
closePublish();
|
||||||
|
setPublishTarget(null);
|
||||||
|
} catch (error) {
|
||||||
|
notify.error(extractErrorMessage(error, t('result.review.error')));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDetailClose = () => {
|
const handleDetailClose = () => {
|
||||||
closeDetail();
|
closeDetail();
|
||||||
setDetailBreakdowns([]);
|
setDetailBreakdowns([]);
|
||||||
@@ -280,7 +264,7 @@ export function ResultPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
|
if (isError) return <ErrorState title={t('result.loadError')} onRetry={refetch} />;
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
...resultColumns(t, locale, showDate, getExamTitle),
|
...resultColumns(t, locale, showDate, getExamTitle),
|
||||||
@@ -288,6 +272,7 @@ export function ResultPage() {
|
|||||||
onQc: openQc,
|
onQc: openQc,
|
||||||
onViewDetail: viewDetail,
|
onViewDetail: viewDetail,
|
||||||
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
|
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
|
||||||
|
onPublish: (r) => { setPublishTarget(r); openPublish(); },
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -295,12 +280,12 @@ export function ResultPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<PageHeader
|
||||||
<div>
|
title={t('result.title')}
|
||||||
<Title order={2}>{t('result.title')}</Title>
|
subtitle={t('result.subtitle')}
|
||||||
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
|
noMargin
|
||||||
</div>
|
action={
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -314,11 +299,14 @@ export function ResultPage() {
|
|||||||
{t('result.review.publish')}
|
{t('result.review.publish')}
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
|
||||||
{t('result.record')}
|
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||||
</Button>
|
{t('result.record')}
|
||||||
</Group>
|
</Button>
|
||||||
</Group>
|
</RequirePermission>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
|
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
|
||||||
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
|
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
|
||||||
@@ -327,10 +315,13 @@ export function ResultPage() {
|
|||||||
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
|
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Card withBorder padding={0}>
|
<AdvancedTable
|
||||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
columns={columns}
|
||||||
<Text fw={600}>{t('result.section')}</Text>
|
data={page.rows}
|
||||||
<Group gap="sm" wrap="wrap">
|
title={t('result.section')}
|
||||||
|
tableName={t('result.title')}
|
||||||
|
toolbar={
|
||||||
|
<>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder={t('result.search.seafarer')}
|
placeholder={t('result.search.seafarer')}
|
||||||
leftSection={<IconSearch size={15} />}
|
leftSection={<IconSearch size={15} />}
|
||||||
@@ -348,23 +339,17 @@ export function ResultPage() {
|
|||||||
style={{ width: 280 }}
|
style={{ width: 280 }}
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</Group>
|
</>
|
||||||
</Group>
|
}
|
||||||
|
itemCount={page.itemCount}
|
||||||
<AdvancedTable
|
pageIndex={page.pageIndex}
|
||||||
columns={columns}
|
onPageChange={setPageIndex}
|
||||||
data={page.rows}
|
pageSize={pageSize}
|
||||||
tableName={t('result.title')}
|
onPageSizeChange={setPageSize}
|
||||||
itemCount={page.itemCount}
|
refresh={refetch}
|
||||||
pageIndex={page.pageIndex}
|
isLoading={isFetching}
|
||||||
onPageChange={setPageIndex}
|
emptyText={t('result.noItems')}
|
||||||
pageSize={pageSize}
|
/>
|
||||||
onPageSizeChange={setPageSize}
|
|
||||||
refresh={refetch}
|
|
||||||
isLoading={isFetching}
|
|
||||||
emptyText={t('result.noItems')}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={detailOpened}
|
opened={detailOpened}
|
||||||
@@ -421,9 +406,10 @@ export function ResultPage() {
|
|||||||
{t('result.review.derivedStatus')}
|
{t('result.review.derivedStatus')}
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs" mt={4}>
|
<Group gap="xs" mt={4}>
|
||||||
<Badge variant="light" color={STATUS_COLOR[detailResult.status]}>
|
<StatusBadge
|
||||||
{t(`result.status.${detailResult.status}`)}
|
tone={STATUS_TONE[detailResult.status]}
|
||||||
</Badge>
|
label={t(`result.status.${detailResult.status}`)}
|
||||||
|
/>
|
||||||
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
|
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
|
||||||
{t(`result.review.${detailResult.reviewStatus}`)}
|
{t(`result.review.${detailResult.reviewStatus}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -504,14 +490,22 @@ export function ResultPage() {
|
|||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
|
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
|
||||||
<Button
|
<RequirePermission
|
||||||
onClick={handleDetailSave}
|
anyOf={[
|
||||||
size="sm"
|
LICENSE_PERMISSIONS.RECORD_EXAM_RESULT,
|
||||||
loading={detailSaving}
|
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
|
||||||
leftSection={<IconDeviceFloppy size={15} />}
|
]}
|
||||||
|
hideOnly
|
||||||
>
|
>
|
||||||
{t('result.save')}
|
<Button
|
||||||
</Button>
|
onClick={handleDetailSave}
|
||||||
|
size="sm"
|
||||||
|
loading={detailSaving}
|
||||||
|
leftSection={<IconDeviceFloppy size={15} />}
|
||||||
|
>
|
||||||
|
{t('result.save')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
@@ -574,6 +568,20 @@ export function ResultPage() {
|
|||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal opened={publishOpened} onClose={closePublish} title={t('result.review.publish')} size="sm">
|
||||||
|
<Text mb="md">
|
||||||
|
{t('result.review.publishConfirmText', {
|
||||||
|
exam: publishTarget ? getExamTitle(publishTarget.examId) : '',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="default" onClick={closePublish} size="sm">{t('result.cancel')}</Button>
|
||||||
|
<Button color="teal" loading={isPublishing} onClick={handleConfirmPublish} size="sm">
|
||||||
|
{t('result.review.publish')}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Choose exam, then record */}
|
{/* Choose exam, then record */}
|
||||||
<Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg">
|
<Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
|
import {Badge, Container, Select, Text, TextInput} from '@mantine/core';
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type SeafarerDocumentRow,
|
type SeafarerDocumentRow,
|
||||||
type SeafarerDocumentStatus,
|
type SeafarerDocumentStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
|
import { AdvancedTable, PageHeader, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
@@ -91,6 +91,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
|||||||
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: 'Waiting',
|
||||||
|
accessorKey: 'submittedAt',
|
||||||
|
size: 90,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<WaitingFor
|
||||||
|
since={row.original.submittedAt}
|
||||||
|
done={row.original.status === 'ISSUED' || row.original.status === 'REJECTED'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
@@ -106,40 +117,39 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Title order={3} mb={4}>
|
<PageHeader
|
||||||
{SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue
|
title={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue`}
|
||||||
</Title>
|
subtitle="Requests released by an approved seafarer registration: confirm payment, schedule the collection date, then issue."
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
/>
|
||||||
Requests released by an approved seafarer registration: confirm payment, schedule the
|
|
||||||
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>
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={data?.items ?? []}
|
data={data?.items ?? []}
|
||||||
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
|
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}
|
itemCount={data?.total ?? 0}
|
||||||
pageIndex={page}
|
pageIndex={page}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, SimpleGrid, Stack, Text, Textarea, ThemeIcon, rem} from '@mantine/core';
|
||||||
Alert,
|
import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
|
||||||
import {
|
import {
|
||||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||||
@@ -29,26 +14,41 @@ import {
|
|||||||
useRejectSeafarerDocumentMutation,
|
useRejectSeafarerDocumentMutation,
|
||||||
useScheduleSeafarerDocumentMutation,
|
useScheduleSeafarerDocumentMutation,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { AmharicDatePicker, notify } from '@ema-platform/ui';
|
import { AmharicDatePicker, notify, PageHeader } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
|
|
||||||
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const;
|
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const;
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Table.Tr>
|
<div>
|
||||||
<Table.Td w="40%">
|
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text fz="sm" fw={600} component="div">{value ?? '—'}</Text>
|
||||||
{label}
|
</div>
|
||||||
</Text>
|
);
|
||||||
</Table.Td>
|
}
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" component="div">
|
function SectionCard({
|
||||||
{value ?? '—'}
|
title,
|
||||||
</Text>
|
icon,
|
||||||
</Table.Td>
|
color,
|
||||||
</Table.Tr>
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<ThemeIcon variant="light" color={color} size={26} radius="md">{icon}</ThemeIcon>
|
||||||
|
<Text fw={700} fz="sm">{title}</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider mb="md" />
|
||||||
|
{children}
|
||||||
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,12 +123,10 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
>
|
>
|
||||||
Back to queue
|
Back to queue
|
||||||
</Button>
|
</Button>
|
||||||
<Group justify="space-between" align="flex-start" mb="md">
|
<PageHeader
|
||||||
<div>
|
title={`${kindLabel} — ${applicant?.name ?? '—'}`}
|
||||||
<Title order={3}>
|
meta={
|
||||||
{kindLabel} — {applicant?.name ?? '—'}
|
<>
|
||||||
</Title>
|
|
||||||
<Group gap="xs" mt={4}>
|
|
||||||
<Text size="sm" c="dimmed" ff="monospace">
|
<Text size="sm" c="dimmed" ff="monospace">
|
||||||
{document.requestNumber}
|
{document.requestNumber}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -140,9 +138,10 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
{document.documentNumber}
|
{document.documentNumber}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</>
|
||||||
</div>
|
}
|
||||||
<Group gap="xs">
|
action={
|
||||||
|
<Group gap="xs">
|
||||||
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
|
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||||
<Button loading={confirming} onClick={() => run(() => confirmPayment(id).unwrap(), 'Payment confirmed')}>
|
<Button loading={confirming} onClick={() => run(() => confirmPayment(id).unwrap(), 'Payment confirmed')}>
|
||||||
@@ -174,8 +173,9 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{document.status === 'REJECTED' && (
|
{document.status === 'REJECTED' && (
|
||||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
|
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
|
||||||
@@ -183,61 +183,49 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper withBorder p="lg" radius="md">
|
<Paper withBorder radius="md" p="lg" mb="md">
|
||||||
<Stack gap="md">
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>
|
||||||
|
{(applicant?.name ?? '??').split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="sm" mb={4}>
|
<Text fw={700} fz="lg" lh={1.2}>{applicant?.name ?? '—'}</Text>
|
||||||
Seafarer
|
<Group gap={6} mt={4}>
|
||||||
</Text>
|
<Text fz="xs" c="dimmed" ff="monospace">{applicant?.seafarerNumber ?? '—'}</Text>
|
||||||
<Table withTableBorder withColumnBorders>
|
{applicant?.registrationId && (
|
||||||
<Table.Tbody>
|
<>
|
||||||
<Row label="Name" value={applicant?.name} />
|
<Text fz="xs" c="dimmed">·</Text>
|
||||||
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
|
||||||
<Row
|
<Text fz="xs" c="blue.6">{applicant.registrationNumber}</Text>
|
||||||
label="Registration"
|
</Link>
|
||||||
value={
|
</>
|
||||||
applicant?.registrationId ? (
|
)}
|
||||||
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
|
</Group>
|
||||||
{applicant.registrationNumber}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
applicant?.registrationNumber
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</Group>
|
||||||
<Text fw={600} size="sm" mb={4}>
|
|
||||||
Payment
|
|
||||||
</Text>
|
|
||||||
<Table withTableBorder withColumnBorders>
|
|
||||||
<Table.Tbody>
|
|
||||||
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
|
||||||
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
|
||||||
<Row label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
|
||||||
<Row label="Provider" value={payment?.provider} />
|
|
||||||
<Row label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text fw={600} size="sm" mb={4}>
|
|
||||||
Issuance
|
|
||||||
</Text>
|
|
||||||
<Table withTableBorder withColumnBorders>
|
|
||||||
<Table.Tbody>
|
|
||||||
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
|
||||||
<Row label="Document №" value={document.documentNumber} />
|
|
||||||
<Row label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
|
||||||
<Row label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
|
<SectionCard title="Payment" icon={<IconCash size={14} />} color="teal">
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<Stat label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||||
|
<Stat label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||||
|
<Stat label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
||||||
|
<Stat label="Provider" value={payment?.provider} />
|
||||||
|
<Stat label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Issuance" icon={<IconFileCertificate size={14} />} color="violet">
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<Stat label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||||
|
<Stat label="Document №" value={document.documentNumber} />
|
||||||
|
<Stat label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
||||||
|
<Stat label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</SectionCard>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
<Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
|
<Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
|
||||||
<Stack>
|
<Stack>
|
||||||
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />
|
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
|
import {Container, Select, Text, TextInput} from '@mantine/core';
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
SEAFARER_REGISTRATION_STATUS_COLORS,
|
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||||
displaySeafarerAnswer,
|
displaySeafarerAnswer,
|
||||||
useListSeafarerRegistrationsQuery,
|
useListSeafarerRegistrationsQuery,
|
||||||
type SeafarerRegistration,
|
type SeafarerRegistration,
|
||||||
type SeafarerRegistrationStatus,
|
type SeafarerRegistrationStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
|
import { AdvancedTable, PageHeader, StatusBadge, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
@@ -78,13 +78,25 @@ export function SeafarerRegistrationQueuePage() {
|
|||||||
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: 'Waiting',
|
||||||
|
accessorKey: 'submittedAt',
|
||||||
|
size: 90,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<WaitingFor
|
||||||
|
since={row.original.submittedAt}
|
||||||
|
done={row.original.status === 'APPROVED' || row.original.status === 'REJECTED'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[row.original.status]}>
|
<StatusBadge
|
||||||
{SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
|
tone={SEAFARER_REGISTRATION_STATUS_TONES[row.original.status]}
|
||||||
</Badge>
|
label={SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -93,40 +105,39 @@ export function SeafarerRegistrationQueuePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Title order={3} mb={4}>
|
<PageHeader
|
||||||
Seafarer Registration Queue
|
title="Seafarer Registration Queue"
|
||||||
</Title>
|
subtitle="Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and BTC applications."
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
/>
|
||||||
Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and
|
|
||||||
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>
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={data?.items ?? []}
|
data={data?.items ?? []}
|
||||||
tableName="Seafarer registrations"
|
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}
|
itemCount={data?.total ?? 0}
|
||||||
pageIndex={page}
|
pageIndex={page}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
|||||||
@@ -1,28 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {Alert, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Container,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||||
SEAFARER_REGISTRATION_SECTIONS,
|
SEAFARER_REGISTRATION_SECTIONS,
|
||||||
SEAFARER_REGISTRATION_STATUS_COLORS,
|
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||||
displaySeafarerAnswer,
|
displaySeafarerAnswer,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
@@ -31,7 +15,7 @@ import {
|
|||||||
useRejectSeafarerRegistrationMutation,
|
useRejectSeafarerRegistrationMutation,
|
||||||
useRequestSeafarerRegistrationChangesMutation,
|
useRequestSeafarerRegistrationChangesMutation,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify, PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import { applicantName } from './SeafarerRegistrationQueuePage';
|
import { applicantName } from './SeafarerRegistrationQueuePage';
|
||||||
|
|
||||||
@@ -109,24 +93,26 @@ export function SeafarerRegistrationReviewPage() {
|
|||||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/seafarer-registrations')} mb="xs">
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/seafarer-registrations')} mb="xs">
|
||||||
Back to queue
|
Back to queue
|
||||||
</Button>
|
</Button>
|
||||||
<Group justify="space-between" align="flex-start" mb="md">
|
<PageHeader
|
||||||
<div>
|
title={applicantName(registration)}
|
||||||
<Title order={3}>{applicantName(registration)}</Title>
|
meta={
|
||||||
<Group gap="xs" mt={4}>
|
<>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">
|
<Text size="sm" c="dimmed" ff="monospace">
|
||||||
{registration.registrationNumber}
|
{registration.registrationNumber}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
|
<StatusBadge
|
||||||
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
|
||||||
</Badge>
|
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
||||||
|
/>
|
||||||
{registration.seafarerNumber && (
|
{registration.seafarerNumber && (
|
||||||
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
|
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
|
||||||
{registration.seafarerNumber}
|
{registration.seafarerNumber}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</>
|
||||||
</div>
|
}
|
||||||
<Group gap="xs">
|
action={
|
||||||
|
<Group gap="xs">
|
||||||
{canDecide && (
|
{canDecide && (
|
||||||
<>
|
<>
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REQUEST_ADJUSTMENT]} hideOnly>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REQUEST_ADJUSTMENT]} hideOnly>
|
||||||
@@ -146,8 +132,9 @@ export function SeafarerRegistrationReviewPage() {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{registration.status === 'RESUBMIT_REQUIRED' && (
|
{registration.status === 'RESUBMIT_REQUIRED' && (
|
||||||
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
|
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
|
||||||
@@ -167,7 +154,7 @@ export function SeafarerRegistrationReviewPage() {
|
|||||||
<Text fw={600} size="sm" mb={4}>
|
<Text fw={600} size="sm" mb={4}>
|
||||||
{section.title}
|
{section.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Table withTableBorder withColumnBorders>
|
<Table variant="vertical">
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{section.fields
|
{section.fields
|
||||||
.filter((f) => f !== 'passportExpiry' || registration.passportNumber)
|
.filter((f) => f !== 'passportExpiry' || registration.passportNumber)
|
||||||
@@ -192,7 +179,7 @@ export function SeafarerRegistrationReviewPage() {
|
|||||||
<Text fw={600} size="sm" mb={4}>
|
<Text fw={600} size="sm" mb={4}>
|
||||||
Documents
|
Documents
|
||||||
</Text>
|
</Text>
|
||||||
<Table withTableBorder withColumnBorders>
|
<Table variant="vertical">
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{slots.map((slot) => {
|
{slots.map((slot) => {
|
||||||
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
|
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
|
import { PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useApiQuery } from '@ema-platform/api';
|
import { useApiQuery } from '@ema-platform/api';
|
||||||
import {
|
import {ActionIcon, Avatar, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
|
||||||
ActionIcon,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Collapse,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Modal,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
rem,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
import {
|
||||||
IconBook2,
|
IconBook2,
|
||||||
IconCertificate,
|
IconCertificate,
|
||||||
@@ -32,6 +15,7 @@ import {
|
|||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
IconUser,
|
IconUser,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -59,71 +43,77 @@ const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
|
|||||||
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
|
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
|
||||||
|
|
||||||
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
|
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
|
||||||
const STATUS_COLOR: Record<string, string> = { Active: 'teal', Inactive: 'gray', Suspended: 'red' };
|
const STATUS_TONE: Record<string, StatusTone> = { Active: 'success', Inactive: 'neutral', Suspended: 'danger' };
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Detail modal
|
// Detail modal
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||||
|
<Text fz="sm" fw={600}>{value ?? '—'}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
|
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
|
||||||
if (!sf) return null;
|
if (!sf) return null;
|
||||||
|
const initials = sf.name.split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase();
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal opened={opened} onClose={onClose} size="xl" radius="lg" padding={0} withCloseButton={false}>
|
||||||
opened={opened}
|
<Stack gap={0}>
|
||||||
onClose={onClose}
|
{/* Header */}
|
||||||
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} — {sf.id}</Text></Group>}
|
<Group justify="space-between" wrap="nowrap" p="lg" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
|
||||||
size="xl"
|
<Group gap="md" wrap="nowrap">
|
||||||
radius="lg"
|
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>{initials}</Avatar>
|
||||||
>
|
<div>
|
||||||
<Stack gap="lg">
|
<Text fw={700} fz="lg" lh={1.2}>{sf.name}</Text>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<Group gap={6} mt={4}>
|
||||||
<Paper withBorder radius="md" p="md">
|
<Text fz="xs" c="dimmed" ff="monospace">{sf.id}</Text>
|
||||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
|
<Text fz="xs" c="dimmed">·</Text>
|
||||||
<Stack gap={4}>
|
<Text fz="xs" c="dimmed">{sf.rank}</Text>
|
||||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
|
|
||||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
|
|
||||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
|
|
||||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
|
|
||||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></Group>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
<Paper withBorder radius="md" p="md">
|
|
||||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
|
|
||||||
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
|
|
||||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
|
|
||||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fz="xs" c="dimmed">Medical Expiry</Text>
|
|
||||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</div>
|
||||||
</Paper>
|
</Group>
|
||||||
</SimpleGrid>
|
<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 && (
|
<Stack gap="lg" p="lg">
|
||||||
<Paper withBorder radius="md" p="md">
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="lg">
|
||||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
|
<Stat label="Nationality" value={sf.nationality} />
|
||||||
<Table fz="xs" verticalSpacing="xs">
|
<Stat label="Date of Birth" value={sf.dob} />
|
||||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
<Stat label="Seaman Book №" value={sf.seamanBookNo} />
|
||||||
<Table.Tr>
|
<Stat label="SB Expiry" value={sf.seamanBookExpiry} />
|
||||||
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
|
<Stat label="BTC №" value={sf.btcNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
|
||||||
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
<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>} />
|
||||||
</Table.Tr>
|
<Stat label="Medical Expiry" value={sf.medicalExpiry} />
|
||||||
</Table.Thead>
|
</SimpleGrid>
|
||||||
<Table.Tbody>
|
|
||||||
|
{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) => (
|
{sf.cocCerts.map((c) => (
|
||||||
<Table.Tr key={c.no}>
|
<Group key={c.no} justify="space-between" wrap="nowrap" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
|
||||||
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
|
<Group gap="xs" wrap="nowrap">
|
||||||
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
|
<ThemeIcon variant="light" color="violet" size={30} radius="md"><IconCertificate size={15} /></ThemeIcon>
|
||||||
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
|
<div>
|
||||||
</Table.Tr>
|
<Text fz="sm" fw={600}>{c.type}</Text>
|
||||||
|
<Text fz="xs" c="dimmed" ff="monospace">{c.no}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Text fz="xs" c="dimmed">Expires {c.expiry}</Text>
|
||||||
|
</Group>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Stack>
|
||||||
</Table>
|
</Paper>
|
||||||
</Paper>
|
)}
|
||||||
)}
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -169,10 +159,11 @@ export function SeafarerRegistryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div>
|
<PageHeader
|
||||||
<Title order={3}>Seafarer Registry</Title>
|
title="Seafarer Registry"
|
||||||
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
|
subtitle="Search and view all registered seafarers, their documents, and certificate status"
|
||||||
</div>
|
noMargin
|
||||||
|
/>
|
||||||
|
|
||||||
{/* KPIs */}
|
{/* KPIs */}
|
||||||
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
|
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
|
||||||
@@ -264,7 +255,12 @@ export function SeafarerRegistryPage() {
|
|||||||
: <Text fz="xs" c="dimmed">—</Text>}
|
: <Text fz="xs" c="dimmed">—</Text>}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
|
<StatusBadge
|
||||||
|
tone={STATUS_TONE[sf.status]}
|
||||||
|
label={sf.status}
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>
|
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ export function VesselRegistrationFormBuilderPage() {
|
|||||||
<IconSettings size={24} />
|
<IconSettings size={24} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<div>
|
<div>
|
||||||
<Title order={3}>Vessel Registration Form Builder</Title>
|
<Title order={2}>Vessel Registration Form Builder</Title>
|
||||||
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
|
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
|
import { Button, Text, Tooltip } from '@mantine/core';
|
||||||
import { IconShieldCog } from '@tabler/icons-react';
|
import { IconShieldCog } from '@tabler/icons-react';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { StatusBadge } from '@ema-platform/ui';
|
||||||
import type { Vessel } from '@ema-platform/api';
|
import type { Vessel } from '@ema-platform/api';
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
|
|
||||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
|
||||||
REGISTERED: 'green',
|
REGISTERED: 'success',
|
||||||
SUSPENDED: 'orange',
|
SUSPENDED: 'pending',
|
||||||
DEREGISTERED: 'gray',
|
DEREGISTERED: 'neutral',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CATEGORY_LABELS: Record<string, string> = {
|
export const CATEGORY_LABELS: Record<string, string> = {
|
||||||
@@ -63,13 +65,12 @@ export function vesselRegistrationQueueColumns(
|
|||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<StatusBadge
|
||||||
|
tone={VESSEL_STATUS_TONES[row.original.status]}
|
||||||
|
label={row.original.status}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={VESSEL_STATUS_COLORS[row.original.status]}
|
/>
|
||||||
>
|
|
||||||
{row.original.status}
|
|
||||||
</Badge>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,29 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {Alert, Badge, Button, Card, Container, Drawer, Group, Loader, Modal, Select, Stack, Table, Text, TextInput, Textarea} from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Container,
|
|
||||||
Drawer,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
@@ -238,26 +221,28 @@ export function VesselRegistrationQueuePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Group justify="space-between" mb="md">
|
<PageHeader
|
||||||
<div>
|
title="Vessel register"
|
||||||
<Title order={3}>Vessel register</Title>
|
subtitle={
|
||||||
<Text size="sm" c="dimmed">
|
<>
|
||||||
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'} —
|
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'} —
|
||||||
pending registrations are reviewed in the{' '}
|
pending registrations are reviewed in the{' '}
|
||||||
<Text component={Link} to="/licence-review" inherit c="blue">
|
<Text component={Link} to="/licence-review" inherit c="blue">
|
||||||
licence queue
|
licence queue
|
||||||
</Text>
|
</Text>
|
||||||
.
|
.
|
||||||
</Text>
|
</>
|
||||||
</div>
|
}
|
||||||
<TextInput
|
action={
|
||||||
placeholder="Name, registration № or IMO"
|
<TextInput
|
||||||
leftSection={<IconSearch size={14} />}
|
placeholder="Name, registration № or IMO"
|
||||||
value={search}
|
leftSection={<IconSearch size={14} />}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
value={search}
|
||||||
w={280}
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
/>
|
w={280}
|
||||||
</Group>
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
tableName="Vessel register"
|
tableName="Vessel register"
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Alert, Container, Group, Text, Title } from '@mantine/core';
|
import {Alert, Container, Group, Text} from '@mantine/core';
|
||||||
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
|
||||||
import {
|
import { ApiErrorAlert, EmptyState, notify, PageHeader, PageLoader } from '@ema-platform/ui';
|
||||||
ApiErrorAlert,
|
|
||||||
EmptyState,
|
|
||||||
PageLoader,
|
|
||||||
notify,
|
|
||||||
} from '@ema-platform/ui';
|
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
downloadAuthedFile,
|
downloadAuthedFile,
|
||||||
@@ -85,16 +80,14 @@ export function VesselRegistrationReportPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Group justify="space-between" mb="md" align="flex-start">
|
<PageHeader
|
||||||
<div>
|
title="Vessel registration report"
|
||||||
<Title order={3}>Vessel registration report</Title>
|
subtitle={
|
||||||
<Text size="sm" c="dimmed">
|
report
|
||||||
{report
|
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
||||||
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
: 'The national vessel register at a glance.'
|
||||||
: 'The national vessel register at a glance.'}
|
}
|
||||||
</Text>
|
/>
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<ReportFilters
|
<ReportFilters
|
||||||
query={query}
|
query={query}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export const am: Translations = {
|
|||||||
tagline: "የቁጥጥር ማዕከል",
|
tagline: "የቁጥጥር ማዕከል",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
a11y: {
|
||||||
|
skipToContent: "ወደ ዋናው ይዘት ዝለል",
|
||||||
|
},
|
||||||
|
|
||||||
msg: {
|
msg: {
|
||||||
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
|
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
|
||||||
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
|
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
|
||||||
@@ -253,8 +257,10 @@ export const am: Translations = {
|
|||||||
oral: "ቃል",
|
oral: "ቃል",
|
||||||
essay: "ኢሴይ",
|
essay: "ኢሴይ",
|
||||||
choice: "ምርጫ",
|
choice: "ምርጫ",
|
||||||
|
both: "ሁለቱም",
|
||||||
offline: "ከመስመር ውጪ",
|
offline: "ከመስመር ውጪ",
|
||||||
online: "በመስመር",
|
online: "በመስመር",
|
||||||
|
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
|
||||||
sum: "ድምር",
|
sum: "ድምር",
|
||||||
average: "አማካይ",
|
average: "አማካይ",
|
||||||
percentage: "መቶኛ",
|
percentage: "መቶኛ",
|
||||||
@@ -262,6 +268,11 @@ export const am: Translations = {
|
|||||||
random: "በዘፈቀደ",
|
random: "በዘፈቀደ",
|
||||||
cuttingPoint: "የማለፊያ ነጥብ",
|
cuttingPoint: "የማለፊያ ነጥብ",
|
||||||
cuttingPointPlaceholder: "ለማለፍ ዝቅተኛ ነጥብ",
|
cuttingPointPlaceholder: "ለማለፍ ዝቅተኛ ነጥብ",
|
||||||
|
cuttingPointPercentagePlaceholder: "ለማለፍ ዝቅተኛ መቶኛ (0-100)",
|
||||||
|
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
|
||||||
|
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
|
||||||
|
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
|
||||||
|
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
|
||||||
status: "ሁኔታ",
|
status: "ሁኔታ",
|
||||||
statusPlaceholder: "የፈተና ሁኔታ",
|
statusPlaceholder: "የፈተና ሁኔታ",
|
||||||
pending: "በመጠባበቅ ላይ",
|
pending: "በመጠባበቅ ላይ",
|
||||||
@@ -299,6 +310,7 @@ export const am: Translations = {
|
|||||||
formType: {
|
formType: {
|
||||||
ESSAY: "ኢሴይ",
|
ESSAY: "ኢሴይ",
|
||||||
CHOICE: "ምርጫ",
|
CHOICE: "ምርጫ",
|
||||||
|
BOTH: "ሁለቱም",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
OFFLINE: "ከመስመር ውጪ",
|
OFFLINE: "ከመስመር ውጪ",
|
||||||
@@ -326,6 +338,10 @@ export const am: Translations = {
|
|||||||
retake: "ድጋሚ {{n}}",
|
retake: "ድጋሚ {{n}}",
|
||||||
firstSitting: "የመጀመሪያ ሙከራ",
|
firstSitting: "የመጀመሪያ ሙከራ",
|
||||||
remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።",
|
remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።",
|
||||||
|
regrade: "እንደገና ደረጃ ስጥ",
|
||||||
|
regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።",
|
||||||
|
regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።",
|
||||||
|
regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።",
|
||||||
},
|
},
|
||||||
attendance: {
|
attendance: {
|
||||||
REGISTERED: "አልተጠራም",
|
REGISTERED: "አልተጠራም",
|
||||||
@@ -370,6 +386,8 @@ export const am: Translations = {
|
|||||||
randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል",
|
randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል",
|
||||||
randomError: "ጥያቄዎችን መምረጥ አልተቻለም",
|
randomError: "ጥያቄዎችን መምረጥ አልተቻለም",
|
||||||
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
|
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
|
||||||
|
cannotReachCuttingPoint:
|
||||||
|
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
|
||||||
},
|
},
|
||||||
|
|
||||||
country: {
|
country: {
|
||||||
@@ -687,6 +705,8 @@ export const am: Translations = {
|
|||||||
returned: "ውጤት ወደ ፈታኙ ተመልሷል",
|
returned: "ውጤት ወደ ፈታኙ ተመልሷል",
|
||||||
publishedCount: "{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።",
|
publishedCount: "{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።",
|
||||||
publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።",
|
publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።",
|
||||||
|
publishConfirmText:
|
||||||
|
"ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?",
|
||||||
lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።",
|
lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።",
|
||||||
originalScore: "የፈታኙ ጠቅላላ",
|
originalScore: "የፈታኙ ጠቅላላ",
|
||||||
derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",
|
derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",
|
||||||
@@ -778,6 +798,22 @@ export const am: Translations = {
|
|||||||
onlyApprovedUsable: "የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።",
|
onlyApprovedUsable: "የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።",
|
||||||
error: "ተግባሩ አልተሳካም",
|
error: "ተግባሩ አልተሳካም",
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
title: "የመልስ አማራጮች",
|
||||||
|
hint: "ትክክለኛውን አማራጭ ምረጥ/ምረጪ። ማስቀመጥ መላውን የአማራጭ ስብስብ ይተካል።",
|
||||||
|
optionLabel: "አማራጭ {{number}}",
|
||||||
|
optionEn: "አማራጭ {{number}} (እንግሊዝኛ)",
|
||||||
|
optionAm: "አማራጭ {{number}} (አማርኛ)",
|
||||||
|
correct: "ትክክለኛ",
|
||||||
|
addOption: "አማራጭ ጨምር",
|
||||||
|
save: "አማራጮችን አስቀምጥ",
|
||||||
|
saved: "አማራጮች ተቀምጠዋል",
|
||||||
|
saveFirst: "መጀመሪያ ጥያቄውን አስቀምጥ፣ ከዚያ አማራጮችን ጨምር።",
|
||||||
|
replaceNotice: "ትክክለኛ መልሶች ከተቀመጡ በኋላ እዚህ አይታዩም — እንደገና ካስተካከልክ/ካስተካከልሽ ዳግም ምረጥ/ምረጪ።",
|
||||||
|
needAtLeastTwo: "ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልገዋል።",
|
||||||
|
needOneCorrect: "ቢያንስ አንድ አማራጭ እንደ ትክክለኛ ምረጥ/ምረጪ።",
|
||||||
|
textRequired: "እያንዳንዱ አማራጭ በሁለቱም ቋንቋዎች ጽሑፍ ያስፈልገዋል።",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
configuration: {
|
configuration: {
|
||||||
@@ -973,7 +1009,10 @@ export const am: Translations = {
|
|||||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||||
reject: "አትቀበል",
|
reject: "አትቀበል",
|
||||||
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||||
|
recordExamOutcome: "የፈተና ውጤት መዝግብ",
|
||||||
confirmPayment: "ክፍያ አረጋግጥ",
|
confirmPayment: "ክፍያ አረጋግጥ",
|
||||||
|
scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ",
|
||||||
|
issueCertificate: "ሰርተፍኬት ስጥ",
|
||||||
print: "ሰነድ አትም",
|
print: "ሰነድ አትም",
|
||||||
copyLink: "አገናኝ ቅዳ",
|
copyLink: "አገናኝ ቅዳ",
|
||||||
downloadDocuments: "ሁሉንም ሰነዶች አውርድ",
|
downloadDocuments: "ሁሉንም ሰነዶች አውርድ",
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export const en = {
|
|||||||
tagline: 'Control Center',
|
tagline: 'Control Center',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Strings only assistive technology encounters.
|
||||||
|
a11y: {
|
||||||
|
skipToContent: 'Skip to main content',
|
||||||
|
},
|
||||||
|
|
||||||
msg: {
|
msg: {
|
||||||
genericError: 'Something went wrong. Please try again.',
|
genericError: 'Something went wrong. Please try again.',
|
||||||
serverError: 'Server error. Please try again later.',
|
serverError: 'Server error. Please try again later.',
|
||||||
@@ -251,8 +256,10 @@ export const en = {
|
|||||||
oral: 'Oral',
|
oral: 'Oral',
|
||||||
essay: 'Essay',
|
essay: 'Essay',
|
||||||
choice: 'Choice',
|
choice: 'Choice',
|
||||||
|
both: 'Both',
|
||||||
offline: 'Offline',
|
offline: 'Offline',
|
||||||
online: 'Online',
|
online: 'Online',
|
||||||
|
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
|
||||||
sum: 'Sum',
|
sum: 'Sum',
|
||||||
average: 'Average',
|
average: 'Average',
|
||||||
percentage: 'Percentage',
|
percentage: 'Percentage',
|
||||||
@@ -260,6 +267,11 @@ export const en = {
|
|||||||
random: 'Random',
|
random: 'Random',
|
||||||
cuttingPoint: 'Cutting Point (Pass Mark)',
|
cuttingPoint: 'Cutting Point (Pass Mark)',
|
||||||
cuttingPointPlaceholder: 'Minimum score to pass',
|
cuttingPointPlaceholder: 'Minimum score to pass',
|
||||||
|
cuttingPointPercentagePlaceholder: 'Minimum % to pass (0-100)',
|
||||||
|
cuttingPointPercentageHint: 'Percentage evaluation — capped at 100.',
|
||||||
|
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
|
||||||
|
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
|
||||||
|
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
statusPlaceholder: 'Exam status',
|
statusPlaceholder: 'Exam status',
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
@@ -296,6 +308,7 @@ export const en = {
|
|||||||
formType: {
|
formType: {
|
||||||
ESSAY: 'Essay',
|
ESSAY: 'Essay',
|
||||||
CHOICE: 'Choice',
|
CHOICE: 'Choice',
|
||||||
|
BOTH: 'Both',
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
OFFLINE: 'Offline',
|
OFFLINE: 'Offline',
|
||||||
@@ -323,6 +336,10 @@ export const en = {
|
|||||||
retake: 'Retake {{n}}',
|
retake: 'Retake {{n}}',
|
||||||
firstSitting: 'First sitting',
|
firstSitting: 'First sitting',
|
||||||
remarkRequired: 'A reason is required for a withdrawal or a disqualification.',
|
remarkRequired: 'A reason is required for a withdrawal or a disqualification.',
|
||||||
|
regrade: 'Regrade',
|
||||||
|
regraded: 'Result created from the graded attempt.',
|
||||||
|
regradeNotEligible: 'Not auto-gradable: {{reason}}. Use Record Result instead.',
|
||||||
|
regradeError: 'Could not regrade this attempt.',
|
||||||
},
|
},
|
||||||
attendance: {
|
attendance: {
|
||||||
REGISTERED: 'Not called',
|
REGISTERED: 'Not called',
|
||||||
@@ -368,6 +385,8 @@ export const en = {
|
|||||||
randomError: 'Could not draw questions',
|
randomError: 'Could not draw questions',
|
||||||
notEnoughApproved:
|
notEnoughApproved:
|
||||||
'Not enough approved questions in the bank for this subject.',
|
'Not enough approved questions in the bank for this subject.',
|
||||||
|
cannotReachCuttingPoint:
|
||||||
|
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
|
||||||
},
|
},
|
||||||
|
|
||||||
country: {
|
country: {
|
||||||
@@ -687,6 +706,8 @@ export const en = {
|
|||||||
returned: 'Result returned to the examiner',
|
returned: 'Result returned to the examiner',
|
||||||
publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.',
|
publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.',
|
||||||
publishNeedsExam: 'Filter by an exam first to publish its results.',
|
publishNeedsExam: 'Filter by an exam first to publish its results.',
|
||||||
|
publishConfirmText:
|
||||||
|
'This publishes every approved result for {{exam}} — not just this one — and notifies each candidate. Continue?',
|
||||||
lockedAfterApproval:
|
lockedAfterApproval:
|
||||||
'This result is approved and can no longer be edited. Return it to the examiner first.',
|
'This result is approved and can no longer be edited. Return it to the examiner first.',
|
||||||
originalScore: 'Examiner total',
|
originalScore: 'Examiner total',
|
||||||
@@ -780,6 +801,23 @@ export const en = {
|
|||||||
'Only approved items can be placed on an examination paper.',
|
'Only approved items can be placed on an examination paper.',
|
||||||
error: 'Operation failed',
|
error: 'Operation failed',
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
title: 'Answer Options',
|
||||||
|
hint: 'Mark every correct option. Saving replaces the entire option set.',
|
||||||
|
optionLabel: 'Option {{number}}',
|
||||||
|
optionEn: 'Option {{number}} (English)',
|
||||||
|
optionAm: 'Option {{number}} (Amharic)',
|
||||||
|
correct: 'Correct',
|
||||||
|
addOption: 'Add option',
|
||||||
|
save: 'Save options',
|
||||||
|
saved: 'Options saved',
|
||||||
|
saveFirst: 'Save the question first, then add its options.',
|
||||||
|
replaceNotice:
|
||||||
|
'Correct answers are never shown here once saved — re-mark them if you edit this set again.',
|
||||||
|
needAtLeastTwo: 'A question needs at least two options.',
|
||||||
|
needOneCorrect: 'Mark at least one option as correct.',
|
||||||
|
textRequired: 'Every option needs text in both languages.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
configuration: {
|
configuration: {
|
||||||
@@ -980,7 +1018,10 @@ export const en = {
|
|||||||
requestAdjustment: 'Request adjustment',
|
requestAdjustment: 'Request adjustment',
|
||||||
reject: 'Reject',
|
reject: 'Reject',
|
||||||
scheduleExam: 'Schedule exam',
|
scheduleExam: 'Schedule exam',
|
||||||
|
recordExamOutcome: 'Record exam outcome',
|
||||||
confirmPayment: 'Confirm payment',
|
confirmPayment: 'Confirm payment',
|
||||||
|
scheduleIssuance: 'Schedule pickup',
|
||||||
|
issueCertificate: 'Issue certificate',
|
||||||
print: 'Print dossier',
|
print: 'Print dossier',
|
||||||
copyLink: 'Copy link',
|
copyLink: 'Copy link',
|
||||||
downloadDocuments: 'Download all documents',
|
downloadDocuments: 'Download all documents',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { AppShell, Drawer } from '@mantine/core';
|
import { AppShell, Box, Drawer, Group, Text } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -8,6 +8,7 @@ import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
|||||||
import type { NavItem, NavSection } from '@ema-platform/ui';
|
import type { NavItem, NavSection } from '@ema-platform/ui';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
|
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
|
||||||
|
import { SkipLink, MAIN_CONTENT_ID } from '@ema-platform/ui';
|
||||||
import { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
|
import { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
|
||||||
import { usePermissions } from '@ema-platform/auth';
|
import { usePermissions } from '@ema-platform/auth';
|
||||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||||
@@ -26,6 +27,14 @@ const BADGE_POLL_MS = 60_000;
|
|||||||
|
|
||||||
const HEADER_HEIGHT = 116;
|
const HEADER_HEIGHT = 116;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal inset of the header chrome. `AppHeader` adds its own `px="lg"`
|
||||||
|
* inside this, so the nav strip below needs the sum to line up with the
|
||||||
|
* controls above it — it used to start 20px to their left.
|
||||||
|
*/
|
||||||
|
const CHROME_PAD_X = 32;
|
||||||
|
const NAV_STRIP_PAD_X = CHROME_PAD_X + 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A desk left unlocked with a license-review or medical-record screen open is
|
* A desk left unlocked with a license-review or medical-record screen open is
|
||||||
* the actual threat model here, not a slow token. 15 minutes of no mouse,
|
* the actual threat model here, not a slow token. 15 minutes of no mouse,
|
||||||
@@ -143,8 +152,14 @@ export function BackofficeLayout() {
|
|||||||
const isSidebar = layoutMode === "sidebar";
|
const isSidebar = layoutMode === "sidebar";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* First focusable element on the page, so a keyboard user can bypass
|
||||||
|
the 20-plus nav items instead of tabbing through them every time. */}
|
||||||
|
<SkipLink />
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
|
// The top layout drops its nav strip on small screens — the drawer is
|
||||||
|
// the nav there — so the header shrinks back to a single row with it.
|
||||||
|
header={{ height: isSidebar ? 74 : { base: 74, sm: HEADER_HEIGHT } }}
|
||||||
navbar={
|
navbar={
|
||||||
isSidebar
|
isSidebar
|
||||||
? {
|
? {
|
||||||
@@ -162,13 +177,28 @@ export function BackofficeLayout() {
|
|||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
style={{
|
style={{
|
||||||
background: "var(--mantine-color-body)",
|
background: "var(--mantine-color-body)",
|
||||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
borderBottom: "1px solid var(--mantine-color-default-border)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}>
|
<div
|
||||||
|
style={{ height: 74, flexShrink: 0, padding: `0 ${CHROME_PAD_X}px` }}
|
||||||
|
>
|
||||||
<AppHeader
|
<AppHeader
|
||||||
|
brand={
|
||||||
|
isSidebar ? undefined : (
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<BrandMark size={28} />
|
||||||
|
<Text fw={700} size="sm" lh={1.1} visibleFrom="xs">
|
||||||
|
{t('app.name')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Nothing to toggle on a desktop top bar; on mobile it opens the
|
||||||
|
// drawer below.
|
||||||
|
burgerHiddenFrom={isSidebar ? undefined : 'sm'}
|
||||||
onToggleNav={toggleNav}
|
onToggleNav={toggleNav}
|
||||||
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
||||||
navOpened={opened}
|
navOpened={opened}
|
||||||
@@ -182,13 +212,14 @@ export function BackofficeLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isSidebar && (
|
{!isSidebar && (
|
||||||
<div
|
<Box
|
||||||
|
visibleFrom="sm"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '0 32px',
|
padding: `0 ${NAV_STRIP_PAD_X}px`,
|
||||||
height: 42,
|
height: 42,
|
||||||
borderTop: '1px solid var(--mantine-color-gray-1)',
|
borderTop: '1px solid var(--mantine-color-default-border)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -199,7 +230,7 @@ export function BackofficeLayout() {
|
|||||||
activePath={location.pathname}
|
activePath={location.pathname}
|
||||||
onNavigate={go}
|
onNavigate={go}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|
||||||
@@ -210,7 +241,7 @@ export function BackofficeLayout() {
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
transition: "width 200ms ease",
|
transition: "width 200ms ease",
|
||||||
background: "var(--mantine-color-body)",
|
background: "var(--mantine-color-body)",
|
||||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
borderRight: "1px solid var(--mantine-color-default-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
@@ -226,7 +257,7 @@ export function BackofficeLayout() {
|
|||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AppShell.Main>
|
<AppShell.Main id={MAIN_CONTENT_ID}>
|
||||||
<div key={location.pathname} className="ema-page-enter">
|
<div key={location.pathname} className="ema-page-enter">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -238,30 +269,29 @@ export function BackofficeLayout() {
|
|||||||
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
|
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
|
||||||
click) instead of AppShell's full-width mobile navbar. Mirrors the
|
click) instead of AppShell's full-width mobile navbar. Mirrors the
|
||||||
landing page's mobile menu. */}
|
landing page's mobile menu. */}
|
||||||
{isSidebar && (
|
<Drawer
|
||||||
<Drawer
|
opened={opened}
|
||||||
opened={opened}
|
onClose={closeNav}
|
||||||
onClose={closeNav}
|
hiddenFrom="sm"
|
||||||
hiddenFrom="sm"
|
size="75%"
|
||||||
size="75%"
|
padding={0}
|
||||||
padding={0}
|
withCloseButton={false}
|
||||||
withCloseButton={false}
|
>
|
||||||
>
|
<AppSidebar
|
||||||
<AppSidebar
|
navItems={sections}
|
||||||
navItems={sections}
|
collapsed={false}
|
||||||
collapsed={false}
|
activePath={location.pathname}
|
||||||
activePath={location.pathname}
|
onToggleCollapse={handleToggleCollapse}
|
||||||
onToggleCollapse={handleToggleCollapse}
|
onNavigate={(item) => {
|
||||||
onNavigate={(item) => {
|
go(item);
|
||||||
go(item);
|
closeNav();
|
||||||
closeNav();
|
}}
|
||||||
}}
|
brandName={t('app.name')}
|
||||||
brandName={t('app.name')}
|
brandSubtitle={t('app.authority')}
|
||||||
brandSubtitle={t('app.authority')}
|
brandLogo={<BrandMark size={32} />}
|
||||||
brandLogo={<BrandMark size={32} />}
|
/>
|
||||||
/>
|
</Drawer>
|
||||||
</Drawer>
|
|
||||||
)}
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
RequirePermission,
|
RequirePermission,
|
||||||
LICENSE_PERMISSIONS as P,
|
LICENSE_PERMISSIONS as P,
|
||||||
} from '@ema-platform/auth';
|
} from '@ema-platform/auth';
|
||||||
|
import { ThemeGallery } from '@ema-platform/ui';
|
||||||
import { AuthLayout } from '../layouts/AuthLayout';
|
import { AuthLayout } from '../layouts/AuthLayout';
|
||||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||||
import { ProtectedRoute } from './ProtectedRoute';
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
@@ -68,6 +69,9 @@ const router = createBrowserRouter([
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/um/*', element: <UserManagementPage /> },
|
{ path: '/um/*', element: <UserManagementPage /> },
|
||||||
|
// Theme visual-regression surface. Unauthenticated by design — it renders
|
||||||
|
// only static primitives, so it needs no API and cannot flake.
|
||||||
|
{ path: '/__gallery', element: <ThemeGallery /> },
|
||||||
{ path: '/', element: <LandingRoute /> },
|
{ path: '/', element: <LandingRoute /> },
|
||||||
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
|
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import '@mantine/core/styles.css';
|
|||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
import '@mantine/spotlight/styles.css';
|
import '@mantine/spotlight/styles.css';
|
||||||
|
// After Mantine's CSS (it defines the variables these tokens resolve to),
|
||||||
|
// before the app's own, which may override them. Relative because the
|
||||||
|
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
|
||||||
|
import '../../../libs/shared/src/lib/theme/semantic.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import './app/i18n/config';
|
import './app/i18n/config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
|
||||||
@tailwind base;
|
Without it every Amharic string in the app renders in whatever the OS
|
||||||
@tailwind components;
|
happens to substitute — different on Windows, macOS and Android. */
|
||||||
@tailwind utilities;
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ test.describe('seafarer registration', () => {
|
|||||||
await expect(page.getByLabel('National ID (Fayda) Number')).toHaveValue('FYD1234567890');
|
await expect(page.getByLabel('National ID (Fayda) Number')).toHaveValue('FYD1234567890');
|
||||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||||
|
|
||||||
// Step 2 — Applicant Details.
|
// Step 2 — Details: address and physical characteristics.
|
||||||
await page.getByLabel('Place of Birth').fill('Addis Ababa');
|
await page.getByLabel('Place of Birth').fill('Addis Ababa');
|
||||||
await pick(page, 'Department', /deck/i);
|
await pick(page, 'Department', /deck/i);
|
||||||
await pick(page, 'City', /addis ababa/i);
|
await pick(page, 'City', /addis ababa/i);
|
||||||
@@ -356,15 +356,15 @@ test.describe('seafarer registration', () => {
|
|||||||
await pick(page, 'Eye Colour', /brown/i);
|
await pick(page, 'Eye Colour', /brown/i);
|
||||||
await page.getByLabel('Height (cm)').fill('172');
|
await page.getByLabel('Height (cm)').fill('172');
|
||||||
await page.getByLabel('Weight (kg)').fill('68');
|
await page.getByLabel('Weight (kg)').fill('68');
|
||||||
await page.getByLabel('Certificate Number').fill('MED-2026-001');
|
|
||||||
await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic');
|
|
||||||
await pickDate(page, 'Issue Date', '2026-01-15');
|
|
||||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||||
|
|
||||||
// Step 3 — Emergency Contact.
|
// Step 3 — Contact & Medical.
|
||||||
await page.getByLabel('Full Name').fill('Almaz Tesfaye');
|
await page.getByLabel('Full Name').fill('Almaz Tesfaye');
|
||||||
await page.getByLabel('Relationship').fill('Sister');
|
await page.getByLabel('Relationship').fill('Sister');
|
||||||
await page.getByLabel('Phone Number').fill('+251911222333');
|
await page.getByLabel('Phone Number').fill('+251911222333');
|
||||||
|
await page.getByLabel('Certificate Number').fill('MED-2026-001');
|
||||||
|
await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic');
|
||||||
|
await pickDate(page, 'Issue Date', '2026-01-15');
|
||||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||||
|
|
||||||
// Step 4 — Documents: all four required slots show as uploaded.
|
// Step 4 — Documents: all four required slots show as uploaded.
|
||||||
|
|||||||
65
apps/e2e/visual.config.ts
Normal file
@@ -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 { useRef, useState } from 'react';
|
||||||
import { useApiQuery } from '@ema-platform/api';
|
import { useApiQuery } from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +31,7 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { StatusBadge, notify } from '@ema-platform/ui';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -45,11 +46,11 @@ interface BSTRecord {
|
|||||||
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
|
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_TONE: Record<string, StatusTone> = {
|
||||||
Valid: 'teal',
|
Valid: 'success',
|
||||||
Expiring: 'orange',
|
Expiring: 'pending',
|
||||||
Expired: 'red',
|
Expired: 'danger',
|
||||||
'Pending Verification': 'yellow',
|
'Pending Verification': 'warning',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */
|
/** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */
|
||||||
@@ -289,14 +290,13 @@ export function BasicSafetyTrainingPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{record && (
|
{record && (
|
||||||
<Badge
|
<StatusBadge
|
||||||
|
tone={STATUS_TONE[record.status]}
|
||||||
|
label={record.status}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={STATUS_COLOR[record.status]}
|
|
||||||
leftSection={<IconShieldCheck size={14} />}
|
leftSection={<IconShieldCheck size={14} />}
|
||||||
>
|
/>
|
||||||
{record.status}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ export function BasicSafetyTrainingPage() {
|
|||||||
<Paper withBorder radius="lg" p="lg">
|
<Paper withBorder radius="lg" p="lg">
|
||||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<ThemeIcon size={48} radius="md" color={STATUS_COLOR[record.status]} variant="light">
|
<ThemeIcon size={48} radius="md" color={STATUS_TONE_COLOR[STATUS_TONE[record.status]]} variant="light">
|
||||||
<IconShieldCheck size={24} />
|
<IconShieldCheck size={24} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<div>
|
<div>
|
||||||
@@ -329,9 +329,7 @@ export function BasicSafetyTrainingPage() {
|
|||||||
<Text fz="xs" c="dimmed">Combined certificate — all 5 STCW components</Text>
|
<Text fz="xs" c="dimmed">Combined certificate — all 5 STCW components</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Badge color={STATUS_COLOR[record.status]} variant="light">
|
<StatusBadge tone={STATUS_TONE[record.status]} label={record.status} variant="light" />
|
||||||
{record.status}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap="xs" mb="md">
|
<Stack gap="xs" mb="md">
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button, Card, Stack, Text, ThemeIcon, Title } from '@mantine/core';
|
||||||
|
import { IconCircleCheck, IconClockPause } from '@tabler/icons-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { AttemptStatus } from '../types/exam-attempt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No score, no pass/fail, nothing evaluation-shaped — grading hasn't run.
|
||||||
|
* This only confirms what actually happened: the candidate submitted, or
|
||||||
|
* the deadline closed the attempt out first.
|
||||||
|
*/
|
||||||
|
export function ExamCompletion({
|
||||||
|
status,
|
||||||
|
submittedAt,
|
||||||
|
}: {
|
||||||
|
status: AttemptStatus;
|
||||||
|
submittedAt: string | null;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const expired = status === 'EXPIRED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack maw={520} mx="auto" align="center" py="xl">
|
||||||
|
<Card withBorder radius="lg" p="xl" w="100%">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<ThemeIcon size={64} radius="xl" variant="light" color={expired ? 'orange' : 'teal'}>
|
||||||
|
{expired ? <IconClockPause size={32} /> : <IconCircleCheck size={32} />}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={3} ta="center">
|
||||||
|
{expired ? 'Time expired' : 'Exam submitted'}
|
||||||
|
</Title>
|
||||||
|
<Text ta="center" c="dimmed">
|
||||||
|
{expired
|
||||||
|
? 'The scheduled time ran out. Your saved answers were recorded as your final submission.'
|
||||||
|
: 'Your answers have been recorded.'}
|
||||||
|
{' '}Your result will appear on the Examinations page once marking, moderation and
|
||||||
|
approval are complete — it is not available yet.
|
||||||
|
</Text>
|
||||||
|
{submittedAt && (
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{expired ? 'Closed' : 'Submitted'} at {new Date(submittedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button variant="light" onClick={() => navigate('/exams')}>
|
||||||
|
Back to Examinations
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Alert, Badge, Button, Card, Group, Stack, Text, Title } from '@mantine/core';
|
||||||
|
import { IconAlertCircle, IconClock, IconInfoCircle, IconPlayerPlay } from '@tabler/icons-react';
|
||||||
|
import type { Bilingual } from '@ema-platform/api';
|
||||||
|
import type { EstimatedTime, RegistrationWithExam } from '../types/exam-attempt';
|
||||||
|
|
||||||
|
function formatDuration(time: EstimatedTime | null | undefined): string {
|
||||||
|
if (!time) return 'Not configured';
|
||||||
|
const parts = [
|
||||||
|
time.days ? `${time.days}d` : null,
|
||||||
|
time.hours ? `${time.hours}h` : null,
|
||||||
|
time.minutes ? `${time.minutes}m` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length ? parts.join(' ') : '0m';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExamInstructions({
|
||||||
|
registration,
|
||||||
|
localized,
|
||||||
|
showDate,
|
||||||
|
starting,
|
||||||
|
onStart,
|
||||||
|
}: {
|
||||||
|
registration: RegistrationWithExam;
|
||||||
|
localized: (value: Bilingual | undefined) => string;
|
||||||
|
showDate: (value: string | null | undefined) => string;
|
||||||
|
starting: boolean;
|
||||||
|
onStart: () => void;
|
||||||
|
}) {
|
||||||
|
const exam = registration.exam;
|
||||||
|
const canStart = exam?.status === 'ACTIVE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack maw={720} mx="auto" gap="md">
|
||||||
|
<Title order={2}>{localized(exam?.title) || 'Examination'}</Title>
|
||||||
|
<Text c="dimmed">{localized(exam?.certification?.name)}</Text>
|
||||||
|
|
||||||
|
<Card withBorder radius="md" p="lg">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz="sm" c="dimmed">Admission number</Text>
|
||||||
|
<Text fz="sm" fw={600} ff="monospace">{registration.admissionNumber}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz="sm" c="dimmed">Session date</Text>
|
||||||
|
<Text fz="sm">{showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz="sm" c="dimmed">Duration</Text>
|
||||||
|
<Badge variant="light" leftSection={<IconClock size={12} />}>
|
||||||
|
{formatDuration(exam?.givenTime)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz="sm" c="dimmed">Attempt</Text>
|
||||||
|
<Badge variant="light" color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}>
|
||||||
|
{registration.kind === 'RETAKE'
|
||||||
|
? `Retake · sitting ${registration.attemptNumber}`
|
||||||
|
: 'First sitting'}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{exam?.direction && localized(exam.direction) && (
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light" title="Instructions">
|
||||||
|
{localized(exam.direction)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light">
|
||||||
|
Once started, the timer cannot be paused. Answers are saved automatically as you go, but
|
||||||
|
the exam ends the moment the deadline passes, whether or not you have submitted.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{!canStart && (
|
||||||
|
<Alert color="gray" variant="light">
|
||||||
|
This session is not currently open for candidates to begin.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
leftSection={<IconPlayerPlay size={16} />}
|
||||||
|
loading={starting}
|
||||||
|
disabled={!canStart}
|
||||||
|
onClick={onStart}
|
||||||
|
>
|
||||||
|
Start exam
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Badge, Button, Group, Paper, Radio, Stack, Text, Textarea } from '@mantine/core';
|
||||||
|
import { IconAlertCircle, IconCheck, IconRefresh } from '@tabler/icons-react';
|
||||||
|
import type { Bilingual } from '@ema-platform/api';
|
||||||
|
import type { CandidateQuestion, SaveState } from '../types/exam-attempt';
|
||||||
|
|
||||||
|
function SaveIndicator({ state, onRetry }: { state: SaveState; onRetry: () => void }) {
|
||||||
|
if (state === 'saving') {
|
||||||
|
return <Text fz="xs" c="dimmed">Saving…</Text>;
|
||||||
|
}
|
||||||
|
if (state === 'saved') {
|
||||||
|
return (
|
||||||
|
<Group gap={4}>
|
||||||
|
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal">Saved</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'error') {
|
||||||
|
return (
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconAlertCircle size={13} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text fz="xs" c="red">Not saved</Text>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconRefresh size={12} />}
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one question — never the answer key, because the API response
|
||||||
|
* this reads from (`CandidateQuestion`/`CandidateOption`) has no such field
|
||||||
|
* to render even by mistake.
|
||||||
|
*/
|
||||||
|
export function ExamQuestionDisplay({
|
||||||
|
question,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
localized,
|
||||||
|
selectedOptionId,
|
||||||
|
answerText,
|
||||||
|
saveState,
|
||||||
|
disabled,
|
||||||
|
onSelectOption,
|
||||||
|
onChangeText,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
question: CandidateQuestion;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
localized: (value: Bilingual | undefined) => string;
|
||||||
|
selectedOptionId: string | null | undefined;
|
||||||
|
answerText: string | null | undefined;
|
||||||
|
saveState: SaveState;
|
||||||
|
disabled: boolean;
|
||||||
|
onSelectOption: (optionId: string) => void;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="lg">
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Badge variant="light" color="gray">
|
||||||
|
Question {index + 1} of {total} · {question.points} pts
|
||||||
|
</Badge>
|
||||||
|
<SaveIndicator state={saveState} onRetry={onRetry} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text fz="md" fw={500} mb="lg">
|
||||||
|
{localized(question.title)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{question.form === 'CHOICE' ? (
|
||||||
|
<Radio.Group
|
||||||
|
value={selectedOptionId ?? ''}
|
||||||
|
onChange={onSelectOption}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{question.options
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((option) => (
|
||||||
|
<Radio.Card
|
||||||
|
key={option.id}
|
||||||
|
value={option.id}
|
||||||
|
disabled={disabled}
|
||||||
|
p="sm"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<Group wrap="nowrap" gap="sm">
|
||||||
|
<Radio.Indicator disabled={disabled} />
|
||||||
|
<Text fz="sm">{localized(option.text)}</Text>
|
||||||
|
</Group>
|
||||||
|
</Radio.Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
placeholder="Write your answer"
|
||||||
|
minRows={8}
|
||||||
|
autosize
|
||||||
|
disabled={disabled}
|
||||||
|
value={answerText ?? ''}
|
||||||
|
onChange={(event) => onChangeText(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Paper, SimpleGrid, Text, UnstyledButton } from '@mantine/core';
|
||||||
|
import type { CandidateQuestion } from '../types/exam-attempt';
|
||||||
|
|
||||||
|
export function ExamQuestionNav({
|
||||||
|
questions,
|
||||||
|
currentIndex,
|
||||||
|
answeredIds,
|
||||||
|
disabled,
|
||||||
|
onJump,
|
||||||
|
}: {
|
||||||
|
questions: CandidateQuestion[];
|
||||||
|
currentIndex: number;
|
||||||
|
answeredIds: Set<string>;
|
||||||
|
disabled: boolean;
|
||||||
|
onJump: (index: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="sm">
|
||||||
|
<Text fz="xs" fw={600} c="dimmed" mb="xs" tt="uppercase">
|
||||||
|
Questions
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={5} spacing={6}>
|
||||||
|
{questions.map((q, index) => {
|
||||||
|
const answered = answeredIds.has(q.id);
|
||||||
|
const current = index === currentIndex;
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
key={q.id}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onJump(index)}
|
||||||
|
style={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
border: current ? '2px solid var(--mantine-color-blue-6)' : '1px solid var(--mantine-color-gray-4)',
|
||||||
|
background: answered
|
||||||
|
? 'var(--mantine-color-teal-1)'
|
||||||
|
: 'var(--mantine-color-body)',
|
||||||
|
color: answered ? 'var(--mantine-color-teal-8)' : undefined,
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Text fz="xs" c="dimmed" mt="sm">
|
||||||
|
{answeredIds.size} of {questions.length} answered
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Badge, Group } from '@mantine/core';
|
||||||
|
import { IconClock } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
function format(totalSeconds: number): string {
|
||||||
|
const s = Math.max(0, totalSeconds);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display only. `remainingSeconds` is a local countdown seeded once from the
|
||||||
|
* server's own clock (`AttemptSession.remainingSeconds`/`serverTime`) and
|
||||||
|
* ticked down client-side — the deadline it represents is enforced by the
|
||||||
|
* backend on every save/submit regardless of whether this number is right.
|
||||||
|
*/
|
||||||
|
export function ExamTimer({ remainingSeconds }: { remainingSeconds: number }) {
|
||||||
|
const low = remainingSeconds <= 300; // 5 minutes
|
||||||
|
return (
|
||||||
|
<Group gap={6}>
|
||||||
|
<Badge
|
||||||
|
size="lg"
|
||||||
|
variant="light"
|
||||||
|
color={low ? 'red' : 'blue'}
|
||||||
|
leftSection={<IconClock size={14} />}
|
||||||
|
ff="monospace"
|
||||||
|
>
|
||||||
|
{format(remainingSeconds)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useApiMutation, useApiQuery, extractErrorMessage } from '@ema-platform/api';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import type {
|
||||||
|
AttemptSession,
|
||||||
|
CandidateAnswer,
|
||||||
|
ExamAttempt,
|
||||||
|
RegistrationWithExam,
|
||||||
|
SaveState,
|
||||||
|
} from '../types/exam-attempt';
|
||||||
|
|
||||||
|
const ESSAY_DEBOUNCE_MS = 1500;
|
||||||
|
|
||||||
|
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
|
||||||
|
|
||||||
|
type LocalAnswer = { selectedOptionId?: string | null; answerText?: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All state and API orchestration for taking one exam. Kept out of the page
|
||||||
|
* component so the component tree stays about rendering, not about save
|
||||||
|
* timers and expiry races.
|
||||||
|
*
|
||||||
|
* Nothing here is a security boundary — every write still goes through the
|
||||||
|
* backend's own ownership + `applyExpiry()` checks on every call. This hook
|
||||||
|
* only decides what to show; a client that skipped straight to calling the
|
||||||
|
* API directly would hit exactly the same server-side rules.
|
||||||
|
*/
|
||||||
|
export function useExamAttempt(examId: string | undefined) {
|
||||||
|
const [session, setSession] = useState<AttemptSession | null>(null);
|
||||||
|
const [viewState, setViewState] = useState<ViewState>('loading');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [answers, setAnswers] = useState<Record<string, LocalAnswer>>({});
|
||||||
|
const [saveStates, setSaveStates] = useState<Record<string, SaveState>>({});
|
||||||
|
const [remainingSeconds, setRemainingSeconds] = useState(0);
|
||||||
|
|
||||||
|
const answersRef = useRef(answers);
|
||||||
|
answersRef.current = answers;
|
||||||
|
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||||
|
const seeded = useRef(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: registrations,
|
||||||
|
isLoading: loadingRegistrations,
|
||||||
|
} = useApiQuery<RegistrationWithExam[]>({ url: '/exams/registrations/mine' });
|
||||||
|
const registration = registrations?.find((r) => r.exam?.id === examId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: mineData,
|
||||||
|
isLoading: loadingMine,
|
||||||
|
isError: mineIsError,
|
||||||
|
error: mineError,
|
||||||
|
refetch: refetchMine,
|
||||||
|
} = useApiQuery<AttemptSession>(
|
||||||
|
{ url: `/exam-attempts/mine/${examId}` },
|
||||||
|
{ skip: !examId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [startTrigger, { isLoading: starting }] = useApiMutation<AttemptSession>();
|
||||||
|
const [answerTrigger] = useApiMutation<CandidateAnswer>();
|
||||||
|
const [submitTrigger, { isLoading: submitting }] = useApiMutation<ExamAttempt>();
|
||||||
|
|
||||||
|
const seedFrom = useCallback((data: AttemptSession) => {
|
||||||
|
setSession(data);
|
||||||
|
const map: Record<string, LocalAnswer> = {};
|
||||||
|
for (const a of data.answers) {
|
||||||
|
map[a.questionId] = { selectedOptionId: a.selectedOptionId, answerText: a.answerText };
|
||||||
|
}
|
||||||
|
setAnswers(map);
|
||||||
|
setRemainingSeconds(data.remainingSeconds);
|
||||||
|
setViewState(data.attempt.status === 'IN_PROGRESS' ? 'taking' : 'completed');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Seed once from the initial load — after that, local state (ticking
|
||||||
|
// timer, in-flight edits) is the source of truth, not this query.
|
||||||
|
useEffect(() => {
|
||||||
|
if (seeded.current) return;
|
||||||
|
if (loadingRegistrations || loadingMine) return;
|
||||||
|
seeded.current = true;
|
||||||
|
|
||||||
|
if (!registration) {
|
||||||
|
setViewState('error');
|
||||||
|
setErrorMessage('You are not registered for this examination.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mineData) {
|
||||||
|
seedFrom(mineData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mineIsError) {
|
||||||
|
const key = extractErrorMessage(mineError, '');
|
||||||
|
if (key === 'attempt_not_found') {
|
||||||
|
setViewState('not-started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewState('error');
|
||||||
|
setErrorMessage(extractErrorMessage(mineError, 'Could not load the exam.'));
|
||||||
|
}
|
||||||
|
}, [loadingRegistrations, loadingMine, registration, mineData, mineIsError, mineError, seedFrom]);
|
||||||
|
|
||||||
|
/** Authoritative resync — used after any write is refused as expired/submitted. */
|
||||||
|
const syncFromServer = useCallback(async () => {
|
||||||
|
const result = await refetchMine();
|
||||||
|
if (result.data) {
|
||||||
|
seedFrom(result.data as AttemptSession);
|
||||||
|
} else {
|
||||||
|
setViewState('error');
|
||||||
|
setErrorMessage(extractErrorMessage(result.error, 'The exam session ended.'));
|
||||||
|
}
|
||||||
|
}, [refetchMine, seedFrom]);
|
||||||
|
|
||||||
|
const persistAnswer = useCallback(
|
||||||
|
async (questionId: string, payload: LocalAnswer) => {
|
||||||
|
if (!session) return;
|
||||||
|
setSaveStates((s) => ({ ...s, [questionId]: 'saving' }));
|
||||||
|
try {
|
||||||
|
const saved = await answerTrigger({
|
||||||
|
url: `/exam-attempts/${session.attempt.id}/answers`,
|
||||||
|
method: 'POST',
|
||||||
|
body: { questionId, ...payload },
|
||||||
|
}).unwrap();
|
||||||
|
setAnswers((a) => ({
|
||||||
|
...a,
|
||||||
|
[questionId]: { selectedOptionId: saved.selectedOptionId, answerText: saved.answerText },
|
||||||
|
}));
|
||||||
|
setSaveStates((s) => ({ ...s, [questionId]: 'saved' }));
|
||||||
|
} catch (error) {
|
||||||
|
setSaveStates((s) => ({ ...s, [questionId]: 'error' }));
|
||||||
|
const key = extractErrorMessage(error, '');
|
||||||
|
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
|
||||||
|
notify.error(
|
||||||
|
key === 'attempt_expired'
|
||||||
|
? 'Time is up — this answer was not saved.'
|
||||||
|
: 'This attempt has already been submitted.',
|
||||||
|
);
|
||||||
|
syncFromServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[session, answerTrigger, syncFromServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const flush = useCallback(
|
||||||
|
(questionId: string) => {
|
||||||
|
const timer = debounceTimers.current[questionId];
|
||||||
|
if (!timer) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
delete debounceTimers.current[questionId];
|
||||||
|
const current = answersRef.current[questionId];
|
||||||
|
if (current) persistAnswer(questionId, current);
|
||||||
|
},
|
||||||
|
[persistAnswer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectOption = useCallback(
|
||||||
|
(questionId: string, optionId: string) => {
|
||||||
|
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], selectedOptionId: optionId } }));
|
||||||
|
persistAnswer(questionId, { selectedOptionId: optionId });
|
||||||
|
},
|
||||||
|
[persistAnswer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeText = useCallback(
|
||||||
|
(questionId: string, text: string) => {
|
||||||
|
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], answerText: text } }));
|
||||||
|
setSaveStates((s) => ({ ...s, [questionId]: 'idle' }));
|
||||||
|
clearTimeout(debounceTimers.current[questionId]);
|
||||||
|
debounceTimers.current[questionId] = setTimeout(() => {
|
||||||
|
delete debounceTimers.current[questionId];
|
||||||
|
persistAnswer(questionId, { answerText: text });
|
||||||
|
}, ESSAY_DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
[persistAnswer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goTo = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const current = session?.questions[currentIndex];
|
||||||
|
if (current) flush(current.id);
|
||||||
|
setCurrentIndex(index);
|
||||||
|
},
|
||||||
|
[session, currentIndex, flush],
|
||||||
|
);
|
||||||
|
|
||||||
|
const retry = useCallback(
|
||||||
|
(questionId: string) => {
|
||||||
|
const current = answersRef.current[questionId];
|
||||||
|
if (current) persistAnswer(questionId, current);
|
||||||
|
},
|
||||||
|
[persistAnswer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
if (!examId) return;
|
||||||
|
try {
|
||||||
|
const result = await startTrigger({
|
||||||
|
url: '/exam-attempts/start',
|
||||||
|
method: 'POST',
|
||||||
|
body: { examId },
|
||||||
|
}).unwrap();
|
||||||
|
seedFrom(result);
|
||||||
|
} catch (error) {
|
||||||
|
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
|
||||||
|
}
|
||||||
|
}, [examId, startTrigger, seedFrom]);
|
||||||
|
|
||||||
|
const submit = useCallback(async () => {
|
||||||
|
if (!session) return;
|
||||||
|
const current = session.questions[currentIndex];
|
||||||
|
if (current) flush(current.id);
|
||||||
|
try {
|
||||||
|
const attempt = await submitTrigger({
|
||||||
|
url: `/exam-attempts/${session.attempt.id}/submit`,
|
||||||
|
method: 'POST',
|
||||||
|
}).unwrap();
|
||||||
|
setSession((s) => (s ? { ...s, attempt } : s));
|
||||||
|
setViewState('completed');
|
||||||
|
} catch (error) {
|
||||||
|
const key = extractErrorMessage(error, '');
|
||||||
|
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
|
||||||
|
syncFromServer();
|
||||||
|
} else {
|
||||||
|
notify.error(extractErrorMessage(error, 'Could not submit the exam.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [session, currentIndex, flush, submitTrigger, syncFromServer]);
|
||||||
|
|
||||||
|
/** Local countdown only — every write is still checked server-side regardless. */
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewState !== 'taking') return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setRemainingSeconds((s) => {
|
||||||
|
if (s <= 1) {
|
||||||
|
clearInterval(id);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return s - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [viewState]);
|
||||||
|
|
||||||
|
// Time reaching zero locally: stop taking input, tell the server, then
|
||||||
|
// trust whatever it reports back over anything computed in the browser.
|
||||||
|
const timedOutRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewState !== 'taking' || remainingSeconds > 0 || timedOutRef.current) return;
|
||||||
|
timedOutRef.current = true;
|
||||||
|
notify.error("Time's up.");
|
||||||
|
// submit() itself resyncs from the server if this loses the race against
|
||||||
|
// applyExpiry() — either way the final state comes from the backend, not
|
||||||
|
// from this timer having reached zero.
|
||||||
|
submit();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [remainingSeconds, viewState]);
|
||||||
|
|
||||||
|
const answeredIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
Object.entries(answers)
|
||||||
|
.filter(([, v]) => v.selectedOptionId || v.answerText?.trim())
|
||||||
|
.map(([id]) => id),
|
||||||
|
),
|
||||||
|
[answers],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewState,
|
||||||
|
errorMessage,
|
||||||
|
registration,
|
||||||
|
session,
|
||||||
|
currentIndex,
|
||||||
|
answers,
|
||||||
|
saveStates,
|
||||||
|
answeredIds,
|
||||||
|
remainingSeconds,
|
||||||
|
starting,
|
||||||
|
submitting,
|
||||||
|
start,
|
||||||
|
selectOption,
|
||||||
|
changeText,
|
||||||
|
goTo,
|
||||||
|
retry,
|
||||||
|
submit,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Alert, Button, Center, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
||||||
|
import { IconAlertCircle, IconSend } from '@tabler/icons-react';
|
||||||
|
import { useLocalized } from '@ema-platform/api';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
import { useExamAttempt } from '../../hooks/useExamAttempt';
|
||||||
|
import { ExamInstructions } from '../../components/ExamInstructions';
|
||||||
|
import { ExamTimer } from '../../components/ExamTimer';
|
||||||
|
import { ExamQuestionNav } from '../../components/ExamQuestionNav';
|
||||||
|
import { ExamQuestionDisplay } from '../../components/ExamQuestionDisplay';
|
||||||
|
import { ExamCompletion } from '../../components/ExamCompletion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate exam-taking screen (Phase 4). Route: `/exams/:examId/take`.
|
||||||
|
*
|
||||||
|
* All state/API orchestration lives in `useExamAttempt` — this component is
|
||||||
|
* the view: pick which of loading/not-started/taking/completed/error to
|
||||||
|
* render. Every write it triggers (start, save, submit) is re-checked by the
|
||||||
|
* backend regardless of what this screen currently shows; nothing here is
|
||||||
|
* the actual security boundary.
|
||||||
|
*/
|
||||||
|
export function ExamAttemptPage() {
|
||||||
|
const { examId } = useParams<{ examId: string }>();
|
||||||
|
const localized = useLocalized();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
const [confirmOpened, setConfirmOpened] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
viewState,
|
||||||
|
errorMessage,
|
||||||
|
registration,
|
||||||
|
session,
|
||||||
|
currentIndex,
|
||||||
|
answers,
|
||||||
|
saveStates,
|
||||||
|
answeredIds,
|
||||||
|
remainingSeconds,
|
||||||
|
starting,
|
||||||
|
submitting,
|
||||||
|
start,
|
||||||
|
selectOption,
|
||||||
|
changeText,
|
||||||
|
goTo,
|
||||||
|
retry,
|
||||||
|
submit,
|
||||||
|
} = useExamAttempt(examId);
|
||||||
|
|
||||||
|
if (viewState === 'loading') {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewState === 'error') {
|
||||||
|
return (
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="red" maw={600} mx="auto" mt="xl">
|
||||||
|
{errorMessage}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewState === 'not-started') {
|
||||||
|
if (!registration) return null; // guarded by 'error' above, appeases TS
|
||||||
|
return (
|
||||||
|
<ExamInstructions
|
||||||
|
registration={registration}
|
||||||
|
localized={localized}
|
||||||
|
showDate={showDate}
|
||||||
|
starting={starting}
|
||||||
|
onStart={start}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewState === 'completed' && session) {
|
||||||
|
return (
|
||||||
|
<ExamCompletion status={session.attempt.status} submittedAt={session.attempt.submittedAt} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) return null; // 'taking' always has a session by construction
|
||||||
|
|
||||||
|
const question = session.questions[currentIndex];
|
||||||
|
const answer = answers[question.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack maw={1000} mx="auto" gap="md">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Text fw={600}>{localized(registration?.exam?.title) || 'Examination in progress'}</Text>
|
||||||
|
<ExamTimer remainingSeconds={remainingSeconds} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group align="flex-start" gap="md" wrap="wrap-reverse">
|
||||||
|
<div style={{ flex: 1, minWidth: 280 }}>
|
||||||
|
<ExamQuestionDisplay
|
||||||
|
question={question}
|
||||||
|
index={currentIndex}
|
||||||
|
total={session.questions.length}
|
||||||
|
localized={localized}
|
||||||
|
selectedOptionId={answer?.selectedOptionId}
|
||||||
|
answerText={answer?.answerText}
|
||||||
|
saveState={saveStates[question.id] ?? 'idle'}
|
||||||
|
disabled={remainingSeconds <= 0}
|
||||||
|
onSelectOption={(optionId) => selectOption(question.id, optionId)}
|
||||||
|
onChangeText={(text) => changeText(question.id, text)}
|
||||||
|
onRetry={() => retry(question.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
disabled={currentIndex === 0}
|
||||||
|
onClick={() => goTo(currentIndex - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
{currentIndex < session.questions.length - 1 ? (
|
||||||
|
<Button onClick={() => goTo(currentIndex + 1)}>Next</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconSend size={15} />}
|
||||||
|
onClick={() => setConfirmOpened(true)}
|
||||||
|
>
|
||||||
|
Submit exam
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: 220, flexShrink: 0 }}>
|
||||||
|
<ExamQuestionNav
|
||||||
|
questions={session.questions}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
answeredIds={answeredIds}
|
||||||
|
disabled={remainingSeconds <= 0}
|
||||||
|
onJump={goTo}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconSend size={15} />}
|
||||||
|
onClick={() => setConfirmOpened(true)}
|
||||||
|
>
|
||||||
|
Submit exam
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={confirmOpened}
|
||||||
|
onClose={() => setConfirmOpened(false)}
|
||||||
|
title="Submit this exam?"
|
||||||
|
radius="lg"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">
|
||||||
|
{answeredIds.size} of {session.questions.length} questions answered. Once submitted,
|
||||||
|
answers cannot be changed.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setConfirmOpened(false)}>
|
||||||
|
Keep working
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
loading={submitting}
|
||||||
|
onClick={async () => {
|
||||||
|
await submit();
|
||||||
|
setConfirmOpened(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExamAttemptPage;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Bilingual } from '@ema-platform/api';
|
||||||
|
|
||||||
|
export type AttemptStatus = 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
|
||||||
|
export type QuestionForm = 'ESSAY' | 'CHOICE';
|
||||||
|
|
||||||
|
export interface CandidateOption {
|
||||||
|
id: string;
|
||||||
|
text: Bilingual;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never carries a correct-answer flag — the API doesn't send one. */
|
||||||
|
export interface CandidateQuestion {
|
||||||
|
id: string;
|
||||||
|
title: Bilingual;
|
||||||
|
form: QuestionForm;
|
||||||
|
points: number;
|
||||||
|
options: CandidateOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamAttempt {
|
||||||
|
id: string;
|
||||||
|
examId: string;
|
||||||
|
registrationId: string;
|
||||||
|
status: AttemptStatus;
|
||||||
|
startedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
submittedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateAnswer {
|
||||||
|
id: string;
|
||||||
|
attemptId: string;
|
||||||
|
questionId: string;
|
||||||
|
selectedOptionId: string | null;
|
||||||
|
answerText: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response shape shared by POST /exam-attempts/start and GET .../mine/:examId. */
|
||||||
|
export interface AttemptSession {
|
||||||
|
attempt: ExamAttempt;
|
||||||
|
questions: CandidateQuestion[];
|
||||||
|
answers: CandidateAnswer[];
|
||||||
|
serverTime: string;
|
||||||
|
remainingSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
export interface EstimatedTime {
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of `GET /exams/registrations/mine`'s response this feature
|
||||||
|
* reads — the endpoint returns the full raw exam/registration, this is
|
||||||
|
* just this feature's own narrow view of it (matches the sibling `exams`
|
||||||
|
* feature's pattern of each screen typing only what it uses).
|
||||||
|
*/
|
||||||
|
export interface RegistrationWithExam {
|
||||||
|
id: string;
|
||||||
|
admissionNumber: string;
|
||||||
|
kind: 'NEW' | 'RETAKE';
|
||||||
|
attemptNumber: number;
|
||||||
|
attendanceStatus: string;
|
||||||
|
exam?: {
|
||||||
|
id: string;
|
||||||
|
title: Bilingual;
|
||||||
|
direction?: Bilingual;
|
||||||
|
date: string;
|
||||||
|
venue: string | null;
|
||||||
|
status: string;
|
||||||
|
givenTime: EstimatedTime | null;
|
||||||
|
certification?: { name?: Bilingual };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, Button, Text } from '@mantine/core';
|
import { Badge, Button, Text } from '@mantine/core';
|
||||||
import { IconFileText, IconGavel } from '@tabler/icons-react';
|
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { Bilingual } from '@ema-platform/api';
|
import type { Bilingual } from '@ema-platform/api';
|
||||||
@@ -20,6 +20,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
|||||||
DISQUALIFIED: 'red',
|
DISQUALIFIED: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
|
||||||
|
|
||||||
export function registrationColumns(
|
export function registrationColumns(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
deps: {
|
deps: {
|
||||||
@@ -28,6 +30,7 @@ export function registrationColumns(
|
|||||||
localized: (value: Bilingual | undefined) => string;
|
localized: (value: Bilingual | undefined) => string;
|
||||||
showDate: (value: string | null | undefined) => string;
|
showDate: (value: string | null | undefined) => string;
|
||||||
onDownloadSlip: (registration: MyRegistration) => void;
|
onDownloadSlip: (registration: MyRegistration) => void;
|
||||||
|
onStartExam: (registration: MyRegistration) => void;
|
||||||
},
|
},
|
||||||
): AdvancedColumn<MyRegistration>[] {
|
): AdvancedColumn<MyRegistration>[] {
|
||||||
return [
|
return [
|
||||||
@@ -91,6 +94,44 @@ export function registrationColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
) : null,
|
) : null,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: t('exams.columns.exam'),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const exam = row.original.exam;
|
||||||
|
const attemptStatus = row.original.attempt?.status;
|
||||||
|
// Already finished — no restart, no more room for "Take exam" to
|
||||||
|
// invite a click that the backend would just refuse.
|
||||||
|
if (attemptStatus === 'SUBMITTED') {
|
||||||
|
return (
|
||||||
|
<Badge size="sm" variant="light" color="teal">
|
||||||
|
{t('exams.columns.completed')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (attemptStatus === 'EXPIRED') {
|
||||||
|
return (
|
||||||
|
<Badge size="sm" variant="light" color="red">
|
||||||
|
{t('exams.columns.timeExpired')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const eligible =
|
||||||
|
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
|
||||||
|
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconPlayerPlay size={13} />}
|
||||||
|
onClick={() => deps.onStartExam(row.original)}
|
||||||
|
>
|
||||||
|
{attemptStatus === 'IN_PROGRESS'
|
||||||
|
? t('exams.columns.resumeExam')
|
||||||
|
: t('exams.columns.takeExam')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -53,6 +54,8 @@ export interface MyRegistration {
|
|||||||
attemptNumber: number;
|
attemptNumber: number;
|
||||||
attendanceStatus: AttendanceStatus;
|
attendanceStatus: AttendanceStatus;
|
||||||
exam?: OpenExam;
|
exam?: OpenExam;
|
||||||
|
/** The candidate's online sitting, when one has been started. */
|
||||||
|
attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MyResult {
|
export interface MyResult {
|
||||||
@@ -80,6 +83,7 @@ export interface MyAppeal {
|
|||||||
*/
|
*/
|
||||||
export function ExamsPage() {
|
export function ExamsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
||||||
@@ -245,6 +249,7 @@ export function ExamsPage() {
|
|||||||
localized,
|
localized,
|
||||||
showDate,
|
showDate,
|
||||||
onDownloadSlip: downloadSlip,
|
onDownloadSlip: downloadSlip,
|
||||||
|
onStartExam: (registration) => navigate(`/exams/${registration.exam?.id}/take`),
|
||||||
})}
|
})}
|
||||||
data={pagedRegistrations.rows}
|
data={pagedRegistrations.rows}
|
||||||
itemCount={pagedRegistrations.itemCount}
|
itemCount={pagedRegistrations.itemCount}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function IdentityDetailsStep(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step 2 — Identity, Address, Physical Characteristics, Medical Certificate. */
|
/** Step 2 — Identity, Address and Physical Characteristics. */
|
||||||
export function ApplicantDetailsStep(p: StepProps) {
|
export function ApplicantDetailsStep(p: StepProps) {
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -138,6 +138,29 @@ export function ApplicantDetailsStep(p: StepProps) {
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 4 — Emergency Contact and Medical Certificate.
|
||||||
|
*
|
||||||
|
* The medical certificate used to sit at the bottom of Applicant Details,
|
||||||
|
* which made that step 17 fields across four sections while this one held
|
||||||
|
* three. Both are short, unrelated-to-identity, and copied off a document in
|
||||||
|
* hand rather than recalled — so they pair here and the wizard's longest step
|
||||||
|
* drops by a third.
|
||||||
|
*/
|
||||||
|
export function EmergencyContactStep(p: StepProps) {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
|
||||||
|
<Grid>
|
||||||
|
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
|
||||||
|
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
|
||||||
|
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<SectionTitle
|
<SectionTitle
|
||||||
title="Medical Certificate"
|
title="Medical Certificate"
|
||||||
@@ -157,17 +180,3 @@ export function ApplicantDetailsStep(p: StepProps) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step 3 — Emergency Contact. */
|
|
||||||
export function EmergencyContactStep(p: StepProps) {
|
|
||||||
return (
|
|
||||||
<Stack gap="lg">
|
|
||||||
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
|
|
||||||
<Grid>
|
|
||||||
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
|
|
||||||
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
|
|
||||||
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
@@ -20,7 +19,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import {
|
import {
|
||||||
PHYSICAL_BOUNDS,
|
PHYSICAL_BOUNDS,
|
||||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||||
SEAFARER_REGISTRATION_STATUS_COLORS,
|
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
extractValidationIssues,
|
extractValidationIssues,
|
||||||
@@ -33,7 +32,7 @@ import {
|
|||||||
type SeafarerRegistration,
|
type SeafarerRegistration,
|
||||||
type ValidationIssue,
|
type ValidationIssue,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { splitPersonName } from '@ema-platform/ui';
|
import { splitPersonName, StatusBadge } from '@ema-platform/ui';
|
||||||
import { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth';
|
import { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth';
|
||||||
import { useAppSelector } from '../../../store/hooks';
|
import { useAppSelector } from '../../../store/hooks';
|
||||||
import { CheckboxField, type AnswerKey } from '../components/fields';
|
import { CheckboxField, type AnswerKey } from '../components/fields';
|
||||||
@@ -41,16 +40,19 @@ import { ApplicantDetailsStep, EmergencyContactStep, IdentityDetailsStep } from
|
|||||||
import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments';
|
import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments';
|
||||||
import { RegistrationSummary } from '../components/RegistrationSummary';
|
import { RegistrationSummary } from '../components/RegistrationSummary';
|
||||||
|
|
||||||
const STEPS = ['Identity Details', 'Applicant Details', 'Emergency Contact', 'Documents', 'Review'];
|
const STEPS = [
|
||||||
|
{ label: 'Identity', description: 'Who you are' },
|
||||||
|
{ label: 'Details', description: 'Address & physical' },
|
||||||
|
{ label: 'Contact & Medical', description: 'Emergency & fitness' },
|
||||||
|
{ label: 'Documents', description: 'Upload evidence' },
|
||||||
|
{ label: 'Review', description: 'Check & submit' },
|
||||||
|
];
|
||||||
|
|
||||||
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
|
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
|
||||||
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
||||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
|
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
|
||||||
[
|
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||||
'placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg',
|
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
||||||
'medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate',
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
[],
|
[],
|
||||||
['declarationAccepted'],
|
['declarationAccepted'],
|
||||||
];
|
];
|
||||||
@@ -123,7 +125,7 @@ export function SeafarerRegistrationPage() {
|
|||||||
const registration = data?.registration ?? null;
|
const registration = data?.registration ?? null;
|
||||||
|
|
||||||
const [start] = useStartSeafarerRegistrationMutation();
|
const [start] = useStartSeafarerRegistrationMutation();
|
||||||
const [save] = useSaveSeafarerRegistrationMutation();
|
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
|
||||||
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
|
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
|
||||||
const [startError, setStartError] = useState<string | null>(null);
|
const [startError, setStartError] = useState<string | null>(null);
|
||||||
const started = useRef(false);
|
const started = useRef(false);
|
||||||
@@ -215,12 +217,16 @@ export function SeafarerRegistrationPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setErrors(found);
|
setErrors(found);
|
||||||
const count = Object.keys(found).length;
|
const missingKeys = Object.keys(found) as AnswerKey[];
|
||||||
if (count) {
|
if (missingKeys.length) {
|
||||||
|
// Name the fields rather than counting them. "Complete 3 required fields"
|
||||||
|
// sends the applicant hunting up a step they have already scrolled past;
|
||||||
|
// the labels are what let them go straight to it.
|
||||||
|
const names = missingKeys.map((k) => SEAFARER_REGISTRATION_FIELD_LABELS[k]);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'red',
|
color: 'red',
|
||||||
title: 'Incomplete',
|
title: missingKeys.length > 1 ? 'Some details are missing' : 'One detail is missing',
|
||||||
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
|
message: `${names.join(', ')}.`,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -307,9 +313,10 @@ export function SeafarerRegistrationPage() {
|
|||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{registration.registrationNumber}
|
{registration.registrationNumber}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
|
<StatusBadge
|
||||||
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
|
||||||
</Badge>
|
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
{showSummary && !readOnly && (
|
{showSummary && !readOnly && (
|
||||||
@@ -362,8 +369,8 @@ export function SeafarerRegistrationPage() {
|
|||||||
{!showSummary && (
|
{!showSummary && (
|
||||||
<Paper withBorder p="lg" radius="md">
|
<Paper withBorder p="lg" radius="md">
|
||||||
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
|
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
|
||||||
{STEPS.map((label) => (
|
{STEPS.map((step) => (
|
||||||
<Stepper.Step key={label} label={label} />
|
<Stepper.Step key={step.label} label={step.label} description={step.description} />
|
||||||
))}
|
))}
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|
||||||
@@ -408,9 +415,11 @@ export function SeafarerRegistrationPage() {
|
|||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
{active < STEPS.length - 1 ? (
|
{active < STEPS.length - 1 ? (
|
||||||
<Button onClick={() => goToStep(active + 1)}>Continue</Button>
|
<Button loading={saving} onClick={() => goToStep(active + 1)}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button color="teal" loading={submitting} disabled={readOnly} onClick={handleSubmit}>
|
<Button color="teal" loading={saving || submitting} disabled={readOnly} onClick={handleSubmit}>
|
||||||
{isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
|
{isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { Badge, Group, Text, Tooltip } from '@mantine/core';
|
import { Badge, Group, Text, Tooltip } from '@mantine/core';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { StatusBadge } from '@ema-platform/ui';
|
||||||
import { seaServiceDays, type MedicalCertificate, type SeaServiceRecord } from '@ema-platform/api';
|
import { seaServiceDays, type MedicalCertificate, type SeaServiceRecord } from '@ema-platform/api';
|
||||||
|
|
||||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
const RECORD_STATUS_TONES: Record<string, StatusTone> = {
|
||||||
SUBMITTED: 'blue',
|
SUBMITTED: 'info',
|
||||||
VERIFIED: 'green',
|
VERIFIED: 'success',
|
||||||
REJECTED: 'red',
|
REJECTED: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fitnessOptions(t: TFunction) {
|
export function fitnessOptions(t: TFunction) {
|
||||||
@@ -78,11 +80,12 @@ export function seaServiceColumns(
|
|||||||
label={row.original.verificationRemark ?? ''}
|
label={row.original.verificationRemark ?? ''}
|
||||||
disabled={!row.original.verificationRemark}
|
disabled={!row.original.verificationRemark}
|
||||||
>
|
>
|
||||||
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
|
<StatusBadge
|
||||||
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
|
tone={RECORD_STATUS_TONES[row.original.status]}
|
||||||
|
label={t(`seaRecords.columns.recordStatus.${row.original.status}`, {
|
||||||
defaultValue: row.original.status,
|
defaultValue: row.original.status,
|
||||||
})}
|
})}
|
||||||
</Badge>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -140,11 +143,12 @@ export function medicalColumns(
|
|||||||
label={row.original.verificationRemark ?? ''}
|
label={row.original.verificationRemark ?? ''}
|
||||||
disabled={!row.original.verificationRemark}
|
disabled={!row.original.verificationRemark}
|
||||||
>
|
>
|
||||||
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
|
<StatusBadge
|
||||||
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
|
tone={RECORD_STATUS_TONES[row.original.status]}
|
||||||
|
label={t(`seaRecords.columns.recordStatus.${row.original.status}`, {
|
||||||
defaultValue: row.original.status,
|
defaultValue: row.original.status,
|
||||||
})}
|
})}
|
||||||
</Badge>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -164,6 +164,13 @@ function EvidenceField({
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- sea service
|
// ---------------------------------------------------------------- sea service
|
||||||
|
|
||||||
|
/** Today as a `yyyy-mm-dd` key — same shape the pickers emit, so plain
|
||||||
|
* string comparison is a valid date comparison. Taken in the authority's
|
||||||
|
* timezone, matching the server's check, so a seafarer logging in from a
|
||||||
|
* zone ahead of Addis isn't offered a day the server then rejects. */
|
||||||
|
const todayKey = () =>
|
||||||
|
new Date().toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
|
||||||
|
|
||||||
const EMPTY_SEA_SERVICE = {
|
const EMPTY_SEA_SERVICE = {
|
||||||
vesselName: '',
|
vesselName: '',
|
||||||
imoNumber: '',
|
imoNumber: '',
|
||||||
@@ -276,12 +283,27 @@ function SeaServiceTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Service already served — neither end of an engagement can be in the future.
|
||||||
|
const today = todayKey();
|
||||||
|
const dateError =
|
||||||
|
form.engagementDate > today || form.dischargeDate > today
|
||||||
|
? t('seaRecords.seaService.dateFuture', {
|
||||||
|
defaultValue: 'Engagement and discharge dates cannot be in the future.',
|
||||||
|
})
|
||||||
|
: form.engagementDate &&
|
||||||
|
form.dischargeDate &&
|
||||||
|
form.engagementDate >= form.dischargeDate
|
||||||
|
? t('seaRecords.seaService.dateOrder', {
|
||||||
|
defaultValue: 'Discharge date must be after the engagement date.',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const valid =
|
const valid =
|
||||||
form.vesselName.trim().length > 1 &&
|
form.vesselName.trim().length > 1 &&
|
||||||
form.rank.trim().length > 1 &&
|
form.rank.trim().length > 1 &&
|
||||||
form.engagementDate &&
|
form.engagementDate &&
|
||||||
form.dischargeDate &&
|
form.dischargeDate &&
|
||||||
form.engagementDate < form.dischargeDate;
|
!dateError;
|
||||||
|
|
||||||
// Shown under the date pickers as they are filled: the seafarer sees what
|
// Shown under the date pickers as they are filled: the seafarer sees what
|
||||||
// the engagement is worth before saving it.
|
// the engagement is worth before saving it.
|
||||||
@@ -408,6 +430,7 @@ function SeaServiceTab() {
|
|||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setForm({ ...form, engagementDate: val })
|
setForm({ ...form, engagementDate: val })
|
||||||
}
|
}
|
||||||
|
maxDate={form.dischargeDate || today}
|
||||||
dateFormat="date"
|
dateFormat="date"
|
||||||
/>
|
/>
|
||||||
<AmharicDatePicker
|
<AmharicDatePicker
|
||||||
@@ -417,24 +440,23 @@ function SeaServiceTab() {
|
|||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setForm({ ...form, dischargeDate: val })
|
setForm({ ...form, dischargeDate: val })
|
||||||
}
|
}
|
||||||
|
minDate={form.engagementDate || undefined}
|
||||||
|
maxDate={today}
|
||||||
dateFormat="date"
|
dateFormat="date"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{form.engagementDate && form.dischargeDate && (
|
{(dateError || (form.engagementDate && form.dischargeDate)) && (
|
||||||
<Alert
|
<Alert
|
||||||
variant="light"
|
variant="light"
|
||||||
color={formDays === null ? 'red' : 'teal'}
|
color={dateError ? 'red' : 'teal'}
|
||||||
icon={<IconInfoCircle size={16} />}
|
icon={<IconInfoCircle size={16} />}
|
||||||
py={6}
|
py={6}
|
||||||
>
|
>
|
||||||
{formDays === null
|
{dateError ??
|
||||||
? t('seaRecords.seaService.dateOrder', {
|
t('seaRecords.seaService.daysServed', {
|
||||||
defaultValue: 'Discharge date must be after the engagement date.',
|
days: formDays,
|
||||||
})
|
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
|
||||||
: t('seaRecords.seaService.daysServed', {
|
})}
|
||||||
days: formDays,
|
|
||||||
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
|
|
||||||
})}
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -581,10 +603,12 @@ function MedicalTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const today = todayKey();
|
||||||
const valid =
|
const valid =
|
||||||
form.issuerName.trim().length > 1 &&
|
form.issuerName.trim().length > 1 &&
|
||||||
form.issueDate &&
|
form.issueDate &&
|
||||||
form.expiryDate &&
|
form.expiryDate &&
|
||||||
|
form.issueDate <= today &&
|
||||||
form.issueDate < form.expiryDate;
|
form.issueDate < form.expiryDate;
|
||||||
|
|
||||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||||
@@ -664,6 +688,7 @@ function MedicalTab() {
|
|||||||
required
|
required
|
||||||
value={form.issueDate}
|
value={form.issueDate}
|
||||||
onChange={(val) => setForm({ ...form, issueDate: val })}
|
onChange={(val) => setForm({ ...form, issueDate: val })}
|
||||||
|
maxDate={today}
|
||||||
dateFormat="date"
|
dateFormat="date"
|
||||||
/>
|
/>
|
||||||
<AmharicDatePicker
|
<AmharicDatePicker
|
||||||
@@ -671,6 +696,7 @@ function MedicalTab() {
|
|||||||
required
|
required
|
||||||
value={form.expiryDate}
|
value={form.expiryDate}
|
||||||
onChange={(val) => setForm({ ...form, expiryDate: val })}
|
onChange={(val) => setForm({ ...form, expiryDate: val })}
|
||||||
|
minDate={form.issueDate || undefined}
|
||||||
dateFormat="date"
|
dateFormat="date"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -45,7 +46,7 @@ import {
|
|||||||
IconX,
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { StatusBadge, notify } from '@ema-platform/ui';
|
||||||
import type { Seafarer } from './SeafarerRegistryPage';
|
import type { Seafarer } from './SeafarerRegistryPage';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -170,14 +171,14 @@ async function updateSeafarerStatus(_id: string, _status: string): Promise<void>
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_TONE: Record<string, StatusTone> = {
|
||||||
Active: 'teal', Pending: 'yellow', Suspended: 'red',
|
Active: 'success', Pending: 'warning', Suspended: 'danger',
|
||||||
Approved: 'teal', Expired: 'red', Valid: 'teal',
|
Approved: 'success', Expired: 'danger', Valid: 'success',
|
||||||
Fit: 'teal', Unfit: 'red', Conditional: 'orange',
|
Fit: 'success', Unfit: 'danger', Conditional: 'pending',
|
||||||
};
|
};
|
||||||
|
|
||||||
function Chip({ value }: { value: string }) {
|
function Chip({ value }: { value: string }) {
|
||||||
return <Badge color={STATUS_COLOR[value] ?? 'gray'} variant="light" radius="sm" size="sm">{value}</Badge>;
|
return <StatusBadge tone={STATUS_TONE[value] ?? 'neutral'} label={value} variant="light" radius="sm" size="sm" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoField({ label, value }: { label: string; value: string }) {
|
function InfoField({ label, value }: { label: string; value: string }) {
|
||||||
@@ -639,7 +640,12 @@ export function SeafarerProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
|
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
|
||||||
<Badge color={STATUS_COLOR[profile.status] ?? 'gray'} variant="filled" radius="sm">{profile.status}</Badge>
|
<StatusBadge
|
||||||
|
tone={STATUS_TONE[profile.status] ?? 'neutral'}
|
||||||
|
label={profile.status}
|
||||||
|
variant="filled"
|
||||||
|
radius="sm"
|
||||||
|
/>
|
||||||
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
|
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
|
||||||
Edit Profile
|
Edit Profile
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
IconX,
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { StatusBadge, notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -163,26 +163,17 @@ function StatCard({
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Status badges
|
// Status badges
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_TONE: Record<string, StatusTone> = {
|
||||||
Active: 'teal',
|
Active: 'success',
|
||||||
Pending: 'yellow',
|
Pending: 'warning',
|
||||||
Suspended: 'red',
|
Suspended: 'danger',
|
||||||
Expired: 'orange',
|
Expired: 'pending',
|
||||||
Fit: 'teal',
|
Fit: 'success',
|
||||||
Unfit: 'red',
|
Unfit: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatusBadge({ value }: { value: string }) {
|
function RegistryStatus({ value }: { value: string }) {
|
||||||
return (
|
return <StatusBadge tone={STATUS_TONE[value] ?? 'neutral'} label={value} />;
|
||||||
<Badge
|
|
||||||
color={STATUS_COLOR[value] ?? 'gray'}
|
|
||||||
variant="light"
|
|
||||||
radius="sm"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -239,9 +230,9 @@ export function SeafarerRegistryPage() {
|
|||||||
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
|
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
|
||||||
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
|
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
|
||||||
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
|
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
|
||||||
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
|
<Table.Td><RegistryStatus value={s.medicalStatus} /></Table.Td>
|
||||||
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
|
<Table.Td><RegistryStatus value={s.bookStatus} /></Table.Td>
|
||||||
<Table.Td><StatusBadge value={s.status} /></Table.Td>
|
<Table.Td><RegistryStatus value={s.status} /></Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
|
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
IconTransferIn,
|
IconTransferIn,
|
||||||
IconUser,
|
IconUser,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notify, PhoneInput } from '@ema-platform/ui';
|
import { StatusBadge, notify, PhoneInput } from '@ema-platform/ui';
|
||||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
|
|
||||||
// Minimal vessel type for the approved vessel list
|
// Minimal vessel type for the approved vessel list
|
||||||
@@ -117,11 +117,11 @@ const TRANSFER_REASONS = [
|
|||||||
'Other',
|
'Other',
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_TONE: Record<string, StatusTone> = {
|
||||||
Pending: 'gray',
|
Pending: 'neutral',
|
||||||
'Under Review': 'yellow',
|
'Under Review': 'warning',
|
||||||
Approved: 'teal',
|
Approved: 'success',
|
||||||
Rejected: 'red',
|
Rejected: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -140,7 +140,11 @@ function TransferCard({ req }: { req: OwnershipTransferRequest }) {
|
|||||||
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
|
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
|
<StatusBadge
|
||||||
|
tone={STATUS_TONE[req.status] ?? 'neutral'}
|
||||||
|
label={req.status}
|
||||||
|
variant="light"
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Divider my="xs" />
|
<Divider my="xs" />
|
||||||
<SimpleGrid cols={2} spacing="xs">
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
IconTransferIn,
|
IconTransferIn,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { AdvancedTable } from '@ema-platform/ui';
|
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
|
||||||
import { inFlightColumns } from '../inFlightColumns';
|
import { inFlightColumns } from '../inFlightColumns';
|
||||||
import {
|
import {
|
||||||
TERMINAL_STATUSES,
|
TERMINAL_STATUSES,
|
||||||
@@ -68,12 +68,12 @@ interface VesselRegistration {
|
|||||||
expiryDate: string | null;
|
expiryDate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_TONE: Record<string, StatusTone> = {
|
||||||
Pending: 'gray',
|
Pending: 'neutral',
|
||||||
'Under Review': 'yellow',
|
'Under Review': 'warning',
|
||||||
Approved: 'teal',
|
Approved: 'success',
|
||||||
Rejected: 'red',
|
Rejected: 'danger',
|
||||||
'Correction Required': 'orange',
|
'Correction Required': 'pending',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inland vessel certificates (1)
|
// Inland vessel certificates (1)
|
||||||
@@ -281,9 +281,12 @@ export function VesselRegistrationPage() {
|
|||||||
<Text fz="xs" c="dimmed">{registration.id}</Text>
|
<Text fz="xs" c="dimmed">{registration.id}</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Badge color={STATUS_COLOR[registration.status] ?? 'gray'} size="lg" variant="light">
|
<StatusBadge
|
||||||
{registration.status}
|
tone={STATUS_TONE[registration.status] ?? 'neutral'}
|
||||||
</Badge>
|
label={registration.status}
|
||||||
|
size="lg"
|
||||||
|
variant="light"
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { StatusBadge } from '@ema-platform/ui';
|
||||||
|
import { type StatusTone } from '@ema-platform/shared';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -30,10 +32,10 @@ import {
|
|||||||
|
|
||||||
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
|
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
|
||||||
|
|
||||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
|
||||||
REGISTERED: 'green',
|
REGISTERED: 'success',
|
||||||
SUSPENDED: 'orange',
|
SUSPENDED: 'pending',
|
||||||
DEREGISTERED: 'gray',
|
DEREGISTERED: 'neutral',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,12 +196,11 @@ export function VesselTransferPage() {
|
|||||||
{categoryLabels[vessel.category] ?? vessel.category}
|
{categoryLabels[vessel.category] ?? vessel.category}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<StatusBadge
|
||||||
|
tone={VESSEL_STATUS_TONES[vessel.status]}
|
||||||
|
label={vessel.status}
|
||||||
size="sm"
|
size="sm"
|
||||||
color={VESSEL_STATUS_COLORS[vessel.status]}
|
/>
|
||||||
>
|
|
||||||
{vessel.status}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const am: Translations = {
|
|||||||
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
a11y: {
|
||||||
|
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
|
||||||
|
},
|
||||||
|
|
||||||
msg: {
|
msg: {
|
||||||
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
||||||
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
||||||
@@ -969,6 +973,11 @@ export const am: Translations = {
|
|||||||
appeal: 'ይግባኝ',
|
appeal: 'ይግባኝ',
|
||||||
retake: 'ድጋሚ · {{n}}',
|
retake: 'ድጋሚ · {{n}}',
|
||||||
firstSitting: 'የመጀመሪያ ሙከራ',
|
firstSitting: 'የመጀመሪያ ሙከራ',
|
||||||
|
exam: 'ፈተና',
|
||||||
|
completed: 'ተጠናቋል',
|
||||||
|
timeExpired: 'ጊዜው አልቋል',
|
||||||
|
resumeExam: 'ፈተና ይቀጥሉ',
|
||||||
|
takeExam: 'ፈተና ይውሰዱ',
|
||||||
attendanceStatus: {
|
attendanceStatus: {
|
||||||
REGISTERED: 'አልተጠራም',
|
REGISTERED: 'አልተጠራም',
|
||||||
PRESENT: 'ተገኝቷል',
|
PRESENT: 'ተገኝቷል',
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export const en = {
|
|||||||
tagline: 'Maritime licensing & certification services',
|
tagline: 'Maritime licensing & certification services',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Strings only assistive technology encounters.
|
||||||
|
a11y: {
|
||||||
|
skipToContent: 'Skip to main content',
|
||||||
|
},
|
||||||
|
|
||||||
msg: {
|
msg: {
|
||||||
genericError: 'Something went wrong. Please try again.',
|
genericError: 'Something went wrong. Please try again.',
|
||||||
serverError: 'Server error. Please try again later.',
|
serverError: 'Server error. Please try again later.',
|
||||||
@@ -970,6 +975,11 @@ export const en = {
|
|||||||
appeal: 'Appeal',
|
appeal: 'Appeal',
|
||||||
retake: 'Retake · {{n}}',
|
retake: 'Retake · {{n}}',
|
||||||
firstSitting: 'First sitting',
|
firstSitting: 'First sitting',
|
||||||
|
exam: 'Exam',
|
||||||
|
completed: 'Completed',
|
||||||
|
timeExpired: 'Time expired',
|
||||||
|
resumeExam: 'Resume exam',
|
||||||
|
takeExam: 'Take exam',
|
||||||
attendanceStatus: {
|
attendanceStatus: {
|
||||||
REGISTERED: 'Not called',
|
REGISTERED: 'Not called',
|
||||||
PRESENT: 'Present',
|
PRESENT: 'Present',
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
AppHeader,
|
AppHeader,
|
||||||
AppSidebar,
|
AppSidebar,
|
||||||
filterByPermissions,
|
filterByPermissions,
|
||||||
|
SkipLink,
|
||||||
|
MAIN_CONTENT_ID,
|
||||||
} from "@ema-platform/ui";
|
} from "@ema-platform/ui";
|
||||||
import type { NavItem } from "@ema-platform/ui";
|
import type { NavItem } from "@ema-platform/ui";
|
||||||
import {
|
import {
|
||||||
@@ -281,6 +283,10 @@ export function PortalLayout() {
|
|||||||
: "?";
|
: "?";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* First focusable element on the page, so a keyboard user can bypass
|
||||||
|
the nav instead of tabbing through it on every navigation. */}
|
||||||
|
<SkipLink />
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 74 }}
|
header={{ height: 74 }}
|
||||||
navbar={{
|
navbar={{
|
||||||
@@ -335,7 +341,7 @@ export function PortalLayout() {
|
|||||||
/>
|
/>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|
||||||
<AppShell.Main>
|
<AppShell.Main id={MAIN_CONTENT_ID}>
|
||||||
<div key={location.pathname} className="ema-page-enter">
|
<div key={location.pathname} className="ema-page-enter">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -364,5 +370,6 @@ export function PortalLayout() {
|
|||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||||
|
import { ThemeGallery } from "@ema-platform/ui";
|
||||||
import { PortalLayout } from "./layouts/PortalLayout";
|
import { PortalLayout } from "./layouts/PortalLayout";
|
||||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
import { LandingRoute } from "./components/LandingRoute";
|
import { LandingRoute } from "./components/LandingRoute";
|
||||||
@@ -29,6 +30,7 @@ import { SupportPage } from "./features/support/pages/SupportPage";
|
|||||||
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
|
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
|
||||||
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
||||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||||
|
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
||||||
|
|
||||||
// Phase 1 pages
|
// Phase 1 pages
|
||||||
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||||
@@ -57,6 +59,10 @@ export const router = createBrowserRouter([
|
|||||||
// Public landing page — institutional overview + role-based entry points.
|
// Public landing page — institutional overview + role-based entry points.
|
||||||
{ path: "/", element: <LandingRoute /> },
|
{ path: "/", element: <LandingRoute /> },
|
||||||
|
|
||||||
|
// Theme visual-regression surface. Unauthenticated by design — it renders
|
||||||
|
// only static primitives, so it needs no API and cannot flake.
|
||||||
|
{ path: "/__gallery", element: <ThemeGallery /> },
|
||||||
|
|
||||||
// Public auth pages
|
// Public auth pages
|
||||||
{ path: "/login", element: <LoginPage /> },
|
{ path: "/login", element: <LoginPage /> },
|
||||||
{ path: "/signup", element: <SignupPage /> },
|
{ path: "/signup", element: <SignupPage /> },
|
||||||
@@ -208,6 +214,14 @@ export const router = createBrowserRouter([
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/exams/:examId/take",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.APPLY_EXAM, P.VIEW_OWN_EXAM]}>
|
||||||
|
<ExamAttemptPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
// The public-facing registry was a hardcoded mock and does not belong in
|
// The public-facing registry was a hardcoded mock and does not belong in
|
||||||
// the applicant portal; officers browse seafarers in the backoffice.
|
// the applicant portal; officers browse seafarers in the backoffice.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,57 @@
|
|||||||
/* Portal global styles — loaded after Mantine's CSS, no Tailwind preflight so
|
/* Portal global styles — loaded after Mantine's CSS, no Tailwind preflight so
|
||||||
it never fights Mantine's base styles. */
|
it never fights Mantine's base styles. */
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
|
||||||
|
Without it every Amharic string in the app renders in whatever the OS
|
||||||
|
happens to substitute — different on Windows, macOS and Android. */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--ema-surface-light: #f5f8fc;
|
--ema-surface-light: #f5f8fc;
|
||||||
--ema-surface-dark: #0e1521;
|
--ema-surface-dark: #0e1521;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
|
||||||
|
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
|
||||||
|
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
|
||||||
|
come from Mantine's dark palette so they track the active color scheme
|
||||||
|
instead of a fixed gray.
|
||||||
|
|
||||||
|
These lived in `apps/portal/src/styles.css`, which nothing ever imported —
|
||||||
|
so the portal has been running with unthemed scrollbars while the backoffice
|
||||||
|
had these. Moved here, where they load.
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--mantine-color-gray-5) transparent;
|
||||||
|
}
|
||||||
|
[data-mantine-color-scheme='dark'] * {
|
||||||
|
scrollbar-color: var(--mantine-color-dark-3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--mantine-color-gray-5);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--mantine-color-gray-6);
|
||||||
|
}
|
||||||
|
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--mantine-color-dark-3);
|
||||||
|
}
|
||||||
|
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--mantine-color-dark-2);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
|||||||
@@ -1,116 +1,13 @@
|
|||||||
import {
|
/**
|
||||||
createTheme,
|
* The portal theme now lives in `@ema-platform/shared`, alongside the
|
||||||
rem,
|
* backoffice theme and the base they share.
|
||||||
type MantineColorsTuple,
|
*
|
||||||
} from '@mantine/core';
|
* It moved because the two themes had diverged into unrelated definitions —
|
||||||
|
* this one carried a full type scale, radius scale and component defaults that
|
||||||
// ---- Coastal Modern palette ----------------------------------------------
|
* the backoffice simply lacked. Sharing the structure fixes the backoffice
|
||||||
// Portal-only theme. Lives here (not in @ema-platform/shared) so the backoffice
|
* without changing the portal.
|
||||||
// is unaffected.
|
*
|
||||||
|
* This re-export is kept so the portal's MantineThemeProvider import stays
|
||||||
const emaPrimary: MantineColorsTuple = [
|
* valid. Prefer importing from `@ema-platform/shared` directly in new code.
|
||||||
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
|
*/
|
||||||
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
|
export { portalTheme } from '@ema-platform/shared';
|
||||||
];
|
|
||||||
|
|
||||||
// Teal accent — the "coastal" half of the palette.
|
|
||||||
const emaTeal: MantineColorsTuple = [
|
|
||||||
'#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
|
|
||||||
'#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Cool neutral grays (slightly blue-tinted) for surfaces & text.
|
|
||||||
const emaGray: MantineColorsTuple = [
|
|
||||||
'#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
|
|
||||||
'#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const portalTheme = createTheme({
|
|
||||||
primaryColor: 'emaPrimary',
|
|
||||||
primaryShade: { light: 6, dark: 5 },
|
|
||||||
colors: {
|
|
||||||
emaPrimary,
|
|
||||||
emaTeal,
|
|
||||||
gray: emaGray,
|
|
||||||
},
|
|
||||||
fontFamily:
|
|
||||||
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
||||||
headings: {
|
|
||||||
fontFamily: 'Inter, sans-serif',
|
|
||||||
fontWeight: '700',
|
|
||||||
sizes: {
|
|
||||||
h1: { fontSize: rem(32), lineHeight: '1.25' },
|
|
||||||
h2: { fontSize: rem(25), lineHeight: '1.3' },
|
|
||||||
h3: { fontSize: rem(21), lineHeight: '1.35' },
|
|
||||||
h4: { fontSize: rem(17), lineHeight: '1.4' },
|
|
||||||
h5: { fontSize: rem(15), lineHeight: '1.45' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultRadius: 'md',
|
|
||||||
radius: {
|
|
||||||
xs: rem(6),
|
|
||||||
sm: rem(8),
|
|
||||||
md: rem(12),
|
|
||||||
lg: rem(16),
|
|
||||||
xl: rem(22),
|
|
||||||
},
|
|
||||||
shadows: {
|
|
||||||
xs: '0 1px 2px rgba(15,23,42,0.06)',
|
|
||||||
sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
|
|
||||||
md: '0 8px 24px rgba(15,23,42,0.08)',
|
|
||||||
lg: '0 16px 40px rgba(15,23,42,0.12)',
|
|
||||||
xl: '0 24px 64px rgba(15,23,42,0.16)',
|
|
||||||
},
|
|
||||||
breakpoints: {
|
|
||||||
xs: '36em',
|
|
||||||
sm: '48em',
|
|
||||||
md: '62em',
|
|
||||||
lg: '75em',
|
|
||||||
xl: '88em',
|
|
||||||
},
|
|
||||||
cursorType: 'pointer',
|
|
||||||
components: {
|
|
||||||
Paper: {
|
|
||||||
defaultProps: { radius: 'lg' },
|
|
||||||
},
|
|
||||||
Card: {
|
|
||||||
defaultProps: { radius: 'lg' },
|
|
||||||
},
|
|
||||||
Button: {
|
|
||||||
defaultProps: { radius: 'md' },
|
|
||||||
styles: { root: { fontWeight: 600 } },
|
|
||||||
},
|
|
||||||
Badge: {
|
|
||||||
defaultProps: { radius: 'sm' },
|
|
||||||
},
|
|
||||||
ThemeIcon: {
|
|
||||||
defaultProps: { radius: 'md' },
|
|
||||||
},
|
|
||||||
NavLink: {
|
|
||||||
styles: { root: { borderRadius: rem(10), fontWeight: 500 } },
|
|
||||||
},
|
|
||||||
TextInput: { defaultProps: { radius: 'md' } },
|
|
||||||
Textarea: { defaultProps: { radius: 'md' } },
|
|
||||||
Select: { defaultProps: { radius: 'md' } },
|
|
||||||
PasswordInput: { defaultProps: { radius: 'md' } },
|
|
||||||
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
|
|
||||||
// max-height it's handed unless scrollAreaComponent is set, so a modal
|
|
||||||
// taller than the viewport just gets clipped with no way to scroll it.
|
|
||||||
// Making the body the scrollport here fixes every Modal/Drawer at once.
|
|
||||||
Modal: {
|
|
||||||
styles: {
|
|
||||||
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
|
|
||||||
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Drawer: {
|
|
||||||
styles: {
|
|
||||||
content: { display: 'flex', flexDirection: 'column' },
|
|
||||||
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
other: {
|
|
||||||
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
|
// After Mantine's CSS (it defines the variables these tokens resolve to),
|
||||||
|
// before the app's own, which may override them. Relative because the
|
||||||
|
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
|
||||||
|
import '../../../libs/shared/src/lib/theme/semantic.css';
|
||||||
import './app/theme/portal.css';
|
import './app/theme/portal.css';
|
||||||
|
|
||||||
import './app/i18n/config';
|
import './app/i18n/config';
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||