diff --git a/.gitignore b/.gitignore index b833848bb..4b15bf545 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ apps/backoffice/public/_um/ apps/backoffice/public/tinymce/ local-packages/iamui-extracted/ + +# Playwright visual-regression artifacts (baselines under apps/e2e/visual are tracked) +test-results/ +dist/visual-report/ diff --git a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx index 8e9414a0a..86352bb97 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx @@ -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 ? ( diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx index 1c5893ab3..717311b65 100644 --- a/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx @@ -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 } color="red" title={t('certification.loadError')} />; + if (isError) + return ; const columns = [ ...certificationColumns(t, locale), @@ -134,17 +124,18 @@ export function CertificationPage() { return ( - -
- {t('certification.title')} - {t('certification.subtitle')} -
- {!showForm && ( - - )} -
+ } onClick={() => setShowForm(true)} size="sm"> + {t('certification.add')} + + ) + } + /> {showForm && ( - {t("configuration.title")} + diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx index 927ca47a3..0d5064448 100644 --- a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx +++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx @@ -1,25 +1,42 @@ import { useNavigate } from 'react-router-dom'; +import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core'; import { - Card, - Center, - Container, - Group, - Loader, - SimpleGrid, - Text, - Title, -} from '@mantine/core'; -import { IconChevronRight } from '@tabler/icons-react'; -import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api'; -import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui'; + IconAlertTriangle, + IconCreditCard, + IconFileText, + IconInbox, + IconUserCheck, +} from '@tabler/icons-react'; +import { + useGetAssignedToMeQuery, + useGetQueueQuery, + useListSeafarerDocumentsQuery, + useListSeafarerRegistrationsQuery, + type LicenseApplication, +} from '@ema-platform/api'; +import { + AdvancedTable, + PageHeader, + PageLoader, + StatTile, + WaitingFor, + useServerTable, +} from '@ema-platform/ui'; import { dashboardQueueColumns } from './columns'; /** * Backoffice home. * - * Shows the licence pipeline, which is the part of the platform that has real - * data behind it. The previous version charted invented registration volumes - * and a fictional breakdown of staff roles. + * Every figure here is counted from a queue the officer can open, and each + * tile navigates to the list it counted — a dashboard that cannot be drilled + * into is a poster. Nothing is charted: the platform exposes queues, not time + * series, and an earlier version of this page invented both a registration + * trend and a staff-role breakdown rather than admit that. + * + * The seafarer counts are fetched with `take: 1`, for `total` alone. Both + * queues are permission-gated and an officer without them simply gets no + * count — never a broken page — so the tiles read `—` rather than `0`, which + * would be a lie. */ export function DashboardPage() { const navigate = useNavigate(); @@ -27,6 +44,13 @@ export function DashboardPage() { const mine = useGetAssignedToMeQuery(); const table = useServerTable(); + const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 }); + const seamanBooks = useListSeafarerDocumentsQuery({ + kind: 'SEAMAN_BOOK', + status: 'PAYMENT_PENDING', + take: 1, + }); + if (queue.isLoading || mine.isLoading) { return ; } @@ -34,73 +58,143 @@ export function DashboardPage() { const unclaimed = queue.data?.items ?? []; const inProgress = mine.data?.items ?? []; const all = [...unclaimed, ...inProgress]; - const paged = table.paginate(unclaimed.slice(0, 8)); - const stats = [ - { label: 'Awaiting claim', value: unclaimed.length, color: 'blue' }, - { label: 'Assigned to me', value: inProgress.length, color: 'indigo' }, - { - label: 'Needs applicant action', - value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length, - color: 'orange', - }, - { - label: 'Awaiting payment', - value: all.filter((a) => a.status === 'PAYMENT_PENDING').length, - color: 'yellow', - }, - ]; + const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length; + const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length; + + /** Oldest first: a queue is worked by age, so the dashboard previews it that way. */ + const byAge = [...unclaimed].sort((a, b) => + (a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt), + ); + const paged = table.paginate(byAge.slice(0, 8)); + + /** `undefined` while loading or forbidden — rendered as "—", never as 0. */ + const countOf = (q: { data?: { total: number }; isError: boolean }) => + q.isError ? undefined : q.data?.total; + + const show = (n: number | undefined) => (n === undefined ? '—' : n); return ( - - - Dashboard - - - Licence applications currently in the system. - + + - - {stats.map((stat) => ( - - - {stat.label} - - - {stat.value} - - - ))} + + navigate('/licence-review')} + /> + navigate('/licence-review')} + /> + + - - - - Awaiting claim - - navigate('/licence-review')} - > - Open queue - - - navigate('/licence-review')} - refresh={queue.refetch} - emptyText="Nothing waiting to be claimed." + {/* Same 4-column track as the row above, so a two-tile row lines up with + it instead of stretching each tile to half the page. */} + + navigate('/seafarer-registrations')} /> - - + navigate('/seaman-book-queue')} + /> + + + + + + title="Awaiting claim — oldest first" + tableName="Awaiting claim" + columns={dashboardQueueColumns} + data={paged.rows} + itemCount={paged.itemCount} + pageIndex={paged.pageIndex} + onPageChange={table.setPageIndex} + pageSize={table.pageSize} + onRowClick={(row) => navigate(`/licence-review/${row.id}`)} + refresh={queue.refetch} + isLoading={queue.isFetching} + emptyText="Nothing waiting to be claimed." + toolbar={ + navigate('/licence-review')}> + Open queue + + } + /> + + + + + + Longest waiting + + + Unclaimed applications, by how long they have sat. + + + {byAge.slice(0, 5).map((app) => ( + + navigate(`/licence-review/${app.id}`)} + > + {app.applicationNumber} + + + + ))} + {byAge.length === 0 && ( + + Nothing waiting. + + )} + + + + +
); } diff --git a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx index 5d5fab6fd..16dfffc1f 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx @@ -1,15 +1,17 @@ +import { type StatusTone } from '@ema-platform/shared'; import { Badge, Button, Text } from '@mantine/core'; import { IconAlertTriangle } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { StatusBadge } from '@ema-platform/ui'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import type { ExamIncident, ExamIncidentStatus } from '../../types/exam'; -const STATUS_COLOR: Record = { - OPEN: 'red', - UNDER_REVIEW: 'yellow', - RESOLVED: 'teal', - DISMISSED: 'gray', +const STATUS_TONE: Record = { + OPEN: 'danger', + UNDER_REVIEW: 'warning', + RESOLVED: 'success', + DISMISSED: 'neutral', }; export function examIncidentColumns( @@ -56,13 +58,12 @@ export function examIncidentColumns( { header: t('exam.incidents.status'), cell: ({ row }) => ( - - {t(`exam.incidentStatus.${row.original.status}`)} - + /> ), }, { diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index 6c09dd93b..f52b29c91 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -1,3 +1,4 @@ +import { type StatusTone } from '@ema-platform/shared'; import { useState, useEffect, useRef, useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { @@ -39,7 +40,7 @@ import { IconCheck, IconX, } from '@tabler/icons-react'; -import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui'; +import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { @@ -57,13 +58,13 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel'; import { PageLoader } from '@ema-platform/ui'; import type { ExamStatus, QuestionBrief } from '../types/exam'; -const STATUS_COLOR: Record = { - PENDING: "gray", - ACTIVE: "blue", - COMPLETED: "teal", - CANCELLED: "red", - POSTPONED: "orange", - PUBLISHED: "green", +const STATUS_TONE: Record = { + PENDING: 'neutral', + ACTIVE: 'info', + COMPLETED: 'success', + CANCELLED: 'danger', + POSTPONED: 'pending', + PUBLISHED: 'success', }; const FORM_LABEL: Record = { ESSAY: "Essay", CHOICE: "Choice" }; @@ -290,7 +291,7 @@ export function ExamDetailPage() {
- {exam.title[locale]} + {exam.title[locale]}
@@ -313,14 +314,13 @@ export function ExamDetailPage() { {/* Status badge */} - - {t(`exam.status.${exam.status}`)} - + /> {/* Exam Info */} diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/columns.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/columns.tsx index a5ec1970f..4fddd6d14 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/columns.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/columns.tsx @@ -1,15 +1,17 @@ +import { type StatusTone } from '@ema-platform/shared'; import { Badge, Text } from "@mantine/core"; import type { TFunction } from "i18next"; import type { AdvancedColumn } from "@ema-platform/ui"; +import { StatusBadge } from '@ema-platform/ui'; import type { Exam } from "../../types/exam"; -const STATUS_COLOR: Record = { - PENDING: "gray", - ACTIVE: "blue", - COMPLETED: "teal", - CANCELLED: "red", - POSTPONED: "orange", - PUBLISHED: "green", +const STATUS_TONE: Record = { + PENDING: 'neutral', + ACTIVE: 'info', + COMPLETED: 'success', + CANCELLED: 'danger', + POSTPONED: 'pending', + PUBLISHED: 'success', }; export function examColumns( @@ -72,9 +74,12 @@ export function examColumns( { header: t("exam.columns.status"), cell: ({ row }) => ( - - {t(`exam.status.${row.original.status}`)} - + ), }, ]; diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx index c34e5975b..cd5d7c192 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -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 ( - } - color="red" - title={t("exam.loadError")} - /> - ); + return ; const columns = [ ...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)), @@ -443,26 +436,25 @@ export function ExamPage() { return ( - -
- {t("exam.title")} - - {t("exam.subtitle")} - -
- {!showForm && ( - - - - )} -
+ + + + ) + } + /> {showForm && ( = { - DRAFT: 'gray', - ACTIVE: 'green', - ARCHIVED: 'orange', +const STATUS_TONES: Record = { + DRAFT: 'neutral', + ACTIVE: 'success', + ARCHIVED: 'pending', }; export function itemColumns(showDate: (date: string) => string): AdvancedColumn[] { @@ -14,7 +15,7 @@ export function itemColumns(showDate: (date: string) => string): AdvancedColumn< { header: 'Status', cell: ({ row }) => ( - {row.original.status} + ), }, { diff --git a/apps/backoffice/src/app/features/item/pages/ItemPage.tsx b/apps/backoffice/src/app/features/item/pages/ItemPage.tsx index 17eb1c237..32fa7aa9b 100644 --- a/apps/backoffice/src/app/features/item/pages/ItemPage.tsx +++ b/apps/backoffice/src/app/features/item/pages/ItemPage.tsx @@ -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 ( - Items + diff --git a/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx index b168c8908..965c9b4d5 100644 --- a/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx +++ b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx @@ -1,15 +1,17 @@ -import { Badge, Button, Text, Tooltip } from '@mantine/core'; +import { type StatusTone } from '@ema-platform/shared'; +import { Button, Text, Tooltip } from '@mantine/core'; import { IconShieldCog } from '@tabler/icons-react'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { StatusBadge } from '@ema-platform/ui'; import type { Bilingual, IssuedLicense } from '@ema-platform/api'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; -const LICENSE_STATUS_COLORS: Record = { - ACTIVE: 'green', - EXPIRED: 'yellow', - SUSPENDED: 'orange', - CANCELLED: 'red', - SUPERSEDED: 'gray', +const LICENSE_STATUS_TONES: Record = { + ACTIVE: 'success', + EXPIRED: 'warning', + SUSPENDED: 'pending', + CANCELLED: 'danger', + SUPERSEDED: 'neutral', }; export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate'; @@ -70,13 +72,12 @@ export function licenseRegisterColumns( { header: 'Status', cell: ({ row }) => ( - - {row.original.status} - + /> ), }, { diff --git a/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/index.tsx b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/index.tsx index 754d0af80..f57d0c933 100644 --- a/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/index.tsx +++ b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/index.tsx @@ -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 ( - -
- Licence register - - {data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'} - -
- } - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - w={280} - /> -
+ } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + w={280} + /> + } + /> diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx index b8791eb4e..856b30581 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx @@ -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 ( - -
- {queueTitle} - {typeCode && ( - - {t(`nav.type${typeCode}`, { defaultValue: typeCode })} - - )} -
- + {t("queue.export", "Export CSV")} - -
+ + } + /> {/* Saved views, counted. */}
- {headerName} + {headerName} {app.applicationNumber} diff --git a/apps/backoffice/src/app/features/location/pages/LocationPage.tsx b/apps/backoffice/src/app/features/location/pages/LocationPage.tsx index b2d4f9e3c..e22a16b20 100644 --- a/apps/backoffice/src/app/features/location/pages/LocationPage.tsx +++ b/apps/backoffice/src/app/features/location/pages/LocationPage.tsx @@ -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 ( - - {t('location.title')} - + {locationTypes.length > 0 && ( - - )} - + + + + ) + } + /> {showForm && ( )} - - - {t('question.pool')} + - - - -
- {t('result.appeals.title')} - - {t('result.appeals.subtitle')} - -
+ diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/columns.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/columns.tsx index 7da28a3c7..5aef7a03f 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage/columns.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/columns.tsx @@ -1,11 +1,13 @@ +import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared'; import { Badge, Box, Text } from '@mantine/core'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { StatusBadge } from '@ema-platform/ui'; import type { Result, ResultReviewStatus } from '../../types/result'; -export const STATUS_COLOR: Record = { - PASSED: 'teal', - FAILED: 'red', +export const STATUS_TONE: Record = { + PASSED: 'success', + FAILED: 'danger', }; /** Where a mark sits in quality control (US-EXAM-011 → 014). */ @@ -46,20 +48,22 @@ export function resultColumns( { header: t('result.columns.status'), cell: ({ row }) => ( - } - > - {t(`result.status.${row.original.status}`)} - + /> ), }, { diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx index 583ea57d0..054a05cda 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx @@ -1,40 +1,9 @@ import { useState, useCallback, type ElementType } from 'react'; import { useTranslation } from 'react-i18next'; -import { - Stack, - Title, - Group, - Table, - Badge, - Modal, - Text, - Paper, - Card, - 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, 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'; @@ -53,7 +22,7 @@ import { useGetExamsQuery } from '../../../exam/api/exam-api'; import { RecordResultModal } from '../../components/RecordResultModal'; import type { Result, ResultBreakdown } from '../../types/result'; import type { Exam } from '../../../exam/types/exam'; -import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns'; +import { resultColumns, STATUS_TONE, REVIEW_COLOR } from './columns'; import { resultActionsColumn, type QcAction } from './actions'; function ResultStat({ @@ -280,7 +249,7 @@ export function ResultPage() { } }; - if (isError) return } color="red" title={t('result.loadError')} />; + if (isError) return ; const columns = [ ...resultColumns(t, locale, showDate, getExamTitle), @@ -295,12 +264,12 @@ export function ResultPage() { return ( - -
- {t('result.title')} - {t('result.subtitle')} -
- + - -
+
+ } + /> @@ -327,10 +297,13 @@ export function ResultPage() { - - - {t('result.section')} - + } @@ -348,23 +321,17 @@ export function ResultPage() { style={{ width: 280 }} clearable /> - - - - - + + } + itemCount={page.itemCount} + pageIndex={page.pageIndex} + onPageChange={setPageIndex} + pageSize={pageSize} + onPageSizeChange={setPageSize} + refresh={refetch} + isLoading={isFetching} + emptyText={t('result.noItems')} + /> - - {t(`result.status.${detailResult.status}`)} - + {t(`result.review.${detailResult.reviewStatus}`)} diff --git a/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentQueuePage.tsx b/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentQueuePage.tsx index 961545a97..51460e1f8 100644 --- a/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentQueuePage.tsx +++ b/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentQueuePage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core'; +import {Badge, Container, Select, Text, TextInput} from '@mantine/core'; import { IconSearch } from '@tabler/icons-react'; import { 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 {row.original.submittedAt ? showDate(row.original.submittedAt) : '—'} ), }, + { + header: 'Waiting', + accessorKey: 'submittedAt', + size: 90, + cell: ({ row }) => ( + + ), + }, { header: 'Status', accessorKey: 'status', @@ -106,40 +117,39 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind return ( - - {SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue - - - Requests released by an approved seafarer registration: confirm payment, schedule the - collection date, then issue. - - - } - value={search} - onChange={(e) => { - setSearch(e.currentTarget.value); - setPage(0); - }} - w={280} - /> - { + setStatus(v as SeafarerDocumentStatus | null); + setPage(0); + }} + clearable + w={200} + /> + + } itemCount={data?.total ?? 0} pageIndex={page} onPageChange={setPage} diff --git a/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx b/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx index a090bd576..271fae22e 100644 --- a/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx +++ b/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx @@ -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 - -
- - {kindLabel} — {applicant?.name ?? '—'} - - + {document.requestNumber} @@ -140,9 +123,10 @@ export function SeafarerDocumentReviewPage() { {document.documentNumber} )} - -
- + + } + action={ + {(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && ( )} - - +
+ } + /> {document.status === 'REJECTED' && ( } title="Rejected" mb="md"> @@ -189,7 +174,7 @@ export function SeafarerDocumentReviewPage() { Seafarer - +
@@ -212,7 +197,7 @@ export function SeafarerDocumentReviewPage() { Payment -
+
@@ -226,7 +211,7 @@ export function SeafarerDocumentReviewPage() { Issuance -
+
diff --git a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx index 26858983f..6c9a7f1b5 100644 --- a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx +++ b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx @@ -1,17 +1,17 @@ import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core'; +import {Container, Select, Text, TextInput} from '@mantine/core'; import { IconSearch } from '@tabler/icons-react'; import { useDebouncedValue } from '@mantine/hooks'; import { - SEAFARER_REGISTRATION_STATUS_COLORS, + SEAFARER_REGISTRATION_STATUS_TONES, SEAFARER_REGISTRATION_STATUS_LABELS, displaySeafarerAnswer, useListSeafarerRegistrationsQuery, type SeafarerRegistration, type SeafarerRegistrationStatus, } from '@ema-platform/api'; -import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui'; +import { AdvancedTable, PageHeader, StatusBadge, WaitingFor, type AdvancedColumn } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; const PAGE_SIZE = 10; @@ -78,13 +78,25 @@ export function SeafarerRegistrationQueuePage() { {row.original.submittedAt ? showDate(row.original.submittedAt) : '—'} ), }, + { + header: 'Waiting', + accessorKey: 'submittedAt', + size: 90, + cell: ({ row }) => ( + + ), + }, { header: 'Status', accessorKey: 'status', cell: ({ row }) => ( - - {SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]} - + ), }, ], @@ -93,40 +105,39 @@ export function SeafarerRegistrationQueuePage() { return ( - - Seafarer Registration Queue - - - Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and - BTC applications. - - - } - value={search} - onChange={(e) => { - setSearch(e.currentTarget.value); - setPage(0); - }} - w={280} - /> - { + setStatus(v as SeafarerRegistrationStatus | null); + setPage(0); + }} + clearable + w={200} + /> + + } itemCount={data?.total ?? 0} pageIndex={page} onPageChange={setPage} diff --git a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx index d31183eae..d8f99215b 100644 --- a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx +++ b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx @@ -1,28 +1,12 @@ 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, SEAFARER_REGISTRATION_FIELD_LABELS, SEAFARER_REGISTRATION_SECTIONS, - SEAFARER_REGISTRATION_STATUS_COLORS, + SEAFARER_REGISTRATION_STATUS_TONES, SEAFARER_REGISTRATION_STATUS_LABELS, displaySeafarerAnswer, extractErrorMessage, @@ -31,7 +15,7 @@ import { useRejectSeafarerRegistrationMutation, useRequestSeafarerRegistrationChangesMutation, } from '@ema-platform/api'; -import { notify } from '@ema-platform/ui'; +import { notify, PageHeader, StatusBadge } from '@ema-platform/ui'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { applicantName } from './SeafarerRegistrationQueuePage'; @@ -109,24 +93,26 @@ export function SeafarerRegistrationReviewPage() { - -
- {applicantName(registration)} - + {registration.registrationNumber} - - {SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]} - + {registration.seafarerNumber && ( }> {registration.seafarerNumber} )} - -
- + + } + action={ + {canDecide && ( <> @@ -146,8 +132,9 @@ export function SeafarerRegistrationReviewPage() { )} - - +
+ } + /> {registration.status === 'RESUBMIT_REQUIRED' && ( } title="Awaiting the applicant's corrections" mb="md"> @@ -167,7 +154,7 @@ export function SeafarerRegistrationReviewPage() { {section.title} -
+
{section.fields .filter((f) => f !== 'passportExpiry' || registration.passportNumber) @@ -192,7 +179,7 @@ export function SeafarerRegistrationReviewPage() { Documents -
+
{slots.map((slot) => { const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0]; diff --git a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx index 5efcd6776..f451d101f 100644 --- a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx +++ b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx @@ -1,25 +1,8 @@ +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, @@ -59,7 +42,7 @@ const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended']; const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired']; const MEDICAL_COLOR: Record = { Valid: 'teal', Expiring: 'orange', Expired: 'red' }; -const STATUS_COLOR: Record = { Active: 'teal', Inactive: 'gray', Suspended: 'red' }; +const STATUS_TONE: Record = { Active: 'success', Inactive: 'neutral', Suspended: 'danger' }; // --------------------------------------------------------------------------- // Detail modal @@ -83,7 +66,7 @@ function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boo Nationality{sf.nationality} Date of Birth{sf.dob} Rank{sf.rank} - Status{sf.status} + Status @@ -169,10 +152,11 @@ export function SeafarerRegistryPage() { return ( -
- Seafarer Registry - Search and view all registered seafarers, their documents, and certificate status -
+ {/* KPIs */} @@ -264,7 +248,12 @@ export function SeafarerRegistryPage() { : } - {sf.status} + setSelected(sf)}> diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationFormBuilderPage.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationFormBuilderPage.tsx index 37b9e3f97..47cac3c33 100644 --- a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationFormBuilderPage.tsx +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationFormBuilderPage.tsx @@ -336,7 +336,7 @@ export function VesselRegistrationFormBuilderPage() {
- Vessel Registration Form Builder + Vessel Registration Form Builder Add, edit, reorder, or disable fields on the vessel registration form
diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx index b912d4677..5c11db5ce 100644 --- a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx @@ -1,13 +1,15 @@ -import { Badge, Button, Text, Tooltip } from '@mantine/core'; +import { type StatusTone } from '@ema-platform/shared'; +import { Button, Text, Tooltip } from '@mantine/core'; import { IconShieldCog } from '@tabler/icons-react'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { StatusBadge } from '@ema-platform/ui'; import type { Vessel } from '@ema-platform/api'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; -const VESSEL_STATUS_COLORS: Record = { - REGISTERED: 'green', - SUSPENDED: 'orange', - DEREGISTERED: 'gray', +const VESSEL_STATUS_TONES: Record = { + REGISTERED: 'success', + SUSPENDED: 'pending', + DEREGISTERED: 'neutral', }; export const CATEGORY_LABELS: Record = { @@ -63,13 +65,12 @@ export function vesselRegistrationQueueColumns( { header: 'Status', cell: ({ row }) => ( - - {row.original.status} - + /> ), }, { diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/index.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/index.tsx index f7a504651..0664497b5 100644 --- a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/index.tsx +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/index.tsx @@ -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 ( - -
- Vessel register - + {data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'} — pending registrations are reviewed in the{' '} licence queue . - -
- } - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - w={280} - /> -
+ + } + action={ + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + w={280} + /> + } + /> - -
- Vessel registration report - - {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.'} - -
-
+ + {/* First focusable element on the page, so a keyboard user can bypass + the 20-plus nav items instead of tabbing through them every time. */} + )} - +
@@ -287,5 +292,6 @@ export function BackofficeLayout() { />
+ ); } diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index 8019270ed..46135bab4 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -12,6 +12,7 @@ import { RequirePermission, LICENSE_PERMISSIONS as P, } from '@ema-platform/auth'; +import { ThemeGallery } from '@ema-platform/ui'; import { AuthLayout } from '../layouts/AuthLayout'; import { BackofficeLayout } from '../layouts/BackofficeLayout'; import { ProtectedRoute } from './ProtectedRoute'; @@ -68,6 +69,9 @@ const router = createBrowserRouter([ ], }, { path: '/um/*', element: }, + // Theme visual-regression surface. Unauthenticated by design — it renders + // only static primitives, so it needs no API and cannot flake. + { path: '/__gallery', element: }, { path: '/', element: }, { path: '/profile-setup', element: }, { diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx index 5d991e0df..e85c16aba 100644 --- a/apps/backoffice/src/main.tsx +++ b/apps/backoffice/src/main.tsx @@ -4,6 +4,10 @@ import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; import '@mantine/dates/styles.css'; import '@mantine/spotlight/styles.css'; +// After Mantine's CSS (it defines the variables these tokens resolve to), +// before the app's own, which may override them. Relative because the +// @ema-platform aliases are tsconfig paths, which do not carry subpaths. +import '../../../libs/shared/src/lib/theme/semantic.css'; import './styles.css'; import './app/i18n/config'; import { App } from './app/app'; diff --git a/apps/backoffice/src/styles.css b/apps/backoffice/src/styles.css index 4b107d549..46ef17e86 100644 --- a/apps/backoffice/src/styles.css +++ b/apps/backoffice/src/styles.css @@ -1,7 +1,7 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); -@tailwind base; -@tailwind components; -@tailwind utilities; +/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it. + Without it every Amharic string in the app renders in whatever the OS + happens to substitute — different on Windows, macOS and Android. */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap'); *, *::before, *::after { box-sizing: border-box; } diff --git a/apps/e2e/src/seafarer-registration.spec.ts b/apps/e2e/src/seafarer-registration.spec.ts index 6fab32b35..b98537e6e 100644 --- a/apps/e2e/src/seafarer-registration.spec.ts +++ b/apps/e2e/src/seafarer-registration.spec.ts @@ -347,7 +347,7 @@ test.describe('seafarer registration', () => { await expect(page.getByLabel('National ID (Fayda) Number')).toHaveValue('FYD1234567890'); await page.getByRole('button', { name: /^continue$/i }).click(); - // Step 2 — Applicant Details. + // Step 2 — Details: address and physical characteristics. await page.getByLabel('Place of Birth').fill('Addis Ababa'); await pick(page, 'Department', /deck/i); await pick(page, 'City', /addis ababa/i); @@ -356,15 +356,15 @@ test.describe('seafarer registration', () => { await pick(page, 'Eye Colour', /brown/i); await page.getByLabel('Height (cm)').fill('172'); await page.getByLabel('Weight (kg)').fill('68'); - await page.getByLabel('Certificate Number').fill('MED-2026-001'); - await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic'); - await pickDate(page, 'Issue Date', '2026-01-15'); await page.getByRole('button', { name: /^continue$/i }).click(); - // Step 3 — Emergency Contact. + // Step 3 — Contact & Medical. await page.getByLabel('Full Name').fill('Almaz Tesfaye'); await page.getByLabel('Relationship').fill('Sister'); await page.getByLabel('Phone Number').fill('+251911222333'); + await page.getByLabel('Certificate Number').fill('MED-2026-001'); + await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic'); + await pickDate(page, 'Issue Date', '2026-01-15'); await page.getByRole('button', { name: /^continue$/i }).click(); // Step 4 — Documents: all four required slots show as uploaded. diff --git a/apps/e2e/visual.config.ts b/apps/e2e/visual.config.ts new file mode 100644 index 000000000..be273b9e0 --- /dev/null +++ b/apps/e2e/visual.config.ts @@ -0,0 +1,65 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Visual-regression suite for theme work. + * + * Deliberately separate from `playwright.config.ts`. That suite drives real + * cross-app workflows and therefore needs the API, a database and migrations; + * this one only needs to know what the theme renders. Loading the same + * dependencies here would make a screenshot diff fail for reasons that have + * nothing to do with the theme — a migration, a seeded row, an expired token. + * + * So: static routes only, `vite preview` over an already-built bundle, no + * backend. Run `vite build` for both apps first. + */ + +const PORTAL_PORT = Number(process.env.VISUAL_PORTAL_PORT ?? 4312); +const BACKOFFICE_PORT = Number(process.env.VISUAL_BACKOFFICE_PORT ?? 4313); + +export const VISUAL = { + portalUrl: `http://localhost:${PORTAL_PORT}`, + backofficeUrl: `http://localhost:${BACKOFFICE_PORT}`, +}; + +export default defineConfig({ + testDir: './visual', + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + // A visual diff that passes on a retry is a flake, and a flake here would + // mask exactly the regressions this suite exists to catch. + retries: 0, + timeout: 60_000, + expect: { + // Anti-aliasing differs slightly between runs; a handful of pixels is not + // a regression. Anything the theme actually changed is far larger. + toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled' }, + }, + reporter: [['list'], ['html', { outputFolder: '../../dist/visual-report', open: 'never' }]], + + use: { + trace: 'retain-on-failure', + actionTimeout: 15_000, + }, + + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + + webServer: [ + { + name: 'portal', + command: `npx vite preview --config apps/portal/vite.config.mts --port ${PORTAL_PORT} --strictPort`, + cwd: '../..', + url: VISUAL.portalUrl, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + { + name: 'backoffice', + command: `npx vite preview --config apps/backoffice/vite.config.mts --port ${BACKOFFICE_PORT} --strictPort`, + cwd: '../..', + url: VISUAL.backofficeUrl, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ], +}); diff --git a/apps/e2e/visual/theme.spec.ts b/apps/e2e/visual/theme.spec.ts new file mode 100644 index 000000000..d0392de81 --- /dev/null +++ b/apps/e2e/visual/theme.spec.ts @@ -0,0 +1,139 @@ +import { test, expect, type Page } from '@playwright/test'; +import { VISUAL } from '../visual.config'; + +/** + * Theme baselines. + * + * These exist so a change to the shared theme can be reviewed as a diff rather + * than trusted. The gallery route renders every primitive the theme controls, + * so one screenshot per app per scheme per width covers the whole surface. + * + * Update baselines deliberately, never reflexively: + * npx playwright test -c apps/e2e/visual.config.ts --update-snapshots + * A diff you did not intend is the entire point of the suite. + */ + +const WIDTHS = [ + { name: 'desktop', width: 1440, height: 1200 }, + { name: 'tablet', width: 768, height: 1200 }, +] as const; + +const SCHEMES = ['light', 'dark'] as const; + +const APPS = [ + { name: 'backoffice', url: VISUAL.backofficeUrl }, + { name: 'portal', url: VISUAL.portalUrl }, +] as const; + +/** + * Set the scheme the way the app itself does — the pre-paint script in + * index.html reads this key. Setting it before navigation means the very first + * paint is already correct, so no screenshot catches a flash of the wrong one. + */ +async function gotoGallery(page: Page, baseUrl: string, scheme: string) { + await page.addInitScript((value) => { + window.localStorage.setItem('mantine-color-scheme-value', value); + }, scheme); + + await page.goto(`${baseUrl}/__gallery`, { waitUntil: 'networkidle' }); + + // The gallery is static, but web fonts are not: screenshotting before they + // settle bakes a fallback-font baseline that every later run then fails + // against. + await page.evaluate(() => document.fonts.ready); + await expect(page.getByRole('heading', { name: 'Theme Gallery' })).toBeVisible(); +} + +for (const app of APPS) { + for (const scheme of SCHEMES) { + for (const size of WIDTHS) { + test(`${app.name} gallery — ${scheme} — ${size.name}`, async ({ page }) => { + await page.setViewportSize({ width: size.width, height: size.height }); + await gotoGallery(page, app.url, scheme); + + await expect(page).toHaveScreenshot( + `${app.name}-gallery-${scheme}-${size.name}.png`, + { fullPage: true }, + ); + }); + } + } + + /** + * The focus ring, captured while actually focused. + * + * The full-page shots above can't show this: nothing is focused in them, so + * a regression that removed the ring entirely would leave them all green. + * Keyboard focus specifically, because `:focus-visible` deliberately does + * not match a mouse click. + */ + test(`${app.name} — focus ring is visible`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await gotoGallery(page, app.url, 'light'); + + const section = page.locator('section, div').filter({ hasText: 'Focus states' }).last(); + await section.scrollIntoViewIfNeeded(); + + const button = page.getByRole('button', { name: 'Button', exact: true }); + await button.focus(); + await expect(button).toBeFocused(); + + // Assert the ring in computed styles as well as pixels. A screenshot alone + // would still pass if the outline came from somewhere unintended, and a + // token that failed to resolve leaves an empty string rather than an error. + const ring = await button.evaluate((el) => { + const s = getComputedStyle(el); + return { + width: s.outlineWidth, + style: s.outlineStyle, + token: getComputedStyle(document.documentElement) + .getPropertyValue('--ema-focus-ring') + .trim(), + }; + }); + expect(ring.style).toBe('solid'); + expect(ring.width).toBe('2px'); + expect(ring.token).not.toBe(''); + + await expect(button).toHaveScreenshot(`${app.name}-focus-ring.png`); + }); +} + +/** + * The skip link, on a real app shell. + * + * Not on the gallery route: the point of a skip link is bypassing the nav, and + * the gallery has none. The login page is the shell-less public route both apps + * share, so this uses the landing route instead — it carries the chrome without + * needing a session. + * + * A skip link is invisible until focused, which means a broken one and a + * working one look identical in every screenshot. Only a focus test separates + * them. + */ +test.describe('skip link', () => { + for (const app of APPS) { + test(`${app.name} — reveals on focus and targets main`, async ({ page }) => { + await page.goto(`${app.url}/`, { waitUntil: 'networkidle' }); + + const link = page.locator('.ema-skip-link'); + if ((await link.count()) === 0) { + // The public landing route does not mount the app shell in every app; + // skipping is honest here, where asserting absence would be wrong. + test.skip(true, 'landing route does not mount the app shell'); + return; + } + + // Off-screen until focused... + await expect(link).not.toBeInViewport(); + + await page.keyboard.press('Tab'); + await expect(link).toBeFocused(); + await expect(link).toBeInViewport(); + + // ...and it must point at something that exists. + const href = await link.getAttribute('href'); + expect(href).toBe('#ema-main-content'); + }); + } +}); diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-focus-ring-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-focus-ring-chromium-win32.png new file mode 100644 index 000000000..434007dca Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-focus-ring-chromium-win32.png differ diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-desktop-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-desktop-chromium-win32.png new file mode 100644 index 000000000..a3836a31b Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-desktop-chromium-win32.png differ diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-tablet-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-tablet-chromium-win32.png new file mode 100644 index 000000000..cc4d3a1cc Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-tablet-chromium-win32.png differ diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-desktop-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-desktop-chromium-win32.png new file mode 100644 index 000000000..ff08cf4f6 Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-desktop-chromium-win32.png differ diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-tablet-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-tablet-chromium-win32.png new file mode 100644 index 000000000..8fcaa1290 Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-tablet-chromium-win32.png differ diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/portal-focus-ring-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/portal-focus-ring-chromium-win32.png new file mode 100644 index 000000000..9271b168c Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/portal-focus-ring-chromium-win32.png differ diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-desktop-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-desktop-chromium-win32.png new file mode 100644 index 000000000..fcb53cffa Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-desktop-chromium-win32.png differ diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-tablet-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-tablet-chromium-win32.png new file mode 100644 index 000000000..96c9165fb Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-tablet-chromium-win32.png differ diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-desktop-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-desktop-chromium-win32.png new file mode 100644 index 000000000..e50811108 Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-desktop-chromium-win32.png differ diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-tablet-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-tablet-chromium-win32.png new file mode 100644 index 000000000..465a9af2d Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-tablet-chromium-win32.png differ diff --git a/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx b/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx index a59a7b514..4ac813385 100644 --- a/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx +++ b/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx @@ -1,3 +1,4 @@ +import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared'; import { useRef, useState } from 'react'; import { useApiQuery } from '@ema-platform/api'; import { @@ -30,7 +31,7 @@ import { IconTrash, IconUpload, } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { StatusBadge, notify } from '@ema-platform/ui'; import { useTranslation } from 'react-i18next'; // --------------------------------------------------------------------------- @@ -45,11 +46,11 @@ interface BSTRecord { status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification'; } -const STATUS_COLOR: Record = { - Valid: 'teal', - Expiring: 'orange', - Expired: 'red', - 'Pending Verification': 'yellow', +const STATUS_TONE: Record = { + Valid: 'success', + Expiring: 'pending', + Expired: 'danger', + 'Pending Verification': 'warning', }; /** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */ @@ -289,14 +290,13 @@ export function BasicSafetyTrainingPage() { {record && ( - } - > - {record.status} - + /> )} @@ -321,7 +321,7 @@ export function BasicSafetyTrainingPage() { - +
@@ -329,9 +329,7 @@ export function BasicSafetyTrainingPage() { Combined certificate — all 5 STCW components
- - {record.status} - +
diff --git a/apps/portal/src/app/features/seafarer-registration/components/steps.tsx b/apps/portal/src/app/features/seafarer-registration/components/steps.tsx index c15fbfc10..d75e7f585 100644 --- a/apps/portal/src/app/features/seafarer-registration/components/steps.tsx +++ b/apps/portal/src/app/features/seafarer-registration/components/steps.tsx @@ -72,7 +72,7 @@ export function IdentityDetailsStep( ); } -/** Step 2 — Identity, Address, Physical Characteristics, Medical Certificate. */ +/** Step 2 — Identity, Address and Physical Characteristics. */ export function ApplicantDetailsStep(p: StepProps) { return ( @@ -138,6 +138,29 @@ export function ApplicantDetailsStep(p: StepProps) { /> + + ); +} + +/** + * Step 4 — Emergency Contact and Medical Certificate. + * + * The medical certificate used to sit at the bottom of Applicant Details, + * which made that step 17 fields across four sections while this one held + * three. Both are short, unrelated-to-identity, and copied off a document in + * hand rather than recalled — so they pair here and the wizard's longest step + * drops by a third. + */ +export function EmergencyContactStep(p: StepProps) { + return ( + + + + + + + + ); } - -/** Step 3 — Emergency Contact. */ -export function EmergencyContactStep(p: StepProps) { - return ( - - - - - - - - - ); -} diff --git a/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx index f4ef3e17a..bcef33bf2 100644 --- a/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx +++ b/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { Alert, - Badge, Button, Center, Container, @@ -20,7 +19,7 @@ import { notifications } from '@mantine/notifications'; import { PHYSICAL_BOUNDS, SEAFARER_REGISTRATION_FIELD_LABELS, - SEAFARER_REGISTRATION_STATUS_COLORS, + SEAFARER_REGISTRATION_STATUS_TONES, SEAFARER_REGISTRATION_STATUS_LABELS, extractErrorMessage, extractValidationIssues, @@ -33,7 +32,7 @@ import { type SeafarerRegistration, type ValidationIssue, } from '@ema-platform/api'; -import { splitPersonName } from '@ema-platform/ui'; +import { splitPersonName, StatusBadge } from '@ema-platform/ui'; import { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth'; import { useAppSelector } from '../../../store/hooks'; import { CheckboxField, type AnswerKey } from '../components/fields'; @@ -41,16 +40,19 @@ import { ApplicantDetailsStep, EmergencyContactStep, IdentityDetailsStep } from import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments'; import { RegistrationSummary } from '../components/RegistrationSummary'; -const STEPS = ['Identity Details', 'Applicant Details', 'Emergency Contact', 'Documents', 'Review']; +const STEPS = [ + { label: 'Identity', description: 'Who you are' }, + { label: 'Details', description: 'Address & physical' }, + { label: 'Contact & Medical', description: 'Emergency & fitness' }, + { label: 'Documents', description: 'Upload evidence' }, + { label: 'Review', description: 'Check & submit' }, +]; /** Which answers each step must have before "Continue" — mirrors the API's submission check. */ const REQUIRED_BY_STEP: AnswerKey[][] = [ ['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'], - [ - 'placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg', - 'medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate', - ], - [], + ['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'], + ['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'], [], ['declarationAccepted'], ]; @@ -123,7 +125,7 @@ export function SeafarerRegistrationPage() { const registration = data?.registration ?? null; const [start] = useStartSeafarerRegistrationMutation(); - const [save] = useSaveSeafarerRegistrationMutation(); + const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation(); const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation(); const [startError, setStartError] = useState(null); const started = useRef(false); @@ -215,12 +217,16 @@ export function SeafarerRegistrationPage() { } } setErrors(found); - const count = Object.keys(found).length; - if (count) { + const missingKeys = Object.keys(found) as AnswerKey[]; + if (missingKeys.length) { + // Name the fields rather than counting them. "Complete 3 required fields" + // sends the applicant hunting up a step they have already scrolled past; + // the labels are what let them go straight to it. + const names = missingKeys.map((k) => SEAFARER_REGISTRATION_FIELD_LABELS[k]); notifications.show({ color: 'red', - title: 'Incomplete', - message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`, + title: missingKeys.length > 1 ? 'Some details are missing' : 'One detail is missing', + message: `${names.join(', ')}.`, }); return false; } @@ -307,9 +313,10 @@ export function SeafarerRegistrationPage() { {registration.registrationNumber} - - {SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]} - + {showSummary && !readOnly && ( @@ -362,8 +369,8 @@ export function SeafarerRegistrationPage() { {!showSummary && ( - {STEPS.map((label) => ( - + {STEPS.map((step) => ( + ))} @@ -408,9 +415,11 @@ export function SeafarerRegistrationPage() { Back {active < STEPS.length - 1 ? ( - + ) : ( - )} diff --git a/apps/portal/src/app/features/seafarer/pages/SeaRecords/columns.tsx b/apps/portal/src/app/features/seafarer/pages/SeaRecords/columns.tsx index b9956e79d..4623d7657 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeaRecords/columns.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeaRecords/columns.tsx @@ -1,12 +1,14 @@ +import { type StatusTone } from '@ema-platform/shared'; import { Badge, Group, Text, Tooltip } from '@mantine/core'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { StatusBadge } from '@ema-platform/ui'; import { seaServiceDays, type MedicalCertificate, type SeaServiceRecord } from '@ema-platform/api'; -const RECORD_STATUS_COLORS: Record = { - SUBMITTED: 'blue', - VERIFIED: 'green', - REJECTED: 'red', +const RECORD_STATUS_TONES: Record = { + SUBMITTED: 'info', + VERIFIED: 'success', + REJECTED: 'danger', }; export function fitnessOptions(t: TFunction) { @@ -78,11 +80,12 @@ export function seaServiceColumns( label={row.original.verificationRemark ?? ''} disabled={!row.original.verificationRemark} > - - {t(`seaRecords.columns.recordStatus.${row.original.status}`, { + + /> ), }, @@ -140,11 +143,12 @@ export function medicalColumns( label={row.original.verificationRemark ?? ''} disabled={!row.original.verificationRemark} > - - {t(`seaRecords.columns.recordStatus.${row.original.status}`, { + + /> ), }, diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx index f1e7f00f6..407b77a69 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx @@ -1,3 +1,4 @@ +import { type StatusTone } from '@ema-platform/shared'; import { useEffect, useState } from 'react'; import { ActionIcon, @@ -45,7 +46,7 @@ import { IconX, } from '@tabler/icons-react'; import { useNavigate, useParams } from 'react-router-dom'; -import { notify } from '@ema-platform/ui'; +import { StatusBadge, notify } from '@ema-platform/ui'; import type { Seafarer } from './SeafarerRegistryPage'; // --------------------------------------------------------------------------- @@ -170,14 +171,14 @@ async function updateSeafarerStatus(_id: string, _status: string): Promise // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -const STATUS_COLOR: Record = { - Active: 'teal', Pending: 'yellow', Suspended: 'red', - Approved: 'teal', Expired: 'red', Valid: 'teal', - Fit: 'teal', Unfit: 'red', Conditional: 'orange', +const STATUS_TONE: Record = { + Active: 'success', Pending: 'warning', Suspended: 'danger', + Approved: 'success', Expired: 'danger', Valid: 'success', + Fit: 'success', Unfit: 'danger', Conditional: 'pending', }; function Chip({ value }: { value: string }) { - return {value}; + return ; } function InfoField({ label, value }: { label: string; value: string }) { @@ -639,7 +640,12 @@ export function SeafarerProfilePage() { - {profile.status} + diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistryPage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistryPage.tsx index cbbc6e310..e29d1a11c 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistryPage.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistryPage.tsx @@ -1,7 +1,7 @@ +import { type StatusTone } from '@ema-platform/shared'; import { useEffect, useState } from 'react'; import { ActionIcon, - Badge, Box, Button, Card, @@ -35,7 +35,7 @@ import { IconX, } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; -import { notify } from '@ema-platform/ui'; +import { StatusBadge, notify } from '@ema-platform/ui'; // --------------------------------------------------------------------------- // Types @@ -163,26 +163,17 @@ function StatCard({ // --------------------------------------------------------------------------- // Status badges // --------------------------------------------------------------------------- -const STATUS_COLOR: Record = { - Active: 'teal', - Pending: 'yellow', - Suspended: 'red', - Expired: 'orange', - Fit: 'teal', - Unfit: 'red', +const STATUS_TONE: Record = { + Active: 'success', + Pending: 'warning', + Suspended: 'danger', + Expired: 'pending', + Fit: 'success', + Unfit: 'danger', }; -function StatusBadge({ value }: { value: string }) { - return ( - - {value} - - ); +function RegistryStatus({ value }: { value: string }) { + return ; } // --------------------------------------------------------------------------- @@ -239,9 +230,9 @@ export function SeafarerRegistryPage() { {s.mobile} {s.region} {s.registeredAt} - - - + + + diff --git a/apps/portal/src/app/features/vessel-registration/pages/OwnershipTransferPage.tsx b/apps/portal/src/app/features/vessel-registration/pages/OwnershipTransferPage.tsx index b4e44e22b..9c2c890a7 100644 --- a/apps/portal/src/app/features/vessel-registration/pages/OwnershipTransferPage.tsx +++ b/apps/portal/src/app/features/vessel-registration/pages/OwnershipTransferPage.tsx @@ -1,9 +1,9 @@ +import { type StatusTone } from '@ema-platform/shared'; import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useApiMutation } from '@ema-platform/api'; import { Alert, - Badge, Box, Button, Card, @@ -31,7 +31,7 @@ import { IconTransferIn, IconUser, } from '@tabler/icons-react'; -import { notify, PhoneInput } from '@ema-platform/ui'; +import { StatusBadge, notify, PhoneInput } from '@ema-platform/ui'; import { isValidPhoneNumber } from 'libphonenumber-js'; // Minimal vessel type for the approved vessel list @@ -117,11 +117,11 @@ const TRANSFER_REASONS = [ 'Other', ]; -const STATUS_COLOR: Record = { - Pending: 'gray', - 'Under Review': 'yellow', - Approved: 'teal', - Rejected: 'red', +const STATUS_TONE: Record = { + Pending: 'neutral', + 'Under Review': 'warning', + Approved: 'success', + Rejected: 'danger', }; // --------------------------------------------------------------------------- @@ -140,7 +140,11 @@ function TransferCard({ req }: { req: OwnershipTransferRequest }) { {req.id} · Transfer to {req.newOwnerName} - {req.status} + diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx index 3a0790c61..7f896900b 100644 --- a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx +++ b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx @@ -1,9 +1,9 @@ +import { type StatusTone } from '@ema-platform/shared'; import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Alert, - Badge, Button, Card, Divider, @@ -27,7 +27,7 @@ import { IconClockHour4, IconTransferIn, } from '@tabler/icons-react'; -import { AdvancedTable } from '@ema-platform/ui'; +import { StatusBadge, AdvancedTable } from '@ema-platform/ui'; import { inFlightColumns } from '../inFlightColumns'; import { TERMINAL_STATUSES, @@ -68,12 +68,12 @@ interface VesselRegistration { expiryDate: string | null; } -const STATUS_COLOR: Record = { - Pending: 'gray', - 'Under Review': 'yellow', - Approved: 'teal', - Rejected: 'red', - 'Correction Required': 'orange', +const STATUS_TONE: Record = { + Pending: 'neutral', + 'Under Review': 'warning', + Approved: 'success', + Rejected: 'danger', + 'Correction Required': 'pending', }; // Inland vessel certificates (1) @@ -281,9 +281,12 @@ export function VesselRegistrationPage() { {registration.id} - - {registration.status} - + diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselTransferPage.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselTransferPage.tsx index f0274d0e0..8b4afd42c 100644 --- a/apps/portal/src/app/features/vessel-registration/pages/VesselTransferPage.tsx +++ b/apps/portal/src/app/features/vessel-registration/pages/VesselTransferPage.tsx @@ -1,3 +1,5 @@ +import { StatusBadge } from '@ema-platform/ui'; +import { type StatusTone } from '@ema-platform/shared'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -30,10 +32,10 @@ import { const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER'; -const VESSEL_STATUS_COLORS: Record = { - REGISTERED: 'green', - SUSPENDED: 'orange', - DEREGISTERED: 'gray', +const VESSEL_STATUS_TONES: Record = { + REGISTERED: 'success', + SUSPENDED: 'pending', + DEREGISTERED: 'neutral', }; /** @@ -194,12 +196,11 @@ export function VesselTransferPage() { {categoryLabels[vessel.category] ?? vessel.category} - - {vessel.status} - + /> diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 19dacf17c..07b37d41e 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -10,6 +10,10 @@ export const am: Translations = { tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች', }, + a11y: { + skipToContent: 'ወደ ዋናው ይዘት ዝለል', + }, + msg: { genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።', serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index fa64dd40a..edb6fc354 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -9,6 +9,11 @@ export const en = { tagline: 'Maritime licensing & certification services', }, + // Strings only assistive technology encounters. + a11y: { + skipToContent: 'Skip to main content', + }, + msg: { genericError: 'Something went wrong. Please try again.', serverError: 'Server error. Please try again later.', diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx index 58b0f17a9..544fe01a9 100644 --- a/apps/portal/src/app/layouts/PortalLayout.tsx +++ b/apps/portal/src/app/layouts/PortalLayout.tsx @@ -26,6 +26,8 @@ import { AppHeader, AppSidebar, filterByPermissions, + SkipLink, + MAIN_CONTENT_ID, } from "@ema-platform/ui"; import type { NavItem } from "@ema-platform/ui"; import { @@ -281,6 +283,10 @@ export function PortalLayout() { : "?"; return ( + <> + {/* First focusable element on the page, so a keyboard user can bypass + the nav instead of tabbing through it on every navigation. */} + - +
@@ -364,5 +370,6 @@ export function PortalLayout() { />
+ ); } diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index f5bb914ea..6fdf0a3f3 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -1,4 +1,5 @@ import { createBrowserRouter, Navigate } from "react-router-dom"; +import { ThemeGallery } from "@ema-platform/ui"; import { PortalLayout } from "./layouts/PortalLayout"; import { ProtectedRoute } from "./components/ProtectedRoute"; import { LandingRoute } from "./components/LandingRoute"; @@ -57,6 +58,10 @@ export const router = createBrowserRouter([ // Public landing page — institutional overview + role-based entry points. { path: "/", element: }, + // Theme visual-regression surface. Unauthenticated by design — it renders + // only static primitives, so it needs no API and cannot flake. + { path: "/__gallery", element: }, + // Public auth pages { path: "/login", element: }, { path: "/signup", element: }, diff --git a/apps/portal/src/app/theme/portal.css b/apps/portal/src/app/theme/portal.css index 8299ca21a..93a2ac64a 100644 --- a/apps/portal/src/app/theme/portal.css +++ b/apps/portal/src/app/theme/portal.css @@ -1,12 +1,57 @@ /* Portal global styles — loaded after Mantine's CSS, no Tailwind preflight so it never fights Mantine's base styles. */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); +/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it. + Without it every Amharic string in the app renders in whatever the OS + happens to substitute — different on Windows, macOS and Android. */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap'); :root { --ema-surface-light: #f5f8fc; --ema-surface-dark: #0e1521; } +/* --------------------------------------------------------------------------- + Scrollbars — themed instead of the raw OS default, so a dark page doesn't + carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color, + Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors + come from Mantine's dark palette so they track the active color scheme + instead of a fixed gray. + + These lived in `apps/portal/src/styles.css`, which nothing ever imported — + so the portal has been running with unthemed scrollbars while the backoffice + had these. Moved here, where they load. + --------------------------------------------------------------------------- */ +* { + scrollbar-width: thin; + scrollbar-color: var(--mantine-color-gray-5) transparent; +} +[data-mantine-color-scheme='dark'] * { + scrollbar-color: var(--mantine-color-dark-3) transparent; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background-color: var(--mantine-color-gray-5); + border-radius: 8px; + border: 2px solid transparent; + background-clip: content-box; +} +::-webkit-scrollbar-thumb:hover { + background-color: var(--mantine-color-gray-6); +} +[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb { + background-color: var(--mantine-color-dark-3); +} +[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover { + background-color: var(--mantine-color-dark-2); +} + body { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; diff --git a/apps/portal/src/app/theme/portalTheme.ts b/apps/portal/src/app/theme/portalTheme.ts index 01bba6e3c..b78975fe9 100644 --- a/apps/portal/src/app/theme/portalTheme.ts +++ b/apps/portal/src/app/theme/portalTheme.ts @@ -1,116 +1,13 @@ -import { - createTheme, - rem, - type MantineColorsTuple, -} from '@mantine/core'; - -// ---- Coastal Modern palette ---------------------------------------------- -// Portal-only theme. Lives here (not in @ema-platform/shared) so the backoffice -// is unaffected. - -const emaPrimary: MantineColorsTuple = [ - '#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9', - '#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2', -]; - -// Teal accent — the "coastal" half of the palette. -const emaTeal: MantineColorsTuple = [ - '#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf', - '#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368', -]; - -// Cool neutral grays (slightly blue-tinted) for surfaces & text. -const emaGray: MantineColorsTuple = [ - '#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7', - '#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52', -]; - -export const portalTheme = createTheme({ - primaryColor: 'emaPrimary', - primaryShade: { light: 6, dark: 5 }, - colors: { - emaPrimary, - emaTeal, - gray: emaGray, - }, - fontFamily: - 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - headings: { - fontFamily: 'Inter, sans-serif', - fontWeight: '700', - sizes: { - h1: { fontSize: rem(32), lineHeight: '1.25' }, - h2: { fontSize: rem(25), lineHeight: '1.3' }, - h3: { fontSize: rem(21), lineHeight: '1.35' }, - h4: { fontSize: rem(17), lineHeight: '1.4' }, - h5: { fontSize: rem(15), lineHeight: '1.45' }, - }, - }, - defaultRadius: 'md', - radius: { - xs: rem(6), - sm: rem(8), - md: rem(12), - lg: rem(16), - xl: rem(22), - }, - shadows: { - xs: '0 1px 2px rgba(15,23,42,0.06)', - sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)', - md: '0 8px 24px rgba(15,23,42,0.08)', - lg: '0 16px 40px rgba(15,23,42,0.12)', - xl: '0 24px 64px rgba(15,23,42,0.16)', - }, - breakpoints: { - xs: '36em', - sm: '48em', - md: '62em', - lg: '75em', - xl: '88em', - }, - cursorType: 'pointer', - components: { - Paper: { - defaultProps: { radius: 'lg' }, - }, - Card: { - defaultProps: { radius: 'lg' }, - }, - Button: { - defaultProps: { radius: 'md' }, - styles: { root: { fontWeight: 600 } }, - }, - Badge: { - defaultProps: { radius: 'sm' }, - }, - ThemeIcon: { - defaultProps: { radius: 'md' }, - }, - NavLink: { - styles: { root: { borderRadius: rem(10), fontWeight: 500 } }, - }, - TextInput: { defaultProps: { radius: 'md' } }, - Textarea: { defaultProps: { radius: 'md' } }, - Select: { defaultProps: { radius: 'md' } }, - PasswordInput: { defaultProps: { radius: 'md' } }, - // Mantine's stock scroll wrapper (NativeScrollArea) discards the - // max-height it's handed unless scrollAreaComponent is set, so a modal - // taller than the viewport just gets clipped with no way to scroll it. - // Making the body the scrollport here fixes every Modal/Drawer at once. - Modal: { - styles: { - content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' }, - body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }, - }, - }, - Drawer: { - styles: { - content: { display: 'flex', flexDirection: 'column' }, - body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }, - }, - }, - }, - other: { - heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)', - }, -}); +/** + * The portal theme now lives in `@ema-platform/shared`, alongside the + * backoffice theme and the base they share. + * + * It moved because the two themes had diverged into unrelated definitions — + * this one carried a full type scale, radius scale and component defaults that + * the backoffice simply lacked. Sharing the structure fixes the backoffice + * without changing the portal. + * + * This re-export is kept so the portal's MantineThemeProvider import stays + * valid. Prefer importing from `@ema-platform/shared` directly in new code. + */ +export { portalTheme } from '@ema-platform/shared'; diff --git a/apps/portal/src/main.tsx b/apps/portal/src/main.tsx index 1297bc950..56a6956cd 100644 --- a/apps/portal/src/main.tsx +++ b/apps/portal/src/main.tsx @@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client'; import '@mantine/core/styles.css'; import '@mantine/dates/styles.css'; import '@mantine/notifications/styles.css'; +// After Mantine's CSS (it defines the variables these tokens resolve to), +// before the app's own, which may override them. Relative because the +// @ema-platform aliases are tsconfig paths, which do not carry subpaths. +import '../../../libs/shared/src/lib/theme/semantic.css'; import './app/theme/portal.css'; import './app/i18n/config'; diff --git a/apps/portal/src/styles.css b/apps/portal/src/styles.css deleted file mode 100644 index a5b3a8408..000000000 --- a/apps/portal/src/styles.css +++ /dev/null @@ -1,42 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* --------------------------------------------------------------------------- - Scrollbars — themed instead of the raw OS default, so a dark page doesn't - carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color, - Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors - come from Mantine's dark palette so they track the active color scheme - instead of a fixed gray. - --------------------------------------------------------------------------- */ -* { - scrollbar-width: thin; - scrollbar-color: var(--mantine-color-gray-5) transparent; -} -[data-mantine-color-scheme='dark'] * { - scrollbar-color: var(--mantine-color-dark-3) transparent; -} - -::-webkit-scrollbar { - width: 10px; - height: 10px; -} -::-webkit-scrollbar-track { - background: transparent; -} -::-webkit-scrollbar-thumb { - background-color: var(--mantine-color-gray-5); - border-radius: 8px; - border: 2px solid transparent; - background-clip: content-box; -} -::-webkit-scrollbar-thumb:hover { - background-color: var(--mantine-color-gray-6); -} -[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb { - background-color: var(--mantine-color-dark-3); -} -[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover { - background-color: var(--mantine-color-dark-2); -} diff --git a/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts b/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts index 8a223c3f6..e3406a4f0 100644 --- a/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts +++ b/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts @@ -107,12 +107,26 @@ export const SEAFARER_REGISTRATION_STATUS_LABELS: Record = { - DRAFT: 'gray', - SUBMITTED: 'blue', - RESUBMIT_REQUIRED: 'orange', - APPROVED: 'teal', - REJECTED: 'red', +/** + * Registration status → platform tone. + * + * Tones, not colours. `StatusTone` is the platform's status vocabulary and the + * one place a tone becomes a colour (`STATUS_TONE_COLOR` in + * @ema-platform/shared), so this map cannot drift the way `APPROVED: 'teal'` + * had already drifted from every other feature's green success. + * + * The union is repeated rather than imported because @ema-platform/api does not + * depend on the theme layer, and should not start to for five string literals. + */ +export const SEAFARER_REGISTRATION_STATUS_TONES: Record< + SeafarerRegistrationStatus, + 'success' | 'warning' | 'danger' | 'info' | 'pending' | 'neutral' +> = { + DRAFT: 'neutral', + SUBMITTED: 'info', + RESUBMIT_REQUIRED: 'pending', + APPROVED: 'success', + REJECTED: 'danger', }; /** Human label for each answer — the review table and the summary both use it. */ diff --git a/libs/shared/src/index.ts b/libs/shared/src/index.ts index 4c88f69e7..ce06ea28f 100644 --- a/libs/shared/src/index.ts +++ b/libs/shared/src/index.ts @@ -1,4 +1,8 @@ +export * from './lib/theme/palettes'; +export * from './lib/theme/base-theme'; export * from './lib/theme/ema-theme'; +export * from './lib/theme/portal-theme'; +export * from './lib/theme/status-tone'; export * from './lib/date/date-displayer'; export * from './lib/date/use-date-displayer'; export * from './lib/date/ethiopic'; diff --git a/libs/shared/src/lib/theme/base-theme.ts b/libs/shared/src/lib/theme/base-theme.ts new file mode 100644 index 000000000..f39fc5d59 --- /dev/null +++ b/libs/shared/src/lib/theme/base-theme.ts @@ -0,0 +1,156 @@ +import type { CSSProperties } from 'react'; +import { createTheme, rem } from '@mantine/core'; + +/** + * Everything both apps agree on: scale, shape, elevation, and component + * defaults. No colours — those are the one thing the backoffice and the portal + * deliberately differ on, so each theme layers its own ramps over this. + * + * This began as the portal's theme. The backoffice had no heading scale, no + * radius scale and no component defaults at all, which is why its features + * drifted: with nothing to inherit, every page invented its own spacing and + * sizing. Promoting the portal's structure here fixes 23 features by editing + * one file, and costs the portal nothing — the values are unchanged. + */ + +/** + * The type stack. + * + * Noto Sans Ethiopic sits directly after Inter rather than being swapped in by + * a `[lang='am']` rule. Browsers fall back per *glyph*, not per element, so one + * stack renders Latin in Inter and Ge'ez in Noto automatically — including + * inside a single string. That matters here: a registry is full of mixed-script + * lines like an Amharic name beside a Latin IMO number, and a language-scoped + * swap would render one half of those in the wrong face. + * + * Inter carries no Ge'ez glyphs at all, so before this the Amharic half of a + * bilingual system rendered in whatever the OS happened to substitute. + */ +export const EMA_FONT_STACK = + 'Inter, "Noto Sans Ethiopic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + +export const baseTheme = createTheme({ + fontFamily: EMA_FONT_STACK, + + headings: { + fontFamily: EMA_FONT_STACK, + fontWeight: '700', + // A little looser than the portal's original values. Ge'ez has taller + // ascenders and deeper descenders than Latin, so a heading set to Inter's + // natural leading clips its Amharic rendering — which only became visible + // once Ethiopic was actually being rendered rather than substituted. + sizes: { + h1: { fontSize: rem(32), lineHeight: '1.3' }, + h2: { fontSize: rem(25), lineHeight: '1.35' }, + h3: { fontSize: rem(21), lineHeight: '1.4' }, + h4: { fontSize: rem(17), lineHeight: '1.45' }, + h5: { fontSize: rem(15), lineHeight: '1.5' }, + }, + }, + + defaultRadius: 'md', + radius: { + xs: rem(6), + sm: rem(8), + md: rem(12), + lg: rem(16), + xl: rem(22), + }, + + shadows: { + xs: '0 1px 2px rgba(15,23,42,0.06)', + sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)', + md: '0 8px 24px rgba(15,23,42,0.08)', + lg: '0 16px 40px rgba(15,23,42,0.12)', + xl: '0 24px 64px rgba(15,23,42,0.16)', + }, + + breakpoints: { + xs: '36em', + sm: '48em', + md: '62em', + lg: '75em', + xl: '88em', + }, + + cursorType: 'pointer', + + // Draw a focus ring for keyboard users only. The codebase had no + // `:focus-visible` handling anywhere, which is the single largest WCAG gap. + focusRing: 'auto', + + // Shade 6 in light. The dark counterpart is deliberately left unset until + // dark mode is verified end to end — changing it moves every filled control. + primaryShade: { light: 6 }, + + components: { + Paper: { defaultProps: { radius: 'lg' } }, + Card: { defaultProps: { radius: 'lg' } }, + Button: { + defaultProps: { radius: 'md' }, + styles: { root: { fontWeight: 600 } }, + }, + Badge: { defaultProps: { radius: 'sm' } }, + ThemeIcon: { defaultProps: { radius: 'md' } }, + NavLink: { styles: { root: { borderRadius: rem(10), fontWeight: 500 } } }, + TextInput: { defaultProps: { radius: 'md' } }, + Textarea: { defaultProps: { radius: 'md' } }, + Select: { defaultProps: { radius: 'md' } }, + PasswordInput: { defaultProps: { radius: 'md' } }, + + // The page sits on a tinted surface and cards float on white. Without this + // the main area is the same white as every Paper on it, and the card + // borders are the only thing separating content from chrome. + AppShell: { + styles: { main: { background: 'var(--ema-surface-page)' } }, + }, + + // Tables — the registry look: quiet uppercase headers, hairline row + // borders, a tint on hover, no zebra striping and no column rules. Set + // once here so the 14 pages rendering a raw
match the 28 that go + // through AdvancedTable instead of each picking their own density. + // + // Header text is `text-secondary` rather than the lighter dimmed gray the + // mockup used: at 11px uppercase, gray-5 on white fails 4.5:1. + Table: { + defaultProps: { highlightOnHover: true, verticalSpacing: 'sm', horizontalSpacing: 'md' }, + styles: { + table: { + '--table-border-color': 'var(--ema-border-subtle)', + '--table-hover-color': 'var(--ema-surface-page)', + '--table-striped-color': 'var(--ema-surface-sunken)', + } as CSSProperties, + th: { + fontSize: rem(11), + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: '0.05em', + color: 'var(--ema-text-secondary)', + whiteSpace: 'nowrap', + }, + td: { fontSize: rem(13) }, + }, + }, + + // Mantine's stock scroll wrapper (NativeScrollArea) discards the + // max-height it's handed unless scrollAreaComponent is set, so a modal + // taller than the viewport just gets clipped with no way to scroll it. + // Making the body the scrollport here fixes every Modal/Drawer at once. + Modal: { + styles: { + content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' }, + body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }, + }, + }, + Drawer: { + styles: { + content: { display: 'flex', flexDirection: 'column' }, + body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }, + }, + }, + }, + + other: { + heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)', + }, +}); diff --git a/libs/shared/src/lib/theme/ema-theme.ts b/libs/shared/src/lib/theme/ema-theme.ts index 6122e2b36..62b8ecaff 100644 --- a/libs/shared/src/lib/theme/ema-theme.ts +++ b/libs/shared/src/lib/theme/ema-theme.ts @@ -1,55 +1,32 @@ -import { createTheme, type MantineColorsTuple } from '@mantine/core'; +import { createTheme, mergeThemeOverrides } from '@mantine/core'; +import { baseTheme } from './base-theme'; +import { emaBlue, emaSecondary } from './palettes'; -const emaPrimary: MantineColorsTuple = [ - '#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa', - '#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a', -]; - -const emaSecondary: MantineColorsTuple = [ - '#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0', - '#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a', -]; - -export const emaTheme = createTheme({ - primaryColor: 'emaPrimary', - colors: { - emaPrimary, - emaSecondary, - }, - fontFamily: 'Inter, sans-serif', - defaultRadius: 'md', - breakpoints: { - xs: '36em', - sm: '48em', - md: '62em', - lg: '75em', - xl: '88em', - }, - shadows: { - xs: '0 1px 3px rgba(0,0,0,0.05)', - sm: '0 1px 5px rgba(0,0,0,0.07)', - md: '0 4px 20px rgba(15,23,42,0.08)', - lg: '0 8px 30px rgba(15,23,42,0.12)', - }, - components: { - // Mantine's stock scroll wrapper (NativeScrollArea) discards the - // max-height it's handed unless scrollAreaComponent is set, so a modal - // taller than the viewport just gets clipped with no way to scroll it. - // Making the body the scrollport here fixes every Modal/Drawer at once. - Modal: { - styles: { - content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' }, - body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }, - }, +/** + * Backoffice theme. + * + * Structure, scale and component defaults come from `baseTheme`; this file + * contributes only the brand. The backoffice keeps its own blue rather than + * adopting the portal's: shade 8 (#1e40af) is the higher-contrast choice for a + * tool staff read all day, and a distinct accent tells an officer at a glance + * which of the two systems they are looking at — worth having when both share + * a domain vocabulary but not the same authority. + * + * The export name is load-bearing: `libs/shared/src/index.ts` and the + * backoffice's MantineThemeProvider both import `emaTheme` by name. + * + * Note this theme does NOT override `colors.gray`. The portal's blue-tinted + * neutrals shift every dimmed label, neutral badge and table border, so that + * change is being made one app at a time rather than as a side effect of + * sharing a base. + */ +export const emaTheme = mergeThemeOverrides( + baseTheme, + createTheme({ + primaryColor: 'emaPrimary', + colors: { + emaPrimary: emaBlue, + emaSecondary, }, - Drawer: { - styles: { - content: { display: 'flex', flexDirection: 'column' }, - body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }, - }, - }, - }, - other: { - heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)', - }, -}); + }), +); diff --git a/libs/shared/src/lib/theme/palettes.ts b/libs/shared/src/lib/theme/palettes.ts new file mode 100644 index 000000000..e894b66b2 --- /dev/null +++ b/libs/shared/src/lib/theme/palettes.ts @@ -0,0 +1,66 @@ +import type { MantineColorsTuple } from '@mantine/core'; + +/** + * Every colour ramp the platform uses, in one place. + * + * Themes compose these; they do not define colours inline. Keeping the ramps + * separate from the themes is what lets the backoffice and the portal share a + * structure while keeping distinct brands — and it gives the hardcoded hexes + * scattered through feature code somewhere legitimate to be migrated to. + */ + +/** + * Backoffice brand. Shade 8 (#1e40af) is the accessible-government blue; the + * ramp is deliberately more saturated than the portal's because staff tools + * are read all day under worse conditions than a citizen portal. + */ +export const emaBlue: MantineColorsTuple = [ + '#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa', + '#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a', +]; + +/** Backoffice secondary — a warm brown, used sparingly for accents. */ +export const emaSecondary: MantineColorsTuple = [ + '#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0', + '#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a', +]; + +/** Portal brand — the "Coastal Modern" blue. Softer than the backoffice ramp. */ +export const emaCoastalBlue: MantineColorsTuple = [ + '#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9', + '#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2', +]; + +/** Portal accent — the "coastal" half of the palette. */ +export const emaTeal: MantineColorsTuple = [ + '#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf', + '#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368', +]; + +/** + * Cool, slightly blue-tinted neutrals for surfaces and text. + * + * Overriding Mantine's stock `gray` with this shifts every `c="dimmed"`, every + * neutral badge and every table border in whichever app adopts it — so it is + * applied per-theme rather than in the base, and promoted one app at a time. + */ +export const emaGray: MantineColorsTuple = [ + '#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7', + '#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52', +]; + +/** + * Ethiopian flag colours. + * + * These are intentional brand, not drift — they appear in the boot splashes, + * the maritime loader and the landing page. They live here so those usages can + * reference a name instead of repeating a hex, but they are deliberately NOT + * semantic tokens: `emaFlag.green` means "the flag's green", never "success". + */ +export const emaFlag = { + blue: '#0284C7', + yellow: '#FCD116', + green: '#078930', + gold: '#D4AF37', + sky: '#38BDF8', +} as const; diff --git a/libs/shared/src/lib/theme/portal-theme.ts b/libs/shared/src/lib/theme/portal-theme.ts new file mode 100644 index 000000000..8909ca005 --- /dev/null +++ b/libs/shared/src/lib/theme/portal-theme.ts @@ -0,0 +1,29 @@ +import { createTheme, mergeThemeOverrides } from '@mantine/core'; +import { baseTheme } from './base-theme'; +import { emaCoastalBlue, emaGray, emaTeal } from './palettes'; + +/** + * Portal theme — "Coastal Modern". + * + * Structure comes from `baseTheme` (which this theme's own structure was the + * source of, so nothing here changes visually). What remains is the brand: a + * softer blue than the backoffice, a teal accent, and cool blue-tinted + * neutrals in place of Mantine's stock gray. + * + * The gray override stays portal-only for now. It is the widest-reaching + * single line in either theme — it retints every dimmed label, neutral badge + * and table border — so the backoffice adopts it as its own reviewed change, + * not as a side effect of sharing a base. + */ +export const portalTheme = mergeThemeOverrides( + baseTheme, + createTheme({ + primaryColor: 'emaPrimary', + primaryShade: { light: 6, dark: 5 }, + colors: { + emaPrimary: emaCoastalBlue, + emaTeal, + gray: emaGray, + }, + }), +); diff --git a/libs/shared/src/lib/theme/semantic.css b/libs/shared/src/lib/theme/semantic.css new file mode 100644 index 000000000..f40f547e6 --- /dev/null +++ b/libs/shared/src/lib/theme/semantic.css @@ -0,0 +1,178 @@ +/* ============================================================================ + Semantic tokens. + + These name a *role* — "the page background", "a subtle border", "danger" — + rather than a colour. Feature code should reach for these instead of a hex, + because a hex cannot follow the colour scheme and a Mantine shade index + (`gray.5`) says nothing about why that shade was chosen. + + Every token resolves to a Mantine variable rather than a literal. That is + deliberate: Mantine already recomputes its own variables under + [data-mantine-color-scheme], so tokens defined in terms of them switch for + free and can never drift from the theme. A parallel palette of raw hexes + would recreate exactly the problem this layer exists to fix. + + Loaded once per app, after Mantine's CSS. + ============================================================================ */ + +:root { + /* --- Surfaces ---------------------------------------------------------- */ + /* The page itself, a raised card, and a recessed well. */ + --ema-surface-page: var(--mantine-color-gray-0); + --ema-surface-raised: var(--mantine-color-white); + --ema-surface-sunken: var(--mantine-color-gray-1); + + /* --- Borders ----------------------------------------------------------- */ + /* Subtle separates rows; strong outlines an input or a focused container. */ + --ema-border-subtle: var(--mantine-color-gray-2); + --ema-border-strong: var(--mantine-color-gray-4); + + /* --- Text -------------------------------------------------------------- */ + /* Secondary must stay a *text* colour: it has to clear 4.5:1, not 3:1, so + it deliberately sits darker than the gray-5 that reads as "dimmed". */ + --ema-text-primary: var(--mantine-color-gray-9); + --ema-text-secondary: var(--mantine-color-gray-7); + --ema-text-disabled: var(--mantine-color-gray-5); + + /* --- Status ------------------------------------------------------------ + Six tones, which is the entire vocabulary a status needs. Domain statuses + map onto these rather than each picking their own colour. + + `-fg` is text on the app background; `-bg` is a tint to sit that text on. + Both are needed because a badge and a label have different contrast + requirements against the same surface. */ + --ema-status-success-fg: var(--mantine-color-green-8); + --ema-status-success-bg: var(--mantine-color-green-0); + --ema-status-warning-fg: var(--mantine-color-yellow-8); + --ema-status-warning-bg: var(--mantine-color-yellow-0); + --ema-status-danger-fg: var(--mantine-color-red-8); + --ema-status-danger-bg: var(--mantine-color-red-0); + --ema-status-info-fg: var(--mantine-color-blue-8); + --ema-status-info-bg: var(--mantine-color-blue-0); + --ema-status-pending-fg: var(--mantine-color-orange-8); + --ema-status-pending-bg: var(--mantine-color-orange-0); + --ema-status-neutral-fg: var(--mantine-color-gray-7); + --ema-status-neutral-bg: var(--mantine-color-gray-1); + + /* --- Focus ------------------------------------------------------------- + One ring for the whole platform. Sized to stay visible against both a + white card and a tinted surface. */ + --ema-focus-ring: var(--mantine-primary-color-filled); + --ema-focus-ring-width: 2px; + --ema-focus-ring-offset: 2px; +} + +[data-mantine-color-scheme='dark'] { + /* Dark is not light inverted. Surfaces lift with elevation rather than + dropping, and text steps down from white rather than up from black. */ + --ema-surface-page: var(--mantine-color-dark-8); + --ema-surface-raised: var(--mantine-color-dark-7); + --ema-surface-sunken: var(--mantine-color-dark-9); + + --ema-border-subtle: var(--mantine-color-dark-4); + --ema-border-strong: var(--mantine-color-dark-3); + + --ema-text-primary: var(--mantine-color-gray-0); + --ema-text-secondary: var(--mantine-color-gray-4); + --ema-text-disabled: var(--mantine-color-dark-2); + + /* Saturated mid-shades go muddy on a dark ground; these step lighter so the + foreground still clears 4.5:1 and the tint stays distinguishable. */ + --ema-status-success-fg: var(--mantine-color-green-4); + --ema-status-success-bg: var(--mantine-color-green-9); + --ema-status-warning-fg: var(--mantine-color-yellow-4); + --ema-status-warning-bg: var(--mantine-color-yellow-9); + --ema-status-danger-fg: var(--mantine-color-red-4); + --ema-status-danger-bg: var(--mantine-color-red-9); + --ema-status-info-fg: var(--mantine-color-blue-4); + --ema-status-info-bg: var(--mantine-color-blue-9); + --ema-status-pending-fg: var(--mantine-color-orange-4); + --ema-status-pending-bg: var(--mantine-color-orange-9); + --ema-status-neutral-fg: var(--mantine-color-gray-4); + --ema-status-neutral-bg: var(--mantine-color-dark-5); +} + +/* ============================================================================ + Focus. + + The codebase had no :focus-visible rule anywhere, which is the single + largest accessibility gap in it. :focus-visible rather than :focus so a + mouse click does not leave a ring behind — that is the behaviour that gets + focus rings deleted from designs in the first place. + ============================================================================ */ + +/* Mantine already rings its own controls (`.mantine-focus-auto:focus-visible` + resolves to the same 2px solid primary). This rule is the safety net for + everything it does not own: plain anchors, custom elements, and the + UnstyledButtons this codebase uses for its own controls. + + Note there is deliberately no `outline: none` opt-out for the Mantine + classes. An earlier attempt at one suppressed Mantine's working ring and + left portal buttons with no focus indicator at all — which of the two rules + won came down to stylesheet order, and that differs between the apps. + Matching values mean overlap is invisible, so overlap is the safe default. */ +:focus-visible { + outline: var(--ema-focus-ring-width) solid var(--ema-focus-ring); + outline-offset: var(--ema-focus-ring-offset); +} + +/* ============================================================================ + Screen-reader-only utility. + + No equivalent existed anywhere in the codebase, so anything needing a text + alternative had nowhere to put it. + ============================================================================ */ + +.ema-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + /* clip-path rather than the legacy clip: it does not force a layer and is + not deprecated. */ + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* A skip link is sr-only until focused, then must be plainly visible. */ +.ema-skip-link { + position: absolute; + top: 0; + left: 0; + z-index: 9999; + padding: 0.75rem 1.25rem; + background: var(--ema-surface-raised); + color: var(--ema-text-primary); + border: 1px solid var(--ema-border-strong); + border-radius: 0 0 var(--mantine-radius-md) 0; + font-weight: 600; + text-decoration: none; + /* Off-screen rather than display:none, so it stays focusable. */ + transform: translateY(-150%); +} + +.ema-skip-link:focus-visible { + transform: translateY(0); +} + +/* ============================================================================ + Reduced motion. + + Honour the OS setting globally. Animation is not removed outright — a + near-instant transition still conveys that something changed, without the + movement that triggers vestibular symptoms. + ============================================================================ */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/libs/shared/src/lib/theme/status-tone.ts b/libs/shared/src/lib/theme/status-tone.ts new file mode 100644 index 000000000..4d68cf716 --- /dev/null +++ b/libs/shared/src/lib/theme/status-tone.ts @@ -0,0 +1,51 @@ +/** + * The platform's status vocabulary. + * + * There are 48 separate status→colour maps across the codebase, each deciding + * independently what "pending" looks like. They disagree. The fix is not one + * bigger map — domain statuses genuinely differ per feature — but one small set + * of *tones* that every domain maps onto, so the colour decision is made six + * times instead of forty-eight. + * + * `semantic.css` carries the CSS-variable form of these for stylesheet use. + * This module is for the many places that need a Mantine `color` prop instead. + */ + +export type StatusTone = + | 'success' + | 'warning' + | 'danger' + | 'info' + | 'pending' + | 'neutral'; + +/** + * Tone → Mantine colour name. + * + * Deliberately the only place a tone becomes a colour. Changing the platform's + * idea of "warning" is an edit here, not a sweep through 48 files. + */ +export const STATUS_TONE_COLOR: Record = { + success: 'green', + warning: 'yellow', + danger: 'red', + info: 'blue', + pending: 'orange', + neutral: 'gray', +}; + +/** + * Tone → CSS custom properties, for inline styles and stylesheets. + * + * Returns variable references rather than resolved colours so the values keep + * following the active colour scheme. + */ +export function statusToneVars(tone: StatusTone): { + color: string; + background: string; +} { + return { + color: `var(--ema-status-${tone}-fg)`, + background: `var(--ema-status-${tone}-bg)`, + }; +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 3934e4136..61f5825db 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -9,6 +9,7 @@ export * from "./lib/feedback/FeatureUnavailable"; export * from "./lib/feedback/EmptyState"; export * from "./lib/feedback/ErrorState"; export * from "./lib/feedback/PageLoader"; +export * from "./lib/feedback/StatusBadge"; export * from "./lib/components/MaritimeLoader"; export * from "./lib/theme/maritime-loader-theme"; export * from "./lib/layout/AppHeader"; @@ -19,13 +20,17 @@ export * from "./lib/layout/BrandAvatar"; export * from "./lib/layout/ColorSchemeToggle"; export * from "./lib/layout/LanguageSwitcher"; export * from "./lib/layout/PageHeader"; +export * from "./lib/layout/SkipLink"; export * from "./lib/input/PasswordRequirements"; 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/data/StatTile"; export * from "./lib/feedback/use-error-handler"; export * from "./lib/data/useServerTable"; export * from "./lib/landing/LandingPage"; export * from "./lib/landing/landing-copy"; export * from "./lib/utils/person-name"; +export * from "./lib/dev/ThemeGallery"; diff --git a/libs/ui/src/lib/data/AdvancedTable.tsx b/libs/ui/src/lib/data/AdvancedTable.tsx index 53a5a54cd..67b92ad59 100644 --- a/libs/ui/src/lib/data/AdvancedTable.tsx +++ b/libs/ui/src/lib/data/AdvancedTable.tsx @@ -51,6 +51,10 @@ interface AdvancedTableProps { rowStyle?: (row: T, index: number) => CSSProperties | undefined; /** Makes rows clickable (adds pointer cursor). */ onRowClick?: (row: T) => void; + /** Card title, top-left. Defaults to `tableName`, which every caller already passes. */ + title?: ReactNode; + /** Search box, filters, export — rendered top-right before Refresh/View. */ + toolbar?: ReactNode; } function getByPath(obj: unknown, path?: string): unknown { @@ -82,6 +86,8 @@ export function AdvancedTable({ verticalSpacing = "sm", rowStyle, onRowClick, + title, + toolbar, }: AdvancedTableProps) { const { t } = useTranslation(); const [visible, setVisible] = useState( @@ -94,13 +100,22 @@ export function AdvancedTable({ }); const shownColumns = columns.filter((_, i) => visible[i] ?? true); + const heading = title ?? tableName; + const from = itemCount === 0 ? 0 : pageIndex * pageSize + 1; + const to = Math.min(itemCount, pageIndex * pageSize + data.length); + return ( - - + + - {""} + {heading && ( + + {heading} + + )} - + + {toolbar} {refresh && (
+
{shownColumns.map((col, i) => ( @@ -230,8 +239,21 @@ export function AdvancedTable({
- {(itemCount > pageSize || onPageSizeChange) && ( - + + + {t("common.showingRange", { + from, + to, + total: itemCount, + defaultValue: "Showing {{from}}–{{to}} of {{total}}", + })} + + {onPageSizeChange && ( +