Merge branch 'ui_ux' of github.com:Tria-plc/emaui into WorkflowChange
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/
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {Stack, Button, Modal, Text, TextInput, Textarea, 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,
|
||||||
@@ -120,7 +109,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 +124,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
|
||||||
|
|||||||
@@ -46,6 +46,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 +379,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +58,13 @@ 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" };
|
||||||
@@ -290,7 +291,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 +314,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,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,
|
||||||
@@ -35,6 +33,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,
|
||||||
@@ -416,13 +415,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}`)),
|
||||||
@@ -443,26 +436,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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -724,7 +724,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}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -375,22 +356,24 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
|||||||
|
|
||||||
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}
|
{statusFilter}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -249,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>
|
||||||
|
|||||||
@@ -1,23 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {Stack, Group, Button, Badge, Modal, Text, TextInput, Select, NumberInput, Textarea} from '@mantine/core';
|
||||||
Stack,
|
|
||||||
Title,
|
|
||||||
Group,
|
|
||||||
Button,
|
|
||||||
Badge,
|
|
||||||
Modal,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Card,
|
|
||||||
Alert,
|
|
||||||
Select,
|
|
||||||
NumberInput,
|
|
||||||
Textarea,
|
|
||||||
} 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 { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
import { AdvancedColumn, AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } 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 { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
||||||
@@ -202,7 +188,7 @@ 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 }),
|
||||||
@@ -217,16 +203,19 @@ export function QuestionPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<PageHeader
|
||||||
<Title order={2}>{t('question.title')}</Title>
|
title={t('question.title')}
|
||||||
{!showForm && (
|
noMargin
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
action={
|
||||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
!showForm && (
|
||||||
{t('question.addQuestion')}
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||||
</Button>
|
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||||
</RequirePermission>
|
{t('question.addQuestion')}
|
||||||
)}
|
</Button>
|
||||||
</Group>
|
</RequirePermission>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<QuestionForm
|
<QuestionForm
|
||||||
@@ -238,9 +227,12 @@ export function QuestionPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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]}
|
||||||
@@ -250,12 +242,8 @@ export function QuestionPage() {
|
|||||||
style={{ width: 280 }}
|
style={{ width: 280 }}
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</Group>
|
}
|
||||||
<AdvancedTable
|
itemCount={page.itemCount}
|
||||||
columns={columns}
|
|
||||||
data={page.rows}
|
|
||||||
tableName={t('question.title')}
|
|
||||||
itemCount={page.itemCount}
|
|
||||||
pageIndex={page.pageIndex}
|
pageIndex={page.pageIndex}
|
||||||
onPageChange={setPageIndex}
|
onPageChange={setPageIndex}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -264,7 +252,6 @@ export function QuestionPage() {
|
|||||||
isLoading={isFetching}
|
isLoading={isFetching}
|
||||||
emptyText={t('question.noQuestions')}
|
emptyText={t('question.noQuestions')}
|
||||||
/>
|
/>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(reviewTarget)}
|
opened={Boolean(reviewTarget)}
|
||||||
|
|||||||
@@ -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,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({
|
||||||
@@ -280,7 +249,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),
|
||||||
@@ -295,12 +264,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"
|
||||||
@@ -317,8 +286,9 @@ export function ResultPage() {
|
|||||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||||
{t('result.record')}
|
{t('result.record')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
|
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
|
||||||
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
|
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
|
||||||
@@ -327,10 +297,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 +321,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 +388,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>
|
||||||
|
|||||||
@@ -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,21 +1,6 @@
|
|||||||
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, Badge, Button, Center, Container, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||||
@@ -29,7 +14,7 @@ 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';
|
||||||
|
|
||||||
@@ -123,12 +108,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 +123,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 +158,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">
|
||||||
@@ -189,7 +174,7 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
<Text fw={600} size="sm" mb={4}>
|
<Text fw={600} size="sm" mb={4}>
|
||||||
Seafarer
|
Seafarer
|
||||||
</Text>
|
</Text>
|
||||||
<Table withTableBorder withColumnBorders>
|
<Table variant="vertical">
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
<Row label="Name" value={applicant?.name} />
|
<Row label="Name" value={applicant?.name} />
|
||||||
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
||||||
@@ -212,7 +197,7 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
<Text fw={600} size="sm" mb={4}>
|
<Text fw={600} size="sm" mb={4}>
|
||||||
Payment
|
Payment
|
||||||
</Text>
|
</Text>
|
||||||
<Table withTableBorder withColumnBorders>
|
<Table variant="vertical">
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
<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="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||||
@@ -226,7 +211,7 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
<Text fw={600} size="sm" mb={4}>
|
<Text fw={600} size="sm" mb={4}>
|
||||||
Issuance
|
Issuance
|
||||||
</Text>
|
</Text>
|
||||||
<Table withTableBorder withColumnBorders>
|
<Table variant="vertical">
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||||
<Row label="Document №" value={document.documentNumber} />
|
<Row label="Document №" value={document.documentNumber} />
|
||||||
|
|||||||
@@ -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, 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,
|
||||||
@@ -59,7 +42,7 @@ 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
|
||||||
@@ -83,7 +66,7 @@ function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boo
|
|||||||
<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">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">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">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>
|
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><StatusBadge tone={STATUS_TONE[sf.status]} label={sf.status} variant="light" size="xs" /></Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
<Paper withBorder radius="md" p="md">
|
<Paper withBorder radius="md" p="md">
|
||||||
@@ -169,10 +152,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 +248,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: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -151,6 +152,10 @@ 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
|
||||||
// The top layout drops its nav strip on small screens — the drawer is
|
// 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.
|
// the nav there — so the header shrinks back to a single row with it.
|
||||||
@@ -252,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>
|
||||||
@@ -287,5 +292,6 @@ export function BackofficeLayout() {
|
|||||||
/>
|
/>
|
||||||
</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">
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -57,6 +58,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 /> },
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -107,12 +107,26 @@ export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationSta
|
|||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SEAFARER_REGISTRATION_STATUS_COLORS: Record<SeafarerRegistrationStatus, string> = {
|
/**
|
||||||
DRAFT: 'gray',
|
* Registration status → platform tone.
|
||||||
SUBMITTED: 'blue',
|
*
|
||||||
RESUBMIT_REQUIRED: 'orange',
|
* Tones, not colours. `StatusTone` is the platform's status vocabulary and the
|
||||||
APPROVED: 'teal',
|
* one place a tone becomes a colour (`STATUS_TONE_COLOR` in
|
||||||
REJECTED: 'red',
|
* @ema-platform/shared), so this map cannot drift the way `APPROVED: 'teal'`
|
||||||
|
* had already drifted from every other feature's green success.
|
||||||
|
*
|
||||||
|
* The union is repeated rather than imported because @ema-platform/api does not
|
||||||
|
* depend on the theme layer, and should not start to for five string literals.
|
||||||
|
*/
|
||||||
|
export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
|
||||||
|
SeafarerRegistrationStatus,
|
||||||
|
'success' | 'warning' | 'danger' | 'info' | 'pending' | 'neutral'
|
||||||
|
> = {
|
||||||
|
DRAFT: 'neutral',
|
||||||
|
SUBMITTED: 'info',
|
||||||
|
RESUBMIT_REQUIRED: 'pending',
|
||||||
|
APPROVED: 'success',
|
||||||
|
REJECTED: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Human label for each answer — the review table and the summary both use it. */
|
/** Human label for each answer — the review table and the summary both use it. */
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
export * from './lib/theme/palettes';
|
||||||
|
export * from './lib/theme/base-theme';
|
||||||
export * from './lib/theme/ema-theme';
|
export * from './lib/theme/ema-theme';
|
||||||
|
export * from './lib/theme/portal-theme';
|
||||||
|
export * from './lib/theme/status-tone';
|
||||||
export * from './lib/date/date-displayer';
|
export * from './lib/date/date-displayer';
|
||||||
export * from './lib/date/use-date-displayer';
|
export * from './lib/date/use-date-displayer';
|
||||||
export * from './lib/date/ethiopic';
|
export * from './lib/date/ethiopic';
|
||||||
|
|||||||
156
libs/shared/src/lib/theme/base-theme.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { createTheme, rem } from '@mantine/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything both apps agree on: scale, shape, elevation, and component
|
||||||
|
* defaults. No colours — those are the one thing the backoffice and the portal
|
||||||
|
* deliberately differ on, so each theme layers its own ramps over this.
|
||||||
|
*
|
||||||
|
* This began as the portal's theme. The backoffice had no heading scale, no
|
||||||
|
* radius scale and no component defaults at all, which is why its features
|
||||||
|
* drifted: with nothing to inherit, every page invented its own spacing and
|
||||||
|
* sizing. Promoting the portal's structure here fixes 23 features by editing
|
||||||
|
* one file, and costs the portal nothing — the values are unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type stack.
|
||||||
|
*
|
||||||
|
* Noto Sans Ethiopic sits directly after Inter rather than being swapped in by
|
||||||
|
* a `[lang='am']` rule. Browsers fall back per *glyph*, not per element, so one
|
||||||
|
* stack renders Latin in Inter and Ge'ez in Noto automatically — including
|
||||||
|
* inside a single string. That matters here: a registry is full of mixed-script
|
||||||
|
* lines like an Amharic name beside a Latin IMO number, and a language-scoped
|
||||||
|
* swap would render one half of those in the wrong face.
|
||||||
|
*
|
||||||
|
* Inter carries no Ge'ez glyphs at all, so before this the Amharic half of a
|
||||||
|
* bilingual system rendered in whatever the OS happened to substitute.
|
||||||
|
*/
|
||||||
|
export const EMA_FONT_STACK =
|
||||||
|
'Inter, "Noto Sans Ethiopic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||||
|
|
||||||
|
export const baseTheme = createTheme({
|
||||||
|
fontFamily: EMA_FONT_STACK,
|
||||||
|
|
||||||
|
headings: {
|
||||||
|
fontFamily: EMA_FONT_STACK,
|
||||||
|
fontWeight: '700',
|
||||||
|
// A little looser than the portal's original values. Ge'ez has taller
|
||||||
|
// ascenders and deeper descenders than Latin, so a heading set to Inter's
|
||||||
|
// natural leading clips its Amharic rendering — which only became visible
|
||||||
|
// once Ethiopic was actually being rendered rather than substituted.
|
||||||
|
sizes: {
|
||||||
|
h1: { fontSize: rem(32), lineHeight: '1.3' },
|
||||||
|
h2: { fontSize: rem(25), lineHeight: '1.35' },
|
||||||
|
h3: { fontSize: rem(21), lineHeight: '1.4' },
|
||||||
|
h4: { fontSize: rem(17), lineHeight: '1.45' },
|
||||||
|
h5: { fontSize: rem(15), lineHeight: '1.5' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultRadius: 'md',
|
||||||
|
radius: {
|
||||||
|
xs: rem(6),
|
||||||
|
sm: rem(8),
|
||||||
|
md: rem(12),
|
||||||
|
lg: rem(16),
|
||||||
|
xl: rem(22),
|
||||||
|
},
|
||||||
|
|
||||||
|
shadows: {
|
||||||
|
xs: '0 1px 2px rgba(15,23,42,0.06)',
|
||||||
|
sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
|
||||||
|
md: '0 8px 24px rgba(15,23,42,0.08)',
|
||||||
|
lg: '0 16px 40px rgba(15,23,42,0.12)',
|
||||||
|
xl: '0 24px 64px rgba(15,23,42,0.16)',
|
||||||
|
},
|
||||||
|
|
||||||
|
breakpoints: {
|
||||||
|
xs: '36em',
|
||||||
|
sm: '48em',
|
||||||
|
md: '62em',
|
||||||
|
lg: '75em',
|
||||||
|
xl: '88em',
|
||||||
|
},
|
||||||
|
|
||||||
|
cursorType: 'pointer',
|
||||||
|
|
||||||
|
// Draw a focus ring for keyboard users only. The codebase had no
|
||||||
|
// `:focus-visible` handling anywhere, which is the single largest WCAG gap.
|
||||||
|
focusRing: 'auto',
|
||||||
|
|
||||||
|
// Shade 6 in light. The dark counterpart is deliberately left unset until
|
||||||
|
// dark mode is verified end to end — changing it moves every filled control.
|
||||||
|
primaryShade: { light: 6 },
|
||||||
|
|
||||||
|
components: {
|
||||||
|
Paper: { defaultProps: { radius: 'lg' } },
|
||||||
|
Card: { defaultProps: { radius: 'lg' } },
|
||||||
|
Button: {
|
||||||
|
defaultProps: { radius: 'md' },
|
||||||
|
styles: { root: { fontWeight: 600 } },
|
||||||
|
},
|
||||||
|
Badge: { defaultProps: { radius: 'sm' } },
|
||||||
|
ThemeIcon: { defaultProps: { radius: 'md' } },
|
||||||
|
NavLink: { styles: { root: { borderRadius: rem(10), fontWeight: 500 } } },
|
||||||
|
TextInput: { defaultProps: { radius: 'md' } },
|
||||||
|
Textarea: { defaultProps: { radius: 'md' } },
|
||||||
|
Select: { defaultProps: { radius: 'md' } },
|
||||||
|
PasswordInput: { defaultProps: { radius: 'md' } },
|
||||||
|
|
||||||
|
// The page sits on a tinted surface and cards float on white. Without this
|
||||||
|
// the main area is the same white as every Paper on it, and the card
|
||||||
|
// borders are the only thing separating content from chrome.
|
||||||
|
AppShell: {
|
||||||
|
styles: { main: { background: 'var(--ema-surface-page)' } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tables — the registry look: quiet uppercase headers, hairline row
|
||||||
|
// borders, a tint on hover, no zebra striping and no column rules. Set
|
||||||
|
// once here so the 14 pages rendering a raw <Table> match the 28 that go
|
||||||
|
// through AdvancedTable instead of each picking their own density.
|
||||||
|
//
|
||||||
|
// Header text is `text-secondary` rather than the lighter dimmed gray the
|
||||||
|
// mockup used: at 11px uppercase, gray-5 on white fails 4.5:1.
|
||||||
|
Table: {
|
||||||
|
defaultProps: { highlightOnHover: true, verticalSpacing: 'sm', horizontalSpacing: 'md' },
|
||||||
|
styles: {
|
||||||
|
table: {
|
||||||
|
'--table-border-color': 'var(--ema-border-subtle)',
|
||||||
|
'--table-hover-color': 'var(--ema-surface-page)',
|
||||||
|
'--table-striped-color': 'var(--ema-surface-sunken)',
|
||||||
|
} as CSSProperties,
|
||||||
|
th: {
|
||||||
|
fontSize: rem(11),
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
color: 'var(--ema-text-secondary)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
},
|
||||||
|
td: { fontSize: rem(13) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
|
||||||
|
// max-height it's handed unless scrollAreaComponent is set, so a modal
|
||||||
|
// taller than the viewport just gets clipped with no way to scroll it.
|
||||||
|
// Making the body the scrollport here fixes every Modal/Drawer at once.
|
||||||
|
Modal: {
|
||||||
|
styles: {
|
||||||
|
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
|
||||||
|
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Drawer: {
|
||||||
|
styles: {
|
||||||
|
content: { display: 'flex', flexDirection: 'column' },
|
||||||
|
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
other: {
|
||||||
|
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,55 +1,32 @@
|
|||||||
import { createTheme, type MantineColorsTuple } from '@mantine/core';
|
import { createTheme, mergeThemeOverrides } from '@mantine/core';
|
||||||
|
import { baseTheme } from './base-theme';
|
||||||
|
import { emaBlue, emaSecondary } from './palettes';
|
||||||
|
|
||||||
const emaPrimary: MantineColorsTuple = [
|
/**
|
||||||
'#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa',
|
* Backoffice theme.
|
||||||
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
|
*
|
||||||
];
|
* Structure, scale and component defaults come from `baseTheme`; this file
|
||||||
|
* contributes only the brand. The backoffice keeps its own blue rather than
|
||||||
const emaSecondary: MantineColorsTuple = [
|
* adopting the portal's: shade 8 (#1e40af) is the higher-contrast choice for a
|
||||||
'#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0',
|
* tool staff read all day, and a distinct accent tells an officer at a glance
|
||||||
'#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a',
|
* which of the two systems they are looking at — worth having when both share
|
||||||
];
|
* a domain vocabulary but not the same authority.
|
||||||
|
*
|
||||||
export const emaTheme = createTheme({
|
* The export name is load-bearing: `libs/shared/src/index.ts` and the
|
||||||
primaryColor: 'emaPrimary',
|
* backoffice's MantineThemeProvider both import `emaTheme` by name.
|
||||||
colors: {
|
*
|
||||||
emaPrimary,
|
* Note this theme does NOT override `colors.gray`. The portal's blue-tinted
|
||||||
emaSecondary,
|
* neutrals shift every dimmed label, neutral badge and table border, so that
|
||||||
},
|
* change is being made one app at a time rather than as a side effect of
|
||||||
fontFamily: 'Inter, sans-serif',
|
* sharing a base.
|
||||||
defaultRadius: 'md',
|
*/
|
||||||
breakpoints: {
|
export const emaTheme = mergeThemeOverrides(
|
||||||
xs: '36em',
|
baseTheme,
|
||||||
sm: '48em',
|
createTheme({
|
||||||
md: '62em',
|
primaryColor: 'emaPrimary',
|
||||||
lg: '75em',
|
colors: {
|
||||||
xl: '88em',
|
emaPrimary: emaBlue,
|
||||||
},
|
emaSecondary,
|
||||||
shadows: {
|
|
||||||
xs: '0 1px 3px rgba(0,0,0,0.05)',
|
|
||||||
sm: '0 1px 5px rgba(0,0,0,0.07)',
|
|
||||||
md: '0 4px 20px rgba(15,23,42,0.08)',
|
|
||||||
lg: '0 8px 30px rgba(15,23,42,0.12)',
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
|
|
||||||
// max-height it's handed unless scrollAreaComponent is set, so a modal
|
|
||||||
// taller than the viewport just gets clipped with no way to scroll it.
|
|
||||||
// Making the body the scrollport here fixes every Modal/Drawer at once.
|
|
||||||
Modal: {
|
|
||||||
styles: {
|
|
||||||
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
|
|
||||||
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Drawer: {
|
}),
|
||||||
styles: {
|
);
|
||||||
content: { display: 'flex', flexDirection: 'column' },
|
|
||||||
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
other: {
|
|
||||||
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
66
libs/shared/src/lib/theme/palettes.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { MantineColorsTuple } from '@mantine/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every colour ramp the platform uses, in one place.
|
||||||
|
*
|
||||||
|
* Themes compose these; they do not define colours inline. Keeping the ramps
|
||||||
|
* separate from the themes is what lets the backoffice and the portal share a
|
||||||
|
* structure while keeping distinct brands — and it gives the hardcoded hexes
|
||||||
|
* scattered through feature code somewhere legitimate to be migrated to.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice brand. Shade 8 (#1e40af) is the accessible-government blue; the
|
||||||
|
* ramp is deliberately more saturated than the portal's because staff tools
|
||||||
|
* are read all day under worse conditions than a citizen portal.
|
||||||
|
*/
|
||||||
|
export const emaBlue: MantineColorsTuple = [
|
||||||
|
'#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa',
|
||||||
|
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Backoffice secondary — a warm brown, used sparingly for accents. */
|
||||||
|
export const emaSecondary: MantineColorsTuple = [
|
||||||
|
'#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0',
|
||||||
|
'#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Portal brand — the "Coastal Modern" blue. Softer than the backoffice ramp. */
|
||||||
|
export const emaCoastalBlue: MantineColorsTuple = [
|
||||||
|
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
|
||||||
|
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Portal accent — the "coastal" half of the palette. */
|
||||||
|
export const emaTeal: MantineColorsTuple = [
|
||||||
|
'#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
|
||||||
|
'#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cool, slightly blue-tinted neutrals for surfaces and text.
|
||||||
|
*
|
||||||
|
* Overriding Mantine's stock `gray` with this shifts every `c="dimmed"`, every
|
||||||
|
* neutral badge and every table border in whichever app adopts it — so it is
|
||||||
|
* applied per-theme rather than in the base, and promoted one app at a time.
|
||||||
|
*/
|
||||||
|
export const emaGray: MantineColorsTuple = [
|
||||||
|
'#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
|
||||||
|
'#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ethiopian flag colours.
|
||||||
|
*
|
||||||
|
* These are intentional brand, not drift — they appear in the boot splashes,
|
||||||
|
* the maritime loader and the landing page. They live here so those usages can
|
||||||
|
* reference a name instead of repeating a hex, but they are deliberately NOT
|
||||||
|
* semantic tokens: `emaFlag.green` means "the flag's green", never "success".
|
||||||
|
*/
|
||||||
|
export const emaFlag = {
|
||||||
|
blue: '#0284C7',
|
||||||
|
yellow: '#FCD116',
|
||||||
|
green: '#078930',
|
||||||
|
gold: '#D4AF37',
|
||||||
|
sky: '#38BDF8',
|
||||||
|
} as const;
|
||||||
29
libs/shared/src/lib/theme/portal-theme.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createTheme, mergeThemeOverrides } from '@mantine/core';
|
||||||
|
import { baseTheme } from './base-theme';
|
||||||
|
import { emaCoastalBlue, emaGray, emaTeal } from './palettes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal theme — "Coastal Modern".
|
||||||
|
*
|
||||||
|
* Structure comes from `baseTheme` (which this theme's own structure was the
|
||||||
|
* source of, so nothing here changes visually). What remains is the brand: a
|
||||||
|
* softer blue than the backoffice, a teal accent, and cool blue-tinted
|
||||||
|
* neutrals in place of Mantine's stock gray.
|
||||||
|
*
|
||||||
|
* The gray override stays portal-only for now. It is the widest-reaching
|
||||||
|
* single line in either theme — it retints every dimmed label, neutral badge
|
||||||
|
* and table border — so the backoffice adopts it as its own reviewed change,
|
||||||
|
* not as a side effect of sharing a base.
|
||||||
|
*/
|
||||||
|
export const portalTheme = mergeThemeOverrides(
|
||||||
|
baseTheme,
|
||||||
|
createTheme({
|
||||||
|
primaryColor: 'emaPrimary',
|
||||||
|
primaryShade: { light: 6, dark: 5 },
|
||||||
|
colors: {
|
||||||
|
emaPrimary: emaCoastalBlue,
|
||||||
|
emaTeal,
|
||||||
|
gray: emaGray,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
178
libs/shared/src/lib/theme/semantic.css
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Semantic tokens.
|
||||||
|
|
||||||
|
These name a *role* — "the page background", "a subtle border", "danger" —
|
||||||
|
rather than a colour. Feature code should reach for these instead of a hex,
|
||||||
|
because a hex cannot follow the colour scheme and a Mantine shade index
|
||||||
|
(`gray.5`) says nothing about why that shade was chosen.
|
||||||
|
|
||||||
|
Every token resolves to a Mantine variable rather than a literal. That is
|
||||||
|
deliberate: Mantine already recomputes its own variables under
|
||||||
|
[data-mantine-color-scheme], so tokens defined in terms of them switch for
|
||||||
|
free and can never drift from the theme. A parallel palette of raw hexes
|
||||||
|
would recreate exactly the problem this layer exists to fix.
|
||||||
|
|
||||||
|
Loaded once per app, after Mantine's CSS.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* --- Surfaces ---------------------------------------------------------- */
|
||||||
|
/* The page itself, a raised card, and a recessed well. */
|
||||||
|
--ema-surface-page: var(--mantine-color-gray-0);
|
||||||
|
--ema-surface-raised: var(--mantine-color-white);
|
||||||
|
--ema-surface-sunken: var(--mantine-color-gray-1);
|
||||||
|
|
||||||
|
/* --- Borders ----------------------------------------------------------- */
|
||||||
|
/* Subtle separates rows; strong outlines an input or a focused container. */
|
||||||
|
--ema-border-subtle: var(--mantine-color-gray-2);
|
||||||
|
--ema-border-strong: var(--mantine-color-gray-4);
|
||||||
|
|
||||||
|
/* --- Text -------------------------------------------------------------- */
|
||||||
|
/* Secondary must stay a *text* colour: it has to clear 4.5:1, not 3:1, so
|
||||||
|
it deliberately sits darker than the gray-5 that reads as "dimmed". */
|
||||||
|
--ema-text-primary: var(--mantine-color-gray-9);
|
||||||
|
--ema-text-secondary: var(--mantine-color-gray-7);
|
||||||
|
--ema-text-disabled: var(--mantine-color-gray-5);
|
||||||
|
|
||||||
|
/* --- Status ------------------------------------------------------------
|
||||||
|
Six tones, which is the entire vocabulary a status needs. Domain statuses
|
||||||
|
map onto these rather than each picking their own colour.
|
||||||
|
|
||||||
|
`-fg` is text on the app background; `-bg` is a tint to sit that text on.
|
||||||
|
Both are needed because a badge and a label have different contrast
|
||||||
|
requirements against the same surface. */
|
||||||
|
--ema-status-success-fg: var(--mantine-color-green-8);
|
||||||
|
--ema-status-success-bg: var(--mantine-color-green-0);
|
||||||
|
--ema-status-warning-fg: var(--mantine-color-yellow-8);
|
||||||
|
--ema-status-warning-bg: var(--mantine-color-yellow-0);
|
||||||
|
--ema-status-danger-fg: var(--mantine-color-red-8);
|
||||||
|
--ema-status-danger-bg: var(--mantine-color-red-0);
|
||||||
|
--ema-status-info-fg: var(--mantine-color-blue-8);
|
||||||
|
--ema-status-info-bg: var(--mantine-color-blue-0);
|
||||||
|
--ema-status-pending-fg: var(--mantine-color-orange-8);
|
||||||
|
--ema-status-pending-bg: var(--mantine-color-orange-0);
|
||||||
|
--ema-status-neutral-fg: var(--mantine-color-gray-7);
|
||||||
|
--ema-status-neutral-bg: var(--mantine-color-gray-1);
|
||||||
|
|
||||||
|
/* --- Focus -------------------------------------------------------------
|
||||||
|
One ring for the whole platform. Sized to stay visible against both a
|
||||||
|
white card and a tinted surface. */
|
||||||
|
--ema-focus-ring: var(--mantine-primary-color-filled);
|
||||||
|
--ema-focus-ring-width: 2px;
|
||||||
|
--ema-focus-ring-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme='dark'] {
|
||||||
|
/* Dark is not light inverted. Surfaces lift with elevation rather than
|
||||||
|
dropping, and text steps down from white rather than up from black. */
|
||||||
|
--ema-surface-page: var(--mantine-color-dark-8);
|
||||||
|
--ema-surface-raised: var(--mantine-color-dark-7);
|
||||||
|
--ema-surface-sunken: var(--mantine-color-dark-9);
|
||||||
|
|
||||||
|
--ema-border-subtle: var(--mantine-color-dark-4);
|
||||||
|
--ema-border-strong: var(--mantine-color-dark-3);
|
||||||
|
|
||||||
|
--ema-text-primary: var(--mantine-color-gray-0);
|
||||||
|
--ema-text-secondary: var(--mantine-color-gray-4);
|
||||||
|
--ema-text-disabled: var(--mantine-color-dark-2);
|
||||||
|
|
||||||
|
/* Saturated mid-shades go muddy on a dark ground; these step lighter so the
|
||||||
|
foreground still clears 4.5:1 and the tint stays distinguishable. */
|
||||||
|
--ema-status-success-fg: var(--mantine-color-green-4);
|
||||||
|
--ema-status-success-bg: var(--mantine-color-green-9);
|
||||||
|
--ema-status-warning-fg: var(--mantine-color-yellow-4);
|
||||||
|
--ema-status-warning-bg: var(--mantine-color-yellow-9);
|
||||||
|
--ema-status-danger-fg: var(--mantine-color-red-4);
|
||||||
|
--ema-status-danger-bg: var(--mantine-color-red-9);
|
||||||
|
--ema-status-info-fg: var(--mantine-color-blue-4);
|
||||||
|
--ema-status-info-bg: var(--mantine-color-blue-9);
|
||||||
|
--ema-status-pending-fg: var(--mantine-color-orange-4);
|
||||||
|
--ema-status-pending-bg: var(--mantine-color-orange-9);
|
||||||
|
--ema-status-neutral-fg: var(--mantine-color-gray-4);
|
||||||
|
--ema-status-neutral-bg: var(--mantine-color-dark-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Focus.
|
||||||
|
|
||||||
|
The codebase had no :focus-visible rule anywhere, which is the single
|
||||||
|
largest accessibility gap in it. :focus-visible rather than :focus so a
|
||||||
|
mouse click does not leave a ring behind — that is the behaviour that gets
|
||||||
|
focus rings deleted from designs in the first place.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Mantine already rings its own controls (`.mantine-focus-auto:focus-visible`
|
||||||
|
resolves to the same 2px solid primary). This rule is the safety net for
|
||||||
|
everything it does not own: plain anchors, custom elements, and the
|
||||||
|
UnstyledButtons this codebase uses for its own controls.
|
||||||
|
|
||||||
|
Note there is deliberately no `outline: none` opt-out for the Mantine
|
||||||
|
classes. An earlier attempt at one suppressed Mantine's working ring and
|
||||||
|
left portal buttons with no focus indicator at all — which of the two rules
|
||||||
|
won came down to stylesheet order, and that differs between the apps.
|
||||||
|
Matching values mean overlap is invisible, so overlap is the safe default. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: var(--ema-focus-ring-width) solid var(--ema-focus-ring);
|
||||||
|
outline-offset: var(--ema-focus-ring-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Screen-reader-only utility.
|
||||||
|
|
||||||
|
No equivalent existed anywhere in the codebase, so anything needing a text
|
||||||
|
alternative had nowhere to put it.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.ema-sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
/* clip-path rather than the legacy clip: it does not force a layer and is
|
||||||
|
not deprecated. */
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A skip link is sr-only until focused, then must be plainly visible. */
|
||||||
|
.ema-skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--ema-surface-raised);
|
||||||
|
color: var(--ema-text-primary);
|
||||||
|
border: 1px solid var(--ema-border-strong);
|
||||||
|
border-radius: 0 0 var(--mantine-radius-md) 0;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
/* Off-screen rather than display:none, so it stays focusable. */
|
||||||
|
transform: translateY(-150%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ema-skip-link:focus-visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Reduced motion.
|
||||||
|
|
||||||
|
Honour the OS setting globally. Animation is not removed outright — a
|
||||||
|
near-instant transition still conveys that something changed, without the
|
||||||
|
movement that triggers vestibular symptoms.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
libs/shared/src/lib/theme/status-tone.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* The platform's status vocabulary.
|
||||||
|
*
|
||||||
|
* There are 48 separate status→colour maps across the codebase, each deciding
|
||||||
|
* independently what "pending" looks like. They disagree. The fix is not one
|
||||||
|
* bigger map — domain statuses genuinely differ per feature — but one small set
|
||||||
|
* of *tones* that every domain maps onto, so the colour decision is made six
|
||||||
|
* times instead of forty-eight.
|
||||||
|
*
|
||||||
|
* `semantic.css` carries the CSS-variable form of these for stylesheet use.
|
||||||
|
* This module is for the many places that need a Mantine `color` prop instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StatusTone =
|
||||||
|
| 'success'
|
||||||
|
| 'warning'
|
||||||
|
| 'danger'
|
||||||
|
| 'info'
|
||||||
|
| 'pending'
|
||||||
|
| 'neutral';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tone → Mantine colour name.
|
||||||
|
*
|
||||||
|
* Deliberately the only place a tone becomes a colour. Changing the platform's
|
||||||
|
* idea of "warning" is an edit here, not a sweep through 48 files.
|
||||||
|
*/
|
||||||
|
export const STATUS_TONE_COLOR: Record<StatusTone, string> = {
|
||||||
|
success: 'green',
|
||||||
|
warning: 'yellow',
|
||||||
|
danger: 'red',
|
||||||
|
info: 'blue',
|
||||||
|
pending: 'orange',
|
||||||
|
neutral: 'gray',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tone → CSS custom properties, for inline styles and stylesheets.
|
||||||
|
*
|
||||||
|
* Returns variable references rather than resolved colours so the values keep
|
||||||
|
* following the active colour scheme.
|
||||||
|
*/
|
||||||
|
export function statusToneVars(tone: StatusTone): {
|
||||||
|
color: string;
|
||||||
|
background: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
color: `var(--ema-status-${tone}-fg)`,
|
||||||
|
background: `var(--ema-status-${tone}-bg)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export * from "./lib/feedback/FeatureUnavailable";
|
|||||||
export * from "./lib/feedback/EmptyState";
|
export * from "./lib/feedback/EmptyState";
|
||||||
export * from "./lib/feedback/ErrorState";
|
export * from "./lib/feedback/ErrorState";
|
||||||
export * from "./lib/feedback/PageLoader";
|
export * from "./lib/feedback/PageLoader";
|
||||||
|
export * from "./lib/feedback/StatusBadge";
|
||||||
export * from "./lib/components/MaritimeLoader";
|
export * from "./lib/components/MaritimeLoader";
|
||||||
export * from "./lib/theme/maritime-loader-theme";
|
export * from "./lib/theme/maritime-loader-theme";
|
||||||
export * from "./lib/layout/AppHeader";
|
export * from "./lib/layout/AppHeader";
|
||||||
@@ -19,13 +20,17 @@ export * from "./lib/layout/BrandAvatar";
|
|||||||
export * from "./lib/layout/ColorSchemeToggle";
|
export * from "./lib/layout/ColorSchemeToggle";
|
||||||
export * from "./lib/layout/LanguageSwitcher";
|
export * from "./lib/layout/LanguageSwitcher";
|
||||||
export * from "./lib/layout/PageHeader";
|
export * from "./lib/layout/PageHeader";
|
||||||
|
export * from "./lib/layout/SkipLink";
|
||||||
export * from "./lib/input/PasswordRequirements";
|
export * from "./lib/input/PasswordRequirements";
|
||||||
export * from "./lib/input/CountrySelect";
|
export * from "./lib/input/CountrySelect";
|
||||||
export * from "./lib/input/PhoneInput";
|
export * from "./lib/input/PhoneInput";
|
||||||
export * from "./lib/input/phone";
|
export * from "./lib/input/phone";
|
||||||
export * from "./lib/data/AdvancedTable";
|
export * from "./lib/data/AdvancedTable";
|
||||||
|
export * from "./lib/data/WaitingFor";
|
||||||
|
export * from "./lib/data/StatTile";
|
||||||
export * from "./lib/feedback/use-error-handler";
|
export * from "./lib/feedback/use-error-handler";
|
||||||
export * from "./lib/data/useServerTable";
|
export * from "./lib/data/useServerTable";
|
||||||
export * from "./lib/landing/LandingPage";
|
export * from "./lib/landing/LandingPage";
|
||||||
export * from "./lib/landing/landing-copy";
|
export * from "./lib/landing/landing-copy";
|
||||||
export * from "./lib/utils/person-name";
|
export * from "./lib/utils/person-name";
|
||||||
|
export * from "./lib/dev/ThemeGallery";
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ interface AdvancedTableProps<T> {
|
|||||||
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
|
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
|
||||||
/** Makes rows clickable (adds pointer cursor). */
|
/** Makes rows clickable (adds pointer cursor). */
|
||||||
onRowClick?: (row: T) => void;
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Card title, top-left. Defaults to `tableName`, which every caller already passes. */
|
||||||
|
title?: ReactNode;
|
||||||
|
/** Search box, filters, export — rendered top-right before Refresh/View. */
|
||||||
|
toolbar?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getByPath(obj: unknown, path?: string): unknown {
|
function getByPath(obj: unknown, path?: string): unknown {
|
||||||
@@ -82,6 +86,8 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
verticalSpacing = "sm",
|
verticalSpacing = "sm",
|
||||||
rowStyle,
|
rowStyle,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
title,
|
||||||
|
toolbar,
|
||||||
}: AdvancedTableProps<T>) {
|
}: AdvancedTableProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [visible, setVisible] = useState<boolean[]>(
|
const [visible, setVisible] = useState<boolean[]>(
|
||||||
@@ -94,13 +100,22 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
});
|
});
|
||||||
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
|
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
|
||||||
|
|
||||||
|
const heading = title ?? tableName;
|
||||||
|
const from = itemCount === 0 ? 0 : pageIndex * pageSize + 1;
|
||||||
|
const to = Math.min(itemCount, pageIndex * pageSize + data.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="md" p="md">
|
<Paper withBorder radius="lg" p={0}>
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" px="md" py="sm" wrap="wrap" gap="sm">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text fw={600}>{""}</Text>
|
{heading && (
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{heading}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap="xs" wrap="wrap">
|
||||||
|
{toolbar}
|
||||||
{refresh && (
|
{refresh && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -162,13 +177,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Table.ScrollContainer minWidth={480}>
|
<Table.ScrollContainer minWidth={480}>
|
||||||
<Table
|
<Table verticalSpacing={verticalSpacing}>
|
||||||
striped
|
|
||||||
highlightOnHover
|
|
||||||
withTableBorder
|
|
||||||
withColumnBorders
|
|
||||||
verticalSpacing={verticalSpacing}
|
|
||||||
>
|
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
{shownColumns.map((col, i) => (
|
{shownColumns.map((col, i) => (
|
||||||
@@ -230,8 +239,21 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
|
|
||||||
{(itemCount > pageSize || onPageSizeChange) && (
|
<Group
|
||||||
<Group justify="flex-end" mt="md">
|
justify="space-between"
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{ borderTop: "1px solid var(--ema-border-subtle)" }}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("common.showingRange", {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
total: itemCount,
|
||||||
|
defaultValue: "Showing {{from}}–{{to}} of {{total}}",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm">
|
||||||
{onPageSizeChange && (
|
{onPageSizeChange && (
|
||||||
<Select
|
<Select
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -253,7 +275,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
83
libs/ui/src/lib/data/StatTile.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Group, Paper, Text, ThemeIcon, UnstyledButton } from '@mantine/core';
|
||||||
|
import type { Icon } from '@tabler/icons-react';
|
||||||
|
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
|
||||||
|
import './stat-tile.css';
|
||||||
|
|
||||||
|
export interface StatTileProps {
|
||||||
|
label: string;
|
||||||
|
/** The number itself. A string so callers can pass "—" while loading. */
|
||||||
|
value: ReactNode;
|
||||||
|
/** One line under the value: what the number means, or how it is trending. */
|
||||||
|
hint?: ReactNode;
|
||||||
|
icon?: Icon;
|
||||||
|
/**
|
||||||
|
* Which tone the icon carries. Tone rather than colour so a tile counting
|
||||||
|
* overdue work is the same red as an overdue badge.
|
||||||
|
*/
|
||||||
|
tone?: StatusTone;
|
||||||
|
/** Makes the whole tile a button — use when the number has somewhere to go. */
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One figure on a dashboard.
|
||||||
|
*
|
||||||
|
* The four stat cards on the backoffice home were `<Card>` + two `<Text>`,
|
||||||
|
* re-declared inline on every dashboard that wanted them — so the logistics
|
||||||
|
* overview and the backoffice home showed the same kind of number at different
|
||||||
|
* sizes. The number leads, the label sits above it small and quiet, and the
|
||||||
|
* icon is decoration that carries the tone.
|
||||||
|
*
|
||||||
|
* A tile with `onClick` becomes a real button: dashboards exist to be a
|
||||||
|
* jumping-off point, and a count you cannot click is a dead end.
|
||||||
|
*/
|
||||||
|
export function StatTile({ label, value, hint, icon: TileIcon, tone, onClick }: StatTileProps) {
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700} lh={1.4}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{TileIcon && (
|
||||||
|
<ThemeIcon
|
||||||
|
size={38}
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color={tone ? STATUS_TONE_COLOR[tone] : undefined}
|
||||||
|
>
|
||||||
|
<TileIcon size={20} stroke={1.7} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text fz={30} fw={800} lh={1.15} mt="xs">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{hint && (
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
{hint}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onClick) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
{body}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={onClick}
|
||||||
|
className="ema-stat-tile-clickable"
|
||||||
|
style={{ display: 'block', width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
<Paper withBorder radius="lg" p="lg" h="100%">
|
||||||
|
{body}
|
||||||
|
</Paper>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
libs/ui/src/lib/data/WaitingFor.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Text, Tooltip } from '@mantine/core';
|
||||||
|
import { statusToneVars } from '@ema-platform/shared';
|
||||||
|
|
||||||
|
export interface WaitingForProps {
|
||||||
|
/** When the clock started — normally the moment the applicant submitted. */
|
||||||
|
since: string | null | undefined;
|
||||||
|
/**
|
||||||
|
* Days after which the wait reads as overdue. Amber at half of it, red past
|
||||||
|
* it. Defaults to 14, which is the shortest SLA any configured licence type
|
||||||
|
* currently uses; pass the real one where a queue knows it.
|
||||||
|
*/
|
||||||
|
slaDays?: number;
|
||||||
|
/** Set once the item is decided — a closed item is not waiting for anyone. */
|
||||||
|
done?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fraction of the target elapsed before the wait reads as at-risk. Matches the
|
||||||
|
* licence queue's own `WARNING_RATIO`, so a queue using this component and one
|
||||||
|
* using `computeSla` turn amber at the same point rather than disagreeing.
|
||||||
|
*/
|
||||||
|
const WARNING_RATIO = 0.7;
|
||||||
|
|
||||||
|
/** Whole days between `since` and now, floored. Negative clock skew reads as 0. */
|
||||||
|
function daysSince(since: string): number {
|
||||||
|
const ms = Date.now() - new Date(since).getTime();
|
||||||
|
return Math.max(0, Math.floor(ms / 86_400_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long an item has been sitting in a queue.
|
||||||
|
*
|
||||||
|
* No review queue showed this. An officer opening a list of thirty
|
||||||
|
* registrations could see what each one *was*, but not which had been waiting
|
||||||
|
* three days and which had been waiting three weeks — so the queue was worked
|
||||||
|
* top-down by whatever the sort happened to be rather than by urgency.
|
||||||
|
*
|
||||||
|
* Colour comes from the platform's status tones rather than its own scale, so
|
||||||
|
* "overdue" here is the same red as "rejected" everywhere else.
|
||||||
|
*/
|
||||||
|
export function WaitingFor({ since, slaDays = 14, done }: WaitingForProps) {
|
||||||
|
if (!since) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = daysSince(since);
|
||||||
|
|
||||||
|
// A decided item keeps its elapsed time visible — useful when reviewing how
|
||||||
|
// long something took — but never coloured, because nothing is pending.
|
||||||
|
const tone = done
|
||||||
|
? 'neutral'
|
||||||
|
: days >= slaDays
|
||||||
|
? 'danger'
|
||||||
|
: days >= slaDays * WARNING_RATIO
|
||||||
|
? 'pending'
|
||||||
|
: 'neutral';
|
||||||
|
|
||||||
|
const label = days === 0 ? 'today' : `${days}d`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
done
|
||||||
|
? `Took ${days} day${days === 1 ? '' : 's'}`
|
||||||
|
: `Waiting ${days} day${days === 1 ? '' : 's'} · ${slaDays}-day target`
|
||||||
|
}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={tone === 'neutral' ? 400 : 600}
|
||||||
|
c={tone === 'neutral' ? 'dimmed' : undefined}
|
||||||
|
style={tone === 'neutral' ? undefined : { color: statusToneVars(tone).color }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
libs/ui/src/lib/data/stat-tile.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* A clickable StatTile. The hover cue lives here rather than in inline style
|
||||||
|
* handlers so it can use a real `:hover` — a JS mouseenter/leave pair misses
|
||||||
|
* keyboard focus, and `:focus-visible` needs the app's focus ring anyway.
|
||||||
|
*/
|
||||||
|
.ema-stat-tile-clickable > * {
|
||||||
|
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ema-stat-tile-clickable:hover > * {
|
||||||
|
border-color: var(--mantine-primary-color-filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ema-stat-tile-clickable:focus-visible > * {
|
||||||
|
border-color: var(--mantine-primary-color-filled);
|
||||||
|
}
|
||||||
362
libs/ui/src/lib/dev/ThemeGallery.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import {
|
||||||
|
SimpleGrid,
|
||||||
|
Alert,
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Radio,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
useMantineTheme,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconAlertTriangle, IconInbox } from '@tabler/icons-react';
|
||||||
|
import { SkipLink, MAIN_CONTENT_ID } from '../layout/SkipLink';
|
||||||
|
import { StatusBadge } from '../feedback/StatusBadge';
|
||||||
|
import { WaitingFor } from '../data/WaitingFor';
|
||||||
|
import { StatTile } from '../data/StatTile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed clock. The gallery is screenshotted by the visual suite, so a tile
|
||||||
|
* reading "3d" must read "3d" tomorrow too — `Date.now()` would rewrite the
|
||||||
|
* baseline every day.
|
||||||
|
*/
|
||||||
|
const GALLERY_NOW = '2026-08-21T00:00:00.000Z';
|
||||||
|
const daysAgo = (n: number) =>
|
||||||
|
new Date(Date.parse(GALLERY_NOW) - n * 86_400_000).toISOString();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every primitive the theme controls, on one page.
|
||||||
|
*
|
||||||
|
* This is the visual-regression surface for theme work. A real feature page
|
||||||
|
* renders a handful of primitives and only their happy states; a theme change
|
||||||
|
* that breaks `Button disabled` or `TextInput error` would sail past it. This
|
||||||
|
* renders all of them, including the states nothing else shows, so a screenshot
|
||||||
|
* diff says precisely what a theme edit did.
|
||||||
|
*
|
||||||
|
* Deliberately free of data, auth and network so it cannot flake: mounted at
|
||||||
|
* `/__gallery` outside the protected routes, it needs no API and no database.
|
||||||
|
*
|
||||||
|
* Not part of the product. Excluded from the nav on purpose.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SWATCH_SHADES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Title order={3}>{title}</Title>
|
||||||
|
<Divider />
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A full 0–9 ramp, so a palette swap is visible shade by shade. */
|
||||||
|
function ColorRamp({ name }: { name: string }) {
|
||||||
|
return (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Group gap={0} wrap="nowrap">
|
||||||
|
{SWATCH_SHADES.map((shade) => (
|
||||||
|
<Box
|
||||||
|
key={shade}
|
||||||
|
style={{
|
||||||
|
background: `var(--mantine-color-${name}-${shade})`,
|
||||||
|
width: 48,
|
||||||
|
height: 40,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 10,
|
||||||
|
// Shades 0-4 are light enough to need dark text; 5+ need light.
|
||||||
|
color: shade < 5 ? 'var(--mantine-color-black)' : 'var(--mantine-color-white)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shade}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeGallery() {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const paletteNames = Object.keys(theme.colors).filter((c) =>
|
||||||
|
// The stock Mantine ramps are noise here; show the ones this app defines,
|
||||||
|
// plus gray because overriding it is the riskiest single theme change.
|
||||||
|
c.startsWith('ema') || c === 'gray',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mirrors the real app shells, so the skip-link contract is testable
|
||||||
|
without a session. */}
|
||||||
|
<SkipLink />
|
||||||
|
<Box id={MAIN_CONTENT_ID} p="xl" style={{ maxWidth: 1100, margin: '0 auto' }}>
|
||||||
|
<Stack gap="xl">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Title order={1}>Theme Gallery</Title>
|
||||||
|
<Text c="dimmed">
|
||||||
|
Visual-regression surface for theme changes. Not a product page.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Section title="Typography">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Title order={1}>Heading 1 — Maritime licensing</Title>
|
||||||
|
<Title order={2}>Heading 2 — Seafarer registry</Title>
|
||||||
|
<Title order={3}>Heading 3 — Vessel registration</Title>
|
||||||
|
<Title order={4}>Heading 4 — Certificate of competency</Title>
|
||||||
|
<Title order={5}>Heading 5 — Sea service record</Title>
|
||||||
|
<Text>
|
||||||
|
Body text. The quick brown fox jumps over the lazy dog.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Small dimmed text, used for subtitles and helper copy.
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Extra-small text, used for metadata.
|
||||||
|
</Text>
|
||||||
|
{/* Mixed-script line: the case a [lang] font swap would break. */}
|
||||||
|
<Text>Amharic: አበበ ግርማ — SF-2024-0001 — የአዲስ አበባ ወደብ</Text>
|
||||||
|
{/* Ge'ez at heading size, where its taller ascenders clip first. */}
|
||||||
|
<Title order={2}>የኢትዮጵያ ማሪታይም ባለስልጣን</Title>
|
||||||
|
<Title order={4}>የመርከበኞች መዝገብ — Seafarer Registry</Title>
|
||||||
|
<Text ff="monospace">Monospace: ET-IMO-0231 · 28,450 GT</Text>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Color ramps">
|
||||||
|
<Stack gap="md">
|
||||||
|
{paletteNames.map((name) => (
|
||||||
|
<ColorRamp key={name} name={name} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Buttons">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group>
|
||||||
|
<Button>Filled</Button>
|
||||||
|
<Button variant="light">Light</Button>
|
||||||
|
<Button variant="outline">Outline</Button>
|
||||||
|
<Button variant="subtle">Subtle</Button>
|
||||||
|
<Button variant="default">Default</Button>
|
||||||
|
<Button variant="white">White</Button>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Button size="xs">xs</Button>
|
||||||
|
<Button size="sm">sm</Button>
|
||||||
|
<Button size="md">md</Button>
|
||||||
|
<Button size="lg">lg</Button>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Button color="red">Destructive</Button>
|
||||||
|
<Button disabled>Disabled</Button>
|
||||||
|
<Button loading>Loading</Button>
|
||||||
|
<Button fullWidth>Full width</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Surfaces">
|
||||||
|
<Group align="stretch" grow>
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600}>Card withBorder</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Radius and shadow come from the theme.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Text fw={600}>Paper withBorder</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Default radius applies here too.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
<Paper p="md" shadow="md">
|
||||||
|
<Text fw={600}>Paper shadow=md</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Shadow ramp check.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</Group>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Badges">
|
||||||
|
<Group>
|
||||||
|
<Badge>Default</Badge>
|
||||||
|
<Badge color="green">Active</Badge>
|
||||||
|
<Badge color="yellow">Pending</Badge>
|
||||||
|
<Badge color="red">Suspended</Badge>
|
||||||
|
<Badge color="orange">Expiring</Badge>
|
||||||
|
<Badge color="gray">Draft</Badge>
|
||||||
|
<Badge variant="filled">Filled</Badge>
|
||||||
|
<Badge variant="outline">Outline</Badge>
|
||||||
|
<Badge variant="dot">Dot</Badge>
|
||||||
|
</Group>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Form controls">
|
||||||
|
<Stack gap="md" style={{ maxWidth: 520 }}>
|
||||||
|
<TextInput label="Text input" placeholder="you@example.com" />
|
||||||
|
<TextInput
|
||||||
|
label="With description"
|
||||||
|
description="Helper text that persists, unlike a placeholder."
|
||||||
|
placeholder="ET-000000"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Error state"
|
||||||
|
error="This field is required."
|
||||||
|
placeholder="Required"
|
||||||
|
/>
|
||||||
|
<TextInput label="Disabled" disabled value="Cannot edit" />
|
||||||
|
<TextInput label="Read-only" readOnly value="Read-only value" />
|
||||||
|
<Select
|
||||||
|
label="Select"
|
||||||
|
data={['Ethiopian', 'Djiboutian', 'Kenyan']}
|
||||||
|
placeholder="Pick one"
|
||||||
|
/>
|
||||||
|
<Textarea label="Textarea" placeholder="Full permanent address" />
|
||||||
|
<Group>
|
||||||
|
<Checkbox label="Checkbox" defaultChecked />
|
||||||
|
<Radio label="Radio" defaultChecked />
|
||||||
|
<Switch label="Switch" defaultChecked />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Feedback">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Alert color="blue" title="Information">
|
||||||
|
A unique Seafarer ID is generated on approval.
|
||||||
|
</Alert>
|
||||||
|
<Alert color="yellow" title="Warning">
|
||||||
|
Upload clear copies of all required documents.
|
||||||
|
</Alert>
|
||||||
|
<Alert color="red" title="Error">
|
||||||
|
The certificate number could not be verified.
|
||||||
|
</Alert>
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size="lg">Ic</ThemeIcon>
|
||||||
|
<ThemeIcon size="lg" variant="light">
|
||||||
|
Lt
|
||||||
|
</ThemeIcon>
|
||||||
|
<ThemeIcon size="lg" color="green" variant="light">
|
||||||
|
Ok
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Tabs">
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="training">Training</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="medical">Medical</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Panel value="overview" pt="md">
|
||||||
|
<Text size="sm">Overview panel content.</Text>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Status badges">
|
||||||
|
<Group>
|
||||||
|
<StatusBadge tone="success" label="Approved" />
|
||||||
|
<StatusBadge tone="warning" label="Expiring" />
|
||||||
|
<StatusBadge tone="danger" label="Rejected" />
|
||||||
|
<StatusBadge tone="info" label="Submitted" />
|
||||||
|
<StatusBadge tone="pending" label="Corrections requested" />
|
||||||
|
<StatusBadge tone="neutral" label="Draft" />
|
||||||
|
</Group>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Waiting for">
|
||||||
|
<Group>
|
||||||
|
<WaitingFor since={GALLERY_NOW} />
|
||||||
|
<WaitingFor since={daysAgo(9)} />
|
||||||
|
<WaitingFor since={daysAgo(16)} />
|
||||||
|
<WaitingFor since={daysAgo(30)} done />
|
||||||
|
<WaitingFor since={null} />
|
||||||
|
</Group>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Stat tiles">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
|
<StatTile label="Awaiting claim" value={12} hint="Nobody has picked these up" icon={IconInbox} tone="info" />
|
||||||
|
<StatTile label="Needs applicant action" value={3} hint="Returned for corrections" icon={IconAlertTriangle} tone="pending" />
|
||||||
|
<StatTile label="Overdue" value={2} hint="Past the turnaround target" icon={IconAlertTriangle} tone="danger" />
|
||||||
|
<StatTile label="Not permitted" value="—" hint="No access to this queue" icon={IconInbox} tone="neutral" />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Table">
|
||||||
|
<Table>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Seafarer ID</Table.Th>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Region</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Anchor size="sm">SF-2024-0001</Anchor>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>Abebe Girma</Table.Td>
|
||||||
|
<Table.Td>Addis Ababa</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color="green">Active</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Anchor size="sm">SF-2024-0002</Anchor>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>ሳራ ተስፋዬ</Table.Td>
|
||||||
|
<Table.Td>Dire Dawa</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color="yellow">Pending</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Focus states">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Tab through these to check the focus ring. Every interactive element
|
||||||
|
must show a visible indicator.
|
||||||
|
</Text>
|
||||||
|
<Group mt="sm">
|
||||||
|
<Button>Button</Button>
|
||||||
|
<Anchor href="#gallery-focus">Link</Anchor>
|
||||||
|
<TextInput placeholder="Input" />
|
||||||
|
<Checkbox label="Checkbox" />
|
||||||
|
</Group>
|
||||||
|
</Section>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,7 +80,10 @@ export function PageLoader({
|
|||||||
<Text
|
<Text
|
||||||
fw={600}
|
fw={600}
|
||||||
size="md"
|
size="md"
|
||||||
c={isDark ? 'gray.1' : 'navy.9'}
|
// Was `navy.9`, which is defined in neither theme — Mantine
|
||||||
|
// silently drops unresolved keys, so this label had been
|
||||||
|
// rendering an inherited colour rather than the intended one.
|
||||||
|
c={isDark ? 'gray.1' : 'gray.9'}
|
||||||
style={{ lineHeight: 1.3 }}
|
style={{ lineHeight: 1.3 }}
|
||||||
>
|
>
|
||||||
{resolvedLabel}
|
{resolvedLabel}
|
||||||
@@ -108,6 +111,7 @@ export function PageLoader({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
width: '40%',
|
width: '40%',
|
||||||
background: 'linear-gradient(90deg, #078930, #FCD116, #2563EB)',
|
background: 'linear-gradient(90deg, #078930, #FCD116, #2563EB)',
|
||||||
borderRadius: '2px',
|
borderRadius: '2px',
|
||||||
@@ -115,9 +119,13 @@ export function PageLoader({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<style>{`
|
<style>{`
|
||||||
|
/* Translating rather than animating \`left\`: the latter runs
|
||||||
|
layout on every frame of an animation that plays during page
|
||||||
|
loads, which is exactly when the main thread is busiest.
|
||||||
|
Reduced motion is handled globally in semantic.css. */
|
||||||
@keyframes ema-shimmer {
|
@keyframes ema-shimmer {
|
||||||
0% { left: -40%; }
|
0% { transform: translateX(-100%); }
|
||||||
100% { left: 100%; }
|
100% { transform: translateX(250%); }
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
30
libs/ui/src/lib/feedback/StatusBadge.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Badge, type BadgeProps } from '@mantine/core';
|
||||||
|
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
|
||||||
|
|
||||||
|
export interface StatusBadgeProps extends Omit<BadgeProps, 'color' | 'children'> {
|
||||||
|
/** The platform tone this status maps onto. */
|
||||||
|
tone: StatusTone;
|
||||||
|
/** Human label. Already translated by the caller. */
|
||||||
|
label: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One badge for every status in the platform.
|
||||||
|
*
|
||||||
|
* There were 29 files rendering `<Badge variant="light" color={MAP[status]}>`,
|
||||||
|
* each re-deciding size, variant and radius alongside the colour — which is why
|
||||||
|
* a "pending" badge in one queue did not match "pending" in the next. Callers
|
||||||
|
* now supply a tone and a label; how a status *looks* is decided once, here.
|
||||||
|
*
|
||||||
|
* Tone rather than colour on purpose: `STATUS_TONE_COLOR` is the single place a
|
||||||
|
* tone becomes a Mantine colour, so restyling the platform's idea of "danger"
|
||||||
|
* stays one edit rather than a sweep.
|
||||||
|
*/
|
||||||
|
export function StatusBadge({ tone, label, ...props }: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<Badge variant="light" size="sm" radius="sm" {...props} color={STATUS_TONE_COLOR[tone]}>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -157,8 +157,8 @@ function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle
|
|||||||
height: rem(40),
|
height: rem(40),
|
||||||
borderRadius: rem(10),
|
borderRadius: rem(10),
|
||||||
opacity: item.soon ? 0.55 : 1,
|
opacity: item.soon ? 0.55 : 1,
|
||||||
color: branchActive ? 'var(--mantine-color-blue-6)' : undefined,
|
color: branchActive ? 'var(--mantine-primary-color-filled)' : undefined,
|
||||||
backgroundColor: branchActive ? 'var(--mantine-color-blue-light)' : undefined,
|
backgroundColor: branchActive ? 'var(--mantine-primary-color-light)' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ItemIcon size={20} stroke={1.6} />
|
<ItemIcon size={20} stroke={1.6} />
|
||||||
@@ -222,7 +222,10 @@ function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle
|
|||||||
opened={hasChildren ? opened : undefined}
|
opened={hasChildren ? opened : undefined}
|
||||||
onChange={hasChildren ? onToggle : undefined}
|
onChange={hasChildren ? onToggle : undefined}
|
||||||
onClick={() => !hasChildren && onNavigate(item)}
|
onClick={() => !hasChildren && onNavigate(item)}
|
||||||
variant="light"
|
// A leaf that is the current page is filled in the app's primary; a
|
||||||
|
// branch whose child is current stays a tint, so the filled item is
|
||||||
|
// always exactly the page you are on.
|
||||||
|
variant={hasChildren ? 'light' : 'filled'}
|
||||||
styles={{
|
styles={{
|
||||||
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
|
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
|
||||||
label: { fontWeight: 500 },
|
label: { fontWeight: 500 },
|
||||||
|
|||||||
@@ -2,17 +2,54 @@ import { Group, Stack, Text, Title } from '@mantine/core';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: string;
|
/**
|
||||||
subtitle?: string;
|
* The page's name. A `ReactNode` rather than a string because detail pages
|
||||||
|
* title themselves with the record they are showing, which is often a
|
||||||
|
* localised or composed value rather than a literal.
|
||||||
|
*/
|
||||||
|
title: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Identifiers that belong beside the title rather than under it — a
|
||||||
|
* reference number, a status badge. Rendered inline, small and dimmed.
|
||||||
|
*/
|
||||||
|
meta?: ReactNode;
|
||||||
/** Right-aligned actions (e.g. a primary button). */
|
/** Right-aligned actions (e.g. a primary button). */
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Set when the header sits directly inside a gapped `<Stack>`, which already
|
||||||
|
* spaces it from what follows — the built-in margin would double that gap.
|
||||||
|
*/
|
||||||
|
noMargin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
/**
|
||||||
|
* The heading every backoffice page starts with.
|
||||||
|
*
|
||||||
|
* It exists because 25 pages each rolled their own: eight at `order={2}`,
|
||||||
|
* seventeen at `order={3}`, with `mb="xs"`, `mb={4}`, `mb="lg"` and nothing at
|
||||||
|
* all between them. Clicking between two screens moved the title, which is the
|
||||||
|
* single loudest signal that a product was assembled rather than designed.
|
||||||
|
*
|
||||||
|
* `order={2}` is deliberate — it is the page's only `h2`, sitting under the
|
||||||
|
* app-level `h1`, so the heading outline reads correctly for a screen reader.
|
||||||
|
* Section headings inside cards stay at `order={4}`/`{5}` and are not this
|
||||||
|
* component's business.
|
||||||
|
*/
|
||||||
|
export function PageHeader({ title, subtitle, meta, action, noMargin }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-end"
|
||||||
|
wrap="wrap"
|
||||||
|
gap="sm"
|
||||||
|
mb={noMargin ? undefined : 'lg'}
|
||||||
|
>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Title order={2}>{title}</Title>
|
<Group gap="sm" align="center" wrap="wrap">
|
||||||
|
<Title order={2}>{title}</Title>
|
||||||
|
{meta}
|
||||||
|
</Group>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
27
libs/ui/src/lib/layout/SkipLink.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
/** The id the skip link targets. Exported so the main region cannot drift. */
|
||||||
|
export const MAIN_CONTENT_ID = 'ema-main-content';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Skip to main content" — the first thing a keyboard user should reach.
|
||||||
|
*
|
||||||
|
* Both apps put a sidebar of 20-plus navigation items before the page body, so
|
||||||
|
* without this, reaching the actual content means tabbing through every one of
|
||||||
|
* them on every navigation. WCAG 2.4.1 asks for a bypass; there was none.
|
||||||
|
*
|
||||||
|
* Hidden until focused, which is why it is positioned off-screen rather than
|
||||||
|
* `display: none` — the latter would make it unfocusable and defeat the point.
|
||||||
|
* Styling lives in `semantic.css` as `.ema-skip-link`.
|
||||||
|
*
|
||||||
|
* Render it as the first child of the shell, before the header.
|
||||||
|
*/
|
||||||
|
export function SkipLink() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a className="ema-skip-link" href={`#${MAIN_CONTENT_ID}`}>
|
||||||
|
{t('a11y.skipToContent', 'Skip to main content')}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,7 +46,6 @@
|
|||||||
"react-router": "^7.12.0",
|
"react-router": "^7.12.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -71,7 +70,6 @@
|
|||||||
"nx": "^22.5.4",
|
"nx": "^22.5.4",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tailwindcss": "^3.4.3",
|
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vitest": "^4.0.0"
|
"vitest": "^4.0.0"
|
||||||
|
|||||||
2014
pnpm-lock.yaml
generated
@@ -1,42 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: 'class',
|
|
||||||
content: [
|
|
||||||
'./apps/**/*.{js,ts,jsx,tsx}',
|
|
||||||
'./libs/**/*.{js,ts,jsx,tsx}',
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
50: '#eff6ff',
|
|
||||||
100: '#dbeafe',
|
|
||||||
200: '#bfdbfe',
|
|
||||||
300: '#93c5fd',
|
|
||||||
400: '#60a5fa',
|
|
||||||
500: '#3b82f6',
|
|
||||||
600: '#2563eb',
|
|
||||||
700: '#1d4ed8',
|
|
||||||
800: '#1e40af',
|
|
||||||
900: '#1e3a8a',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
50: '#fdf8f6',
|
|
||||||
100: '#f2e8e5',
|
|
||||||
200: '#eaddd7',
|
|
||||||
300: '#e0cec7',
|
|
||||||
400: '#d2bab0',
|
|
||||||
500: '#bfa094',
|
|
||||||
600: '#a18072',
|
|
||||||
700: '#977669',
|
|
||||||
800: '#65524d',
|
|
||||||
900: '#2c1f1a',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
card: '0 4px 20px rgba(15, 23, 42, 0.08)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||