ui chnages

This commit is contained in:
Fistum
2026-08-21 11:16:22 +00:00
parent 8f6ebdafb1
commit 6b5b99f42d
31 changed files with 1173 additions and 1642 deletions

View File

@@ -107,12 +107,26 @@ export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationSta
REJECTED: 'Rejected',
};
export const SEAFARER_REGISTRATION_STATUS_COLORS: Record<SeafarerRegistrationStatus, string> = {
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. */

View File

@@ -1,3 +1,4 @@
import type { CSSProperties } from 'react';
import { createTheme, rem } from '@mantine/core';
/**
@@ -97,6 +98,40 @@ export const baseTheme = createTheme({
Select: { defaultProps: { radius: 'md' } },
PasswordInput: { defaultProps: { radius: 'md' } },
// The page sits on a tinted surface and cards float on white. Without this
// the main area is the same white as every Paper on it, and the card
// borders are the only thing separating content from chrome.
AppShell: {
styles: { main: { background: 'var(--ema-surface-page)' } },
},
// Tables — the registry look: quiet uppercase headers, hairline row
// borders, a tint on hover, no zebra striping and no column rules. Set
// once here so the 14 pages rendering a raw <Table> match the 28 that go
// through AdvancedTable instead of each picking their own density.
//
// Header text is `text-secondary` rather than the lighter dimmed gray the
// mockup used: at 11px uppercase, gray-5 on white fails 4.5:1.
Table: {
defaultProps: { highlightOnHover: true, verticalSpacing: 'sm', horizontalSpacing: 'md' },
styles: {
table: {
'--table-border-color': 'var(--ema-border-subtle)',
'--table-hover-color': 'var(--ema-surface-page)',
'--table-striped-color': 'var(--ema-surface-sunken)',
} as CSSProperties,
th: {
fontSize: rem(11),
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--ema-text-secondary)',
whiteSpace: 'nowrap',
},
td: { fontSize: rem(13) },
},
},
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
// max-height it's handed unless scrollAreaComponent is set, so a modal
// taller than the viewport just gets clipped with no way to scroll it.

View File

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

View File

@@ -51,6 +51,10 @@ interface AdvancedTableProps<T> {
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<T extends { id?: string | number }>({
verticalSpacing = "sm",
rowStyle,
onRowClick,
title,
toolbar,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(
@@ -94,13 +100,22 @@ export function AdvancedTable<T extends { id?: string | number }>({
});
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
const heading = title ?? tableName;
const from = itemCount === 0 ? 0 : pageIndex * pageSize + 1;
const to = Math.min(itemCount, pageIndex * pageSize + data.length);
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md">
<Paper withBorder radius="lg" p={0}>
<Group justify="space-between" px="md" py="sm" wrap="wrap" gap="sm">
<Group gap="xs">
<Text fw={600}>{""}</Text>
{heading && (
<Text fw={600} size="sm">
{heading}
</Text>
)}
</Group>
<Group gap="xs">
<Group gap="xs" wrap="wrap">
{toolbar}
{refresh && (
<Button
variant="default"
@@ -162,13 +177,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Group>
<Table.ScrollContainer minWidth={480}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
verticalSpacing={verticalSpacing}
>
<Table verticalSpacing={verticalSpacing}>
<Table.Thead>
<Table.Tr>
{shownColumns.map((col, i) => (
@@ -230,8 +239,21 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Table>
</Table.ScrollContainer>
{(itemCount > pageSize || onPageSizeChange) && (
<Group justify="flex-end" mt="md">
<Group
justify="space-between"
px="md"
py="sm"
style={{ borderTop: "1px solid var(--ema-border-subtle)" }}
>
<Text size="xs" c="dimmed">
{t("common.showingRange", {
from,
to,
total: itemCount,
defaultValue: "Showing {{from}}{{to}} of {{total}}",
})}
</Text>
<Group gap="sm">
{onPageSizeChange && (
<Select
size="sm"
@@ -253,7 +275,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
/>
)}
</Group>
)}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from 'react';
import { Badge, type BadgeProps } from '@mantine/core';
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
export interface StatusBadgeProps extends Omit<BadgeProps, 'color' | 'children'> {
/** The platform tone this status maps onto. */
tone: StatusTone;
/** Human label. Already translated by the caller. */
label: ReactNode;
}
/**
* One badge for every status in the platform.
*
* There were 29 files rendering `<Badge variant="light" color={MAP[status]}>`,
* each re-deciding size, variant and radius alongside the colour — which is why
* a "pending" badge in one queue did not match "pending" in the next. Callers
* now supply a tone and a label; how a status *looks* is decided once, here.
*
* Tone rather than colour on purpose: `STATUS_TONE_COLOR` is the single place a
* tone becomes a Mantine colour, so restyling the platform's idea of "danger"
* stays one edit rather than a sweep.
*/
export function StatusBadge({ tone, label, ...props }: StatusBadgeProps) {
return (
<Badge variant="light" size="sm" radius="sm" {...props} color={STATUS_TONE_COLOR[tone]}>
{label}
</Badge>
);
}

View File

@@ -157,8 +157,8 @@ function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle
height: rem(40),
borderRadius: rem(10),
opacity: item.soon ? 0.55 : 1,
color: branchActive ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: branchActive ? 'var(--mantine-color-blue-light)' : undefined,
color: branchActive ? 'var(--mantine-primary-color-filled)' : undefined,
backgroundColor: branchActive ? 'var(--mantine-primary-color-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
@@ -222,7 +222,10 @@ function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle
opened={hasChildren ? opened : undefined}
onChange={hasChildren ? onToggle : undefined}
onClick={() => !hasChildren && onNavigate(item)}
variant="light"
// A leaf that is the current page is filled in the app's primary; a
// branch whose child is current stays a tint, so the filled item is
// always exactly the page you are on.
variant={hasChildren ? 'light' : 'filled'}
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },