mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'ui_ux' of https://github.com/Tria-plc/emaui into ui_ux
This commit is contained in:
@@ -44,6 +44,7 @@ export function CertificateRequirementsPage() {
|
|||||||
'certReq.subtitle',
|
'certReq.subtitle',
|
||||||
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
|
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
|
||||||
)}
|
)}
|
||||||
|
noMargin
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {Stack, Button, Modal, Text, TextInput, Textarea, Card} from '@mantine/core';
|
||||||
Stack,
|
|
||||||
Title,
|
|
||||||
Group,
|
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Textarea,
|
|
||||||
Card,
|
|
||||||
Alert,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
import {IconPlus} from '@tabler/icons-react';
|
||||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
useGetCertificationsQuery,
|
useGetCertificationsQuery,
|
||||||
useCreateCertificationMutation,
|
useCreateCertificationMutation,
|
||||||
@@ -120,7 +109,8 @@ export function CertificationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
if (isError)
|
||||||
|
return <ErrorState title={t('certification.loadError')} onRetry={refetch} />;
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
...certificationColumns(t, locale),
|
...certificationColumns(t, locale),
|
||||||
@@ -134,17 +124,18 @@ export function CertificationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<PageHeader
|
||||||
<div>
|
title={t('certification.title')}
|
||||||
<Title order={2}>{t('certification.title')}</Title>
|
subtitle={t('certification.subtitle')}
|
||||||
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text>
|
noMargin
|
||||||
</div>
|
action={
|
||||||
{!showForm && (
|
!showForm && (
|
||||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||||
{t('certification.add')}
|
{t('certification.add')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<CertificationForm
|
<CertificationForm
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
import type { Profession } from "../../types/configuration";
|
import type { Profession } from "../../types/configuration";
|
||||||
import { professionColumns } from "./columns";
|
import { professionColumns } from "./columns";
|
||||||
import { professionActionsColumn } from "./actions";
|
import { professionActionsColumn } from "./actions";
|
||||||
|
import { PageHeader } from '@ema-platform/ui';
|
||||||
|
|
||||||
interface ProfFormValues {
|
interface ProfFormValues {
|
||||||
nameEn: string;
|
nameEn: string;
|
||||||
@@ -378,7 +379,7 @@ export function ConfigurationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Title order={2}>{t("configuration.title")}</Title>
|
<PageHeader title={t("configuration.title")} noMargin />
|
||||||
|
|
||||||
<Tabs defaultValue="professions">
|
<Tabs defaultValue="professions">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
|
|||||||
@@ -1,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) => (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Title,
|
|
||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -10,7 +9,6 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
Card,
|
Card,
|
||||||
Alert,
|
|
||||||
Select,
|
Select,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -35,6 +33,7 @@ import {
|
|||||||
import type { Exam } from "../../types/exam";
|
import type { Exam } from "../../types/exam";
|
||||||
import { examColumns } from "./columns";
|
import { examColumns } from "./columns";
|
||||||
import { examActionsColumn } from "./actions";
|
import { examActionsColumn } from "./actions";
|
||||||
|
import { ErrorState, PageHeader } from '@ema-platform/ui';
|
||||||
|
|
||||||
function ExamForm({
|
function ExamForm({
|
||||||
editing,
|
editing,
|
||||||
@@ -416,13 +415,7 @@ export function ExamPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isError)
|
if (isError)
|
||||||
return (
|
return <ErrorState title={t("exam.loadError")} onRetry={refetch} />;
|
||||||
<Alert
|
|
||||||
icon={<IconInfoCircle size={16} />}
|
|
||||||
color="red"
|
|
||||||
title={t("exam.loadError")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
|
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
|
||||||
@@ -443,26 +436,25 @@ export function ExamPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<PageHeader
|
||||||
<div>
|
title={t("exam.title")}
|
||||||
<Title order={2}>{t("exam.title")}</Title>
|
subtitle={t("exam.subtitle")}
|
||||||
<Text fz="sm" c="dimmed">
|
noMargin
|
||||||
{t("exam.subtitle")}
|
action={
|
||||||
</Text>
|
!showForm && (
|
||||||
</div>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||||
{!showForm && (
|
<Button
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
variant="light"
|
||||||
<Button
|
leftSection={<IconPlus size={16} />}
|
||||||
variant="light"
|
onClick={() => setShowForm(true)}
|
||||||
leftSection={<IconPlus size={16} />}
|
size="sm"
|
||||||
onClick={() => setShowForm(true)}
|
>
|
||||||
size="sm"
|
{t("exam.add")}
|
||||||
>
|
</Button>
|
||||||
{t("exam.add")}
|
</RequirePermission>
|
||||||
</Button>
|
)
|
||||||
</RequirePermission>
|
}
|
||||||
)}
|
/>
|
||||||
</Group>
|
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<ExamForm
|
<ExamForm
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Stack, Title, Paper } from '@mantine/core';
|
import {Stack, Paper} from '@mantine/core';
|
||||||
import { ItemTable } from '../components/ItemTable';
|
import { ItemTable } from '../components/ItemTable';
|
||||||
|
import { PageHeader } from '@ema-platform/ui';
|
||||||
|
|
||||||
export function ItemPage() {
|
export function ItemPage() {
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Title order={2}>Items</Title>
|
<PageHeader title="Items" noMargin />
|
||||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||||
<ItemTable />
|
<ItemTable />
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Textarea} from '@mantine/core';
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
@@ -151,21 +139,19 @@ export function LicenseRegisterPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Group justify="space-between" mb="md">
|
<PageHeader
|
||||||
<div>
|
title="Licence register"
|
||||||
<Title order={3}>Licence register</Title>
|
subtitle={`${data?.total ?? 0} issued licence${(data?.total ?? 0) === 1 ? '' : 's'}`}
|
||||||
<Text size="sm" c="dimmed">
|
action={
|
||||||
{data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'}
|
<TextInput
|
||||||
</Text>
|
placeholder="Certificate № or company"
|
||||||
</div>
|
leftSection={<IconSearch size={14} />}
|
||||||
<TextInput
|
value={search}
|
||||||
placeholder="Certificate № or company"
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
leftSection={<IconSearch size={14} />}
|
w={280}
|
||||||
value={search}
|
/>
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
}
|
||||||
w={280}
|
/>
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Card withBorder padding={0}>
|
<Card withBorder padding={0}>
|
||||||
<AdvancedTable<IssuedLicense>
|
<AdvancedTable<IssuedLicense>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +71,7 @@ import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
|
|||||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
|
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
|
||||||
import { licenseQueueColumns } from "./columns";
|
import { licenseQueueColumns } from "./columns";
|
||||||
import { licenseQueueActionsColumn } from "./actions";
|
import { licenseQueueActionsColumn } from "./actions";
|
||||||
|
import { PageHeader } from '@ema-platform/ui';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
@@ -501,16 +501,11 @@ export function LicenseQueuePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
||||||
<Group justify="space-between" mb="md">
|
<PageHeader
|
||||||
<div>
|
title={queueTitle}
|
||||||
<Title order={3}>{queueTitle}</Title>
|
subtitle={typeCode ? t(`nav.type${typeCode}`, { defaultValue: typeCode }) : undefined}
|
||||||
{typeCode && (
|
action={
|
||||||
<Text size="sm" c="dimmed">
|
<Group gap="xs">
|
||||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Group gap="xs">
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
size="xs"
|
size="xs"
|
||||||
value={density}
|
value={density}
|
||||||
@@ -534,8 +529,9 @@ export function LicenseQueuePage() {
|
|||||||
>
|
>
|
||||||
{t("queue.export", "Export CSV")}
|
{t("queue.export", "Export CSV")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Saved views, counted. */}
|
{/* Saved views, counted. */}
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -724,7 +724,7 @@ export function LicenseReviewPage() {
|
|||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<div>
|
<div>
|
||||||
<Title order={3}>{headerName}</Title>
|
<Title order={2}>{headerName}</Title>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{app.applicationNumber}
|
{app.applicationNumber}
|
||||||
|
|||||||
@@ -1,23 +1,9 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import {
|
import {Stack, Group, Button, Paper, Text, Grid, Modal, ActionIcon, Tooltip, Loader, Center, Alert} from '@mantine/core';
|
||||||
Stack,
|
|
||||||
Title,
|
|
||||||
Group,
|
|
||||||
Button,
|
|
||||||
Paper,
|
|
||||||
Text,
|
|
||||||
Grid,
|
|
||||||
Modal,
|
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
Loader,
|
|
||||||
Center,
|
|
||||||
Alert,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui';
|
import { ModalFooter, notify, PageHeader, PageLoader, useErrorHandler } from '@ema-platform/ui';
|
||||||
import { LocationTree } from '../components/LocationTree';
|
import { LocationTree } from '../components/LocationTree';
|
||||||
import { LocationDetail } from '../components/LocationDetail';
|
import { LocationDetail } from '../components/LocationDetail';
|
||||||
import { LocationForm } from '../components/LocationForm';
|
import { LocationForm } from '../components/LocationForm';
|
||||||
@@ -108,9 +94,11 @@ export function LocationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between">
|
<PageHeader
|
||||||
<Title order={2}>{t('location.title')}</Title>
|
title={t('location.title')}
|
||||||
<Group gap="sm">
|
noMargin
|
||||||
|
action={
|
||||||
|
<Group gap="sm">
|
||||||
{locationTypes.length > 0 && (
|
{locationTypes.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -135,8 +123,9 @@ export function LocationPage() {
|
|||||||
<IconSettings size={20} />
|
<IconSettings size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{locationTypes.length === 0 && (
|
{locationTypes.length === 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {Badge, Card, Center, Container, Grid, Group, Loader, SimpleGrid, Stack, Text} from '@mantine/core';
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
Center,
|
|
||||||
Container,
|
|
||||||
Grid,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconChevronRight } from '@tabler/icons-react';
|
import { IconChevronRight } from '@tabler/icons-react';
|
||||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
@@ -74,12 +62,10 @@ export function LogisticsHeadDashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Title order={3} mb="xs">
|
<PageHeader
|
||||||
Logistics overview
|
title="Logistics overview"
|
||||||
</Title>
|
subtitle="Licence applications currently in the department."
|
||||||
<Text size="sm" c="dimmed" mb="lg">
|
/>
|
||||||
Licence applications currently in the department.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
|
|||||||
@@ -1,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {Alert, Button, Center, Group, Loader, Modal, Paper, Select, Stack, Text, Textarea} from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconInfoCircle } from '@tabler/icons-react';
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { AdvancedTable, notify, useServerTable, PageLoader } from '@ema-platform/ui';
|
import { AdvancedTable, notify, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import { extractErrorMessage } from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
@@ -75,12 +62,11 @@ export function ExamAppealsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<div>
|
<PageHeader
|
||||||
<Title order={2}>{t('result.appeals.title')}</Title>
|
title={t('result.appeals.title')}
|
||||||
<Text fz="sm" c="dimmed">
|
subtitle={t('result.appeals.subtitle')}
|
||||||
{t('result.appeals.subtitle')}
|
noMargin
|
||||||
</Text>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
<Paper withBorder radius="md">
|
||||||
<AdvancedTable<ExamAppeal>
|
<AdvancedTable<ExamAppeal>
|
||||||
|
|||||||
@@ -1,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" />
|
||||||
|
|||||||
@@ -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 ?? []}
|
||||||
|
|||||||
@@ -1,21 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {Alert, Badge, Button, Center, Container, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||||
@@ -29,7 +14,7 @@ import {
|
|||||||
useRejectSeafarerDocumentMutation,
|
useRejectSeafarerDocumentMutation,
|
||||||
useScheduleSeafarerDocumentMutation,
|
useScheduleSeafarerDocumentMutation,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { AmharicDatePicker, notify } from '@ema-platform/ui';
|
import { AmharicDatePicker, notify, PageHeader } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
|
|
||||||
@@ -123,12 +108,10 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
>
|
>
|
||||||
Back to queue
|
Back to queue
|
||||||
</Button>
|
</Button>
|
||||||
<Group justify="space-between" align="flex-start" mb="md">
|
<PageHeader
|
||||||
<div>
|
title={`${kindLabel} — ${applicant?.name ?? '—'}`}
|
||||||
<Title order={3}>
|
meta={
|
||||||
{kindLabel} — {applicant?.name ?? '—'}
|
<>
|
||||||
</Title>
|
|
||||||
<Group gap="xs" mt={4}>
|
|
||||||
<Text size="sm" c="dimmed" ff="monospace">
|
<Text size="sm" c="dimmed" ff="monospace">
|
||||||
{document.requestNumber}
|
{document.requestNumber}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -140,9 +123,10 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
{document.documentNumber}
|
{document.documentNumber}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</>
|
||||||
</div>
|
}
|
||||||
<Group gap="xs">
|
action={
|
||||||
|
<Group gap="xs">
|
||||||
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
|
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||||
<Button loading={confirming} onClick={() => run(() => confirmPayment(id).unwrap(), 'Payment confirmed')}>
|
<Button loading={confirming} onClick={() => run(() => confirmPayment(id).unwrap(), 'Payment confirmed')}>
|
||||||
@@ -174,8 +158,9 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{document.status === 'REJECTED' && (
|
{document.status === 'REJECTED' && (
|
||||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
|
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
|
||||||
@@ -189,7 +174,7 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
<Text fw={600} size="sm" mb={4}>
|
<Text fw={600} size="sm" mb={4}>
|
||||||
Seafarer
|
Seafarer
|
||||||
</Text>
|
</Text>
|
||||||
<Table withTableBorder withColumnBorders>
|
<Table variant="vertical">
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
<Row label="Name" value={applicant?.name} />
|
<Row label="Name" value={applicant?.name} />
|
||||||
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
||||||
@@ -212,7 +197,7 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
<Text fw={600} size="sm" mb={4}>
|
<Text fw={600} size="sm" mb={4}>
|
||||||
Payment
|
Payment
|
||||||
</Text>
|
</Text>
|
||||||
<Table withTableBorder withColumnBorders>
|
<Table variant="vertical">
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||||
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||||
@@ -226,7 +211,7 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
<Text fw={600} size="sm" mb={4}>
|
<Text fw={600} size="sm" mb={4}>
|
||||||
Issuance
|
Issuance
|
||||||
</Text>
|
</Text>
|
||||||
<Table withTableBorder withColumnBorders>
|
<Table variant="vertical">
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||||
<Row label="Document №" value={document.documentNumber} />
|
<Row label="Document №" value={document.documentNumber} />
|
||||||
|
|||||||
@@ -1,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 ?? []}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ export function VesselRegistrationFormBuilderPage() {
|
|||||||
<IconSettings size={24} />
|
<IconSettings size={24} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<div>
|
<div>
|
||||||
<Title order={3}>Vessel Registration Form Builder</Title>
|
<Title order={2}>Vessel Registration Form Builder</Title>
|
||||||
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
|
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,29 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {Alert, Badge, Button, Card, Container, Drawer, Group, Loader, Modal, Select, Stack, Table, Text, TextInput, Textarea} from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Container,
|
|
||||||
Drawer,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
@@ -238,26 +221,28 @@ export function VesselRegistrationQueuePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Group justify="space-between" mb="md">
|
<PageHeader
|
||||||
<div>
|
title="Vessel register"
|
||||||
<Title order={3}>Vessel register</Title>
|
subtitle={
|
||||||
<Text size="sm" c="dimmed">
|
<>
|
||||||
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'} —
|
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'} —
|
||||||
pending registrations are reviewed in the{' '}
|
pending registrations are reviewed in the{' '}
|
||||||
<Text component={Link} to="/licence-review" inherit c="blue">
|
<Text component={Link} to="/licence-review" inherit c="blue">
|
||||||
licence queue
|
licence queue
|
||||||
</Text>
|
</Text>
|
||||||
.
|
.
|
||||||
</Text>
|
</>
|
||||||
</div>
|
}
|
||||||
<TextInput
|
action={
|
||||||
placeholder="Name, registration № or IMO"
|
<TextInput
|
||||||
leftSection={<IconSearch size={14} />}
|
placeholder="Name, registration № or IMO"
|
||||||
value={search}
|
leftSection={<IconSearch size={14} />}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
value={search}
|
||||||
w={280}
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
/>
|
w={280}
|
||||||
</Group>
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
tableName="Vessel register"
|
tableName="Vessel register"
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Alert, Container, Group, Text, Title } from '@mantine/core';
|
import {Alert, Container, Group, Text} from '@mantine/core';
|
||||||
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
|
||||||
import {
|
import { ApiErrorAlert, EmptyState, notify, PageHeader, PageLoader } from '@ema-platform/ui';
|
||||||
ApiErrorAlert,
|
|
||||||
EmptyState,
|
|
||||||
PageLoader,
|
|
||||||
notify,
|
|
||||||
} from '@ema-platform/ui';
|
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
downloadAuthedFile,
|
downloadAuthedFile,
|
||||||
@@ -85,16 +80,14 @@ export function VesselRegistrationReportPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Group justify="space-between" mb="md" align="flex-start">
|
<PageHeader
|
||||||
<div>
|
title="Vessel registration report"
|
||||||
<Title order={3}>Vessel registration report</Title>
|
subtitle={
|
||||||
<Text size="sm" c="dimmed">
|
report
|
||||||
{report
|
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
||||||
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
: 'The national vessel register at a glance.'
|
||||||
: 'The national vessel register at a glance.'}
|
}
|
||||||
</Text>
|
/>
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<ReportFilters
|
<ReportFilters
|
||||||
query={query}
|
query={query}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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';
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user