mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
backoffice
This commit is contained in:
@@ -44,6 +44,7 @@ export function CertificateRequirementsPage() {
|
||||
'certReq.subtitle',
|
||||
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
|
||||
)}
|
||||
noMargin
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Card,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Button, Modal, Text, TextInput, Textarea, Card} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
||||
import {IconPlus} from '@tabler/icons-react';
|
||||
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
@@ -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 = [
|
||||
...certificationColumns(t, locale),
|
||||
@@ -134,17 +124,18 @@ export function CertificationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t('certification.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('certification.add')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<PageHeader
|
||||
title={t('certification.title')}
|
||||
subtitle={t('certification.subtitle')}
|
||||
noMargin
|
||||
action={
|
||||
!showForm && (
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('certification.add')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<CertificationForm
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import type { Profession } from "../../types/configuration";
|
||||
import { professionColumns } from "./columns";
|
||||
import { professionActionsColumn } from "./actions";
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
interface ProfFormValues {
|
||||
nameEn: string;
|
||||
@@ -378,7 +379,7 @@ export function ConfigurationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>{t("configuration.title")}</Title>
|
||||
<PageHeader title={t("configuration.title")} noMargin />
|
||||
|
||||
<Tabs defaultValue="professions">
|
||||
<Tabs.List>
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Card, Center, Container, Group, Loader, SimpleGrid, Text} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -53,12 +44,10 @@ export function DashboardPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Dashboard
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="lg">
|
||||
Licence applications currently in the system.
|
||||
</Text>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Licence applications currently in the system."
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||
{stats.map((stat) => (
|
||||
|
||||
@@ -291,7 +291,7 @@ export function ExamDetailPage() {
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Title order={3}>{exam.title[locale]}</Title>
|
||||
<Title order={2}>{exam.title[locale]}</Title>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Modal,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
TextInput,
|
||||
Textarea,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
@@ -35,6 +33,7 @@ import {
|
||||
import type { Exam } from "../../types/exam";
|
||||
import { examColumns } from "./columns";
|
||||
import { examActionsColumn } from "./actions";
|
||||
import { ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
|
||||
function ExamForm({
|
||||
editing,
|
||||
@@ -416,13 +415,7 @@ export function ExamPage() {
|
||||
};
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
color="red"
|
||||
title={t("exam.loadError")}
|
||||
/>
|
||||
);
|
||||
return <ErrorState title={t("exam.loadError")} onRetry={refetch} />;
|
||||
|
||||
const columns = [
|
||||
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
|
||||
@@ -443,26 +436,25 @@ export function ExamPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t("exam.title")}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("exam.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.add")}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
<PageHeader
|
||||
title={t("exam.title")}
|
||||
subtitle={t("exam.subtitle")}
|
||||
noMargin
|
||||
action={
|
||||
!showForm && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.add")}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<ExamForm
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Stack, Title, Paper } from '@mantine/core';
|
||||
import {Stack, Paper} from '@mantine/core';
|
||||
import { ItemTable } from '../components/ItemTable';
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
export function ItemPage() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>Items</Title>
|
||||
<PageHeader title="Items" noMargin />
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<ItemTable />
|
||||
</Paper>
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Textarea} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -151,21 +139,19 @@ export function LicenseRegisterPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Licence register</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Certificate № or company"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
<PageHeader
|
||||
title="Licence register"
|
||||
subtitle={`${data?.total ?? 0} issued licence${(data?.total ?? 0) === 1 ? '' : 's'}`}
|
||||
action={
|
||||
<TextInput
|
||||
placeholder="Certificate № or company"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable<IssuedLicense>
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
@@ -72,6 +71,7 @@ import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
|
||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
|
||||
import { licenseQueueColumns } from "./columns";
|
||||
import { licenseQueueActionsColumn } from "./actions";
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
@@ -501,16 +501,11 @@ export function LicenseQueuePage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{queueTitle}</Title>
|
||||
{typeCode && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<PageHeader
|
||||
title={queueTitle}
|
||||
subtitle={typeCode ? t(`nav.type${typeCode}`, { defaultValue: typeCode }) : undefined}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={density}
|
||||
@@ -534,8 +529,9 @@ export function LicenseQueuePage() {
|
||||
>
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Saved views, counted. */}
|
||||
<Tabs
|
||||
|
||||
@@ -724,7 +724,7 @@ export function LicenseReviewPage() {
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{headerName}</Title>
|
||||
<Title order={2}>{headerName}</Title>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.applicationNumber}
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Paper,
|
||||
Text,
|
||||
Grid,
|
||||
Modal,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Group, Button, Paper, Text, Grid, Modal, ActionIcon, Tooltip, Loader, Center, Alert} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui';
|
||||
import { ModalFooter, notify, PageHeader, PageLoader, useErrorHandler } from '@ema-platform/ui';
|
||||
import { LocationTree } from '../components/LocationTree';
|
||||
import { LocationDetail } from '../components/LocationDetail';
|
||||
import { LocationForm } from '../components/LocationForm';
|
||||
@@ -108,9 +94,11 @@ export function LocationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>{t('location.title')}</Title>
|
||||
<Group gap="sm">
|
||||
<PageHeader
|
||||
title={t('location.title')}
|
||||
noMargin
|
||||
action={
|
||||
<Group gap="sm">
|
||||
{locationTypes.length > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -135,8 +123,9 @@ export function LocationPage() {
|
||||
<IconSettings size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{locationTypes.length === 0 && (
|
||||
<Alert
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Badge, Card, Center, Container, Grid, Group, Loader, SimpleGrid, Stack, Text} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -74,12 +62,10 @@ export function LogisticsHeadDashboardPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Logistics overview
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="lg">
|
||||
Licence applications currently in the department.
|
||||
</Text>
|
||||
<PageHeader
|
||||
title="Logistics overview"
|
||||
subtitle="Licence applications currently in the department."
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||
{stats.map((stat) => (
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Badge, Button, Center, Container, Group, Loader, Modal, Paper, SegmentedControl, Stack, Text, Textarea} from '@mantine/core';
|
||||
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
|
||||
import {
|
||||
AdvancedTable,
|
||||
notify,
|
||||
PdfPreviewModal,
|
||||
type AdvancedColumn,
|
||||
} from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, PdfPreviewModal, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -376,22 +357,24 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
{isMedical
|
||||
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
||||
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{isMedical
|
||||
? t(
|
||||
'recordVerification.medicalSubtitle',
|
||||
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
|
||||
)
|
||||
: t(
|
||||
'recordVerification.seaServiceSubtitle',
|
||||
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
|
||||
)}
|
||||
</Text>
|
||||
<PageHeader
|
||||
title={
|
||||
isMedical
|
||||
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
||||
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')
|
||||
}
|
||||
subtitle={
|
||||
isMedical
|
||||
? t(
|
||||
'recordVerification.medicalSubtitle',
|
||||
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
|
||||
)
|
||||
: t(
|
||||
'recordVerification.seaServiceSubtitle',
|
||||
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{isMedical ? (
|
||||
<AdvancedTable
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Center, Group, Loader, Modal, NumberInput, Paper, Stack, Switch, Text, TextInput, ThemeIcon, Tooltip} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCreditCard,
|
||||
IconInfoCircle,
|
||||
IconLock,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
notify,
|
||||
ModalFooter,
|
||||
AdvancedTable,
|
||||
useServerTable,
|
||||
type AdvancedColumn,
|
||||
PageLoader,
|
||||
} from '@ema-platform/ui';
|
||||
import { AdvancedTable, ModalFooter, notify, PageHeader, PageLoader, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
@@ -92,20 +68,19 @@ export function PaymentConfigPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{t(
|
||||
'paymentConfig.subtitle',
|
||||
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconCreditCard size={22} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<PageHeader
|
||||
title={t('paymentConfig.title', 'Payment configuration')}
|
||||
subtitle={t(
|
||||
'paymentConfig.subtitle',
|
||||
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||
)}
|
||||
noMargin
|
||||
action={
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconCreditCard size={22} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
variant="light"
|
||||
|
||||
@@ -247,7 +247,7 @@ export function ProfilePage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={900}>
|
||||
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
|
||||
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} noMargin />
|
||||
|
||||
{/* Profile summary */}
|
||||
<Paper p="lg" shadow="sm" radius="lg" withBorder>
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Badge,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Group, Button, Badge, Modal, Text, TextInput, Select, NumberInput, Textarea} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import {IconPlus} from '@tabler/icons-react';
|
||||
import { AdvancedColumn, AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
||||
@@ -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>[] = [
|
||||
...questionColumns(t, { locale, getCertName }),
|
||||
@@ -216,16 +203,19 @@ export function QuestionPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Title order={2}>{t('question.title')}</Title>
|
||||
{!showForm && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('question.addQuestion')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
<PageHeader
|
||||
title={t('question.title')}
|
||||
noMargin
|
||||
action={
|
||||
!showForm && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('question.addQuestion')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<QuestionForm
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Button, Center, Group, Loader, Modal, Paper, Select, Stack, Text, Textarea} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
@@ -75,12 +62,11 @@ export function ExamAppealsPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2}>{t('result.appeals.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('result.appeals.subtitle')}
|
||||
</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={t('result.appeals.title')}
|
||||
subtitle={t('result.appeals.subtitle')}
|
||||
noMargin
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<AdvancedTable<ExamAppeal>
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
import { useState, useCallback, type ElementType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Table,
|
||||
Badge,
|
||||
Modal,
|
||||
Text,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Button,
|
||||
ThemeIcon,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconUser,
|
||||
IconCertificate,
|
||||
IconDeviceFloppy,
|
||||
IconPlus,
|
||||
IconClipboardList,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconChartBar,
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput, useErrorHandler, AdvancedTable, StatusBadge, useServerTable, ModalFooter } from '@ema-platform/ui';
|
||||
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
|
||||
import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
||||
@@ -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 = [
|
||||
...resultColumns(t, locale, showDate, getExamTitle),
|
||||
@@ -294,12 +264,12 @@ export function ResultPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t('result.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
<PageHeader
|
||||
title={t('result.title')}
|
||||
subtitle={t('result.subtitle')}
|
||||
noMargin
|
||||
action={
|
||||
<Group gap="sm">
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -316,8 +286,9 @@ export function ResultPage() {
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||
{t('result.record')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
|
||||
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
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 { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type SeafarerDocumentRow,
|
||||
type SeafarerDocumentStatus,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageHeader, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -91,6 +91,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Waiting',
|
||||
accessorKey: 'submittedAt',
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<WaitingFor
|
||||
since={row.original.submittedAt}
|
||||
done={row.original.status === 'ISSUED' || row.original.status === 'REJECTED'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
@@ -106,13 +117,10 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
{SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Requests released by an approved seafarer registration: confirm payment, schedule the
|
||||
collection date, then issue.
|
||||
</Text>
|
||||
<PageHeader
|
||||
title={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue`}
|
||||
subtitle="Requests released by an approved seafarer registration: confirm payment, schedule the collection date, then issue."
|
||||
/>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Center, Container, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
@@ -29,7 +14,7 @@ import {
|
||||
useRejectSeafarerDocumentMutation,
|
||||
useScheduleSeafarerDocumentMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, notify } from '@ema-platform/ui';
|
||||
import { AmharicDatePicker, notify, PageHeader } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
@@ -123,12 +108,10 @@ export function SeafarerDocumentReviewPage() {
|
||||
>
|
||||
Back to queue
|
||||
</Button>
|
||||
<Group justify="space-between" align="flex-start" mb="md">
|
||||
<div>
|
||||
<Title order={3}>
|
||||
{kindLabel} — {applicant?.name ?? '—'}
|
||||
</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<PageHeader
|
||||
title={`${kindLabel} — ${applicant?.name ?? '—'}`}
|
||||
meta={
|
||||
<>
|
||||
<Text size="sm" c="dimmed" ff="monospace">
|
||||
{document.requestNumber}
|
||||
</Text>
|
||||
@@ -140,9 +123,10 @@ export function SeafarerDocumentReviewPage() {
|
||||
{document.documentNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||
<Button loading={confirming} onClick={() => run(() => confirmPayment(id).unwrap(), 'Payment confirmed')}>
|
||||
@@ -174,8 +158,9 @@ export function SeafarerDocumentReviewPage() {
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{document.status === 'REJECTED' && (
|
||||
<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}>
|
||||
Seafarer
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
<Row label="Name" value={applicant?.name} />
|
||||
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
||||
@@ -212,7 +197,7 @@ export function SeafarerDocumentReviewPage() {
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Payment
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||
@@ -226,7 +211,7 @@ export function SeafarerDocumentReviewPage() {
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Issuance
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||
<Row label="Document №" value={document.documentNumber} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
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 { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type SeafarerRegistration,
|
||||
type SeafarerRegistrationStatus,
|
||||
} 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';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -78,6 +78,17 @@ export function SeafarerRegistrationQueuePage() {
|
||||
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Waiting',
|
||||
accessorKey: 'submittedAt',
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<WaitingFor
|
||||
since={row.original.submittedAt}
|
||||
done={row.original.status === 'APPROVED' || row.original.status === 'REJECTED'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
@@ -94,13 +105,10 @@ export function SeafarerRegistrationQueuePage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
Seafarer Registration Queue
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and
|
||||
BTC applications.
|
||||
</Text>
|
||||
<PageHeader
|
||||
title="Seafarer Registration Queue"
|
||||
subtitle="Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and BTC applications."
|
||||
/>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||
@@ -31,7 +15,7 @@ import {
|
||||
useRejectSeafarerRegistrationMutation,
|
||||
useRequestSeafarerRegistrationChangesMutation,
|
||||
} 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 { 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">
|
||||
Back to queue
|
||||
</Button>
|
||||
<Group justify="space-between" align="flex-start" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{applicantName(registration)}</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<PageHeader
|
||||
title={applicantName(registration)}
|
||||
meta={
|
||||
<>
|
||||
<Text size="sm" c="dimmed" ff="monospace">
|
||||
{registration.registrationNumber}
|
||||
</Text>
|
||||
@@ -125,9 +109,10 @@ export function SeafarerRegistrationReviewPage() {
|
||||
{registration.seafarerNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
{canDecide && (
|
||||
<>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REQUEST_ADJUSTMENT]} hideOnly>
|
||||
@@ -147,8 +132,9 @@ export function SeafarerRegistrationReviewPage() {
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{registration.status === 'RESUBMIT_REQUIRED' && (
|
||||
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
|
||||
@@ -168,7 +154,7 @@ export function SeafarerRegistrationReviewPage() {
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
{section.title}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
{section.fields
|
||||
.filter((f) => f !== 'passportExpiry' || registration.passportNumber)
|
||||
@@ -193,7 +179,7 @@ export function SeafarerRegistrationReviewPage() {
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Documents
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
{slots.map((slot) => {
|
||||
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import { StatusBadge } from '@ema-platform/ui';
|
||||
import { PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useState } from 'react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {ActionIcon, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
@@ -171,10 +152,11 @@ export function SeafarerRegistryPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registry</Title>
|
||||
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Seafarer Registry"
|
||||
subtitle="Search and view all registered seafarers, their documents, and certificate status"
|
||||
noMargin
|
||||
/>
|
||||
|
||||
{/* KPIs */}
|
||||
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
|
||||
|
||||
@@ -336,7 +336,7 @@ export function VesselRegistrationFormBuilderPage() {
|
||||
<IconSettings size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Vessel Registration Form Builder</Title>
|
||||
<Title order={2}>Vessel Registration Form Builder</Title>
|
||||
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {Alert, Badge, Button, Card, Container, Drawer, Group, Loader, Modal, Select, Stack, Table, Text, TextInput, Textarea} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -238,26 +221,28 @@ export function VesselRegistrationQueuePage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Vessel register</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
<PageHeader
|
||||
title="Vessel register"
|
||||
subtitle={
|
||||
<>
|
||||
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'} —
|
||||
pending registrations are reviewed in the{' '}
|
||||
<Text component={Link} to="/licence-review" inherit c="blue">
|
||||
licence queue
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Name, registration № or IMO"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<TextInput
|
||||
placeholder="Name, registration № or IMO"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<AdvancedTable
|
||||
tableName="Vessel register"
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Alert, Container, Group, Text, Title } from '@mantine/core';
|
||||
import {Alert, Container, Group, Text} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
|
||||
import {
|
||||
ApiErrorAlert,
|
||||
EmptyState,
|
||||
PageLoader,
|
||||
notify,
|
||||
} from '@ema-platform/ui';
|
||||
import { ApiErrorAlert, EmptyState, notify, PageHeader, PageLoader } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
downloadAuthedFile,
|
||||
@@ -85,16 +80,14 @@ export function VesselRegistrationReportPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Vessel registration report</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{report
|
||||
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
||||
: 'The national vessel register at a glance.'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<PageHeader
|
||||
title="Vessel registration report"
|
||||
subtitle={
|
||||
report
|
||||
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
||||
: 'The national vessel register at a glance.'
|
||||
}
|
||||
/>
|
||||
|
||||
<ReportFilters
|
||||
query={query}
|
||||
|
||||
@@ -26,6 +26,7 @@ export * from "./lib/input/CountrySelect";
|
||||
export * from "./lib/input/PhoneInput";
|
||||
export * from "./lib/input/phone";
|
||||
export * from "./lib/data/AdvancedTable";
|
||||
export * from "./lib/data/WaitingFor";
|
||||
export * from "./lib/feedback/use-error-handler";
|
||||
export * from "./lib/data/useServerTable";
|
||||
export * from "./lib/landing/LandingPage";
|
||||
|
||||
83
libs/ui/src/lib/data/WaitingFor.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,54 @@ import { Group, Stack, Text, Title } from '@mantine/core';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
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). */
|
||||
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 (
|
||||
<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}>
|
||||
<Title order={2}>{title}</Title>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2}>{title}</Title>
|
||||
{meta}
|
||||
</Group>
|
||||
{subtitle && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{subtitle}
|
||||
|
||||
Reference in New Issue
Block a user