Merge branch 'ui_ux' of https://github.com/Tria-plc/emaui into ui_ux

This commit is contained in:
fitse-yotor
2026-08-21 16:12:16 +03:00
29 changed files with 392 additions and 487 deletions

View File

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

View File

@@ -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

View File

@@ -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>

View File

@@ -1,17 +1,8 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {Card, Center, Container, Group, Loader, SimpleGrid, Text} from '@mantine/core';
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Text,
Title,
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react'; import { IconChevronRight } from '@tabler/icons-react';
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api'; import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui'; import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
import { dashboardQueueColumns } from './columns'; import { dashboardQueueColumns } from './columns';
/** /**
@@ -53,12 +44,10 @@ export function DashboardPage() {
return ( return (
<Container size="xl" py="md"> <Container size="xl" py="md">
<Title order={3} mb="xs"> <PageHeader
Dashboard title="Dashboard"
</Title> subtitle="Licence applications currently in the system."
<Text size="sm" c="dimmed" mb="lg"> />
Licence applications currently in the system.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl"> <SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
{stats.map((stat) => ( {stats.map((stat) => (

View File

@@ -291,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">

View File

@@ -2,7 +2,6 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Stack, Stack,
Title,
Group, Group,
Button, Button,
Modal, Modal,
@@ -10,7 +9,6 @@ import {
TextInput, TextInput,
Textarea, Textarea,
Card, Card,
Alert,
Select, Select,
NumberInput, NumberInput,
Tabs, Tabs,
@@ -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

View File

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

View File

@@ -1,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>

View File

@@ -17,7 +17,6 @@ import {
Tabs, Tabs,
Text, Text,
TextInput, TextInput,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { import {
@@ -72,6 +71,7 @@ import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard"; import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
import { licenseQueueColumns } from "./columns"; import { licenseQueueColumns } from "./columns";
import { licenseQueueActionsColumn } from "./actions"; import { licenseQueueActionsColumn } from "./actions";
import { PageHeader } from '@ema-platform/ui';
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const SEARCH_DEBOUNCE_MS = 300; const SEARCH_DEBOUNCE_MS = 300;
@@ -501,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

View File

@@ -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}

View File

@@ -1,23 +1,9 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { import {Stack, Group, Button, Paper, Text, Grid, Modal, ActionIcon, Tooltip, Loader, Center, Alert} from '@mantine/core';
Stack,
Title,
Group,
Button,
Paper,
Text,
Grid,
Modal,
ActionIcon,
Tooltip,
Loader,
Center,
Alert,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react'; import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui'; import { ModalFooter, notify, PageHeader, PageLoader, useErrorHandler } from '@ema-platform/ui';
import { LocationTree } from '../components/LocationTree'; import { LocationTree } from '../components/LocationTree';
import { LocationDetail } from '../components/LocationDetail'; import { LocationDetail } from '../components/LocationDetail';
import { LocationForm } from '../components/LocationForm'; import { LocationForm } from '../components/LocationForm';
@@ -108,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

View File

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

View File

@@ -1,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,
@@ -376,22 +357,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> )
}
/>
{isMedical ? ( {isMedical ? (
<AdvancedTable <AdvancedTable

View File

@@ -1,37 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {Alert, Badge, Button, Center, Group, Loader, Modal, NumberInput, Paper, Stack, Switch, Text, TextInput, ThemeIcon, Tooltip} from '@mantine/core';
Alert,
Badge,
Button,
Center,
Group,
Loader,
Modal,
NumberInput,
Paper,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
Title,
Tooltip,
} from '@mantine/core';
import { import {
IconAlertTriangle, IconAlertTriangle,
IconCreditCard, IconCreditCard,
IconInfoCircle, IconInfoCircle,
IconLock, IconLock,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { import { AdvancedTable, ModalFooter, notify, PageHeader, PageLoader, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
notify,
ModalFooter,
AdvancedTable,
useServerTable,
type AdvancedColumn,
PageLoader,
} from '@ema-platform/ui';
import { import {
extractErrorMessage, extractErrorMessage,
useLocalized, useLocalized,
@@ -92,20 +68,19 @@ export function PaymentConfigPage() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between" align="flex-start"> <PageHeader
<div> title={t('paymentConfig.title', 'Payment configuration')}
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title> subtitle={t(
<Text size="sm" c="dimmed" mt={4}> '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"

View File

@@ -247,7 +247,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>

View File

@@ -1,22 +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,
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';
@@ -201,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 }),
@@ -216,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

View File

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

View File

@@ -1,39 +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,
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, StatusBadge, 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';
@@ -279,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),
@@ -294,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"
@@ -316,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" />

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import {Badge, Container, 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,13 +117,10 @@ 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>
<AdvancedTable <AdvancedTable
columns={columns} columns={columns}
data={data?.items ?? []} data={data?.items ?? []}

View File

@@ -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} />

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import {Container, 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 {
@@ -11,7 +11,7 @@ import {
type SeafarerRegistration, type SeafarerRegistration,
type SeafarerRegistrationStatus, type SeafarerRegistrationStatus,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { AdvancedTable, StatusBadge, 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,6 +78,17 @@ 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',
@@ -94,13 +105,10 @@ 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>
<AdvancedTable <AdvancedTable
columns={columns} columns={columns}
data={data?.items ?? []} data={data?.items ?? []}

View File

@@ -1,22 +1,6 @@
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,
@@ -31,7 +15,7 @@ import {
useRejectSeafarerRegistrationMutation, useRejectSeafarerRegistrationMutation,
useRequestSeafarerRegistrationChangesMutation, useRequestSeafarerRegistrationChangesMutation,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { notify, StatusBadge } 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,10 +93,10 @@ 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>
@@ -125,9 +109,10 @@ export function SeafarerRegistrationReviewPage() {
{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>
@@ -147,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">
@@ -168,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)
@@ -193,7 +179,7 @@ export function SeafarerRegistrationReviewPage() {
<Text fw={600} size="sm" mb={4}> <Text fw={600} size="sm" mb={4}>
Documents Documents
</Text> </Text>
<Table withTableBorder withColumnBorders> <Table variant="vertical">
<Table.Tbody> <Table.Tbody>
{slots.map((slot) => { {slots.map((slot) => {
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0]; const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];

View File

@@ -1,27 +1,8 @@
import { StatusBadge } from '@ema-platform/ui'; import { PageHeader, StatusBadge } from '@ema-platform/ui';
import { type StatusTone } from '@ema-platform/shared'; 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,
@@ -171,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">

View File

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

View File

@@ -1,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"

View File

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

View File

@@ -26,6 +26,7 @@ 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/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";

View File

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

View File

@@ -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}