Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-03 15:07:52 +03:00
21 changed files with 1256 additions and 59 deletions

View File

@@ -32,6 +32,7 @@ import {
IconCreditCard,
IconDownload,
IconFileText,
IconRefresh,
IconShieldCheck,
} from '@tabler/icons-react';
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
@@ -41,11 +42,14 @@ import {
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
extractErrorMessage,
localized,
useCreateApplicationMutation,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
@@ -53,8 +57,13 @@ import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
* The applicant's home screen.
*
* Ordered by what the applicant needs from it: first anything blocked on them,
* then a read of where their applications stand, then the licence catalogue,
* then the licences they already hold. Every figure is the signed-in user's
* then the records they already have — licences, then applications and only
* then the catalogue to file something new. A returning applicant comes here to
* check on their own things, not to shop; the catalogue used to sit above both
* and pushed them below the fold.
*
* The exception is an applicant with nothing at all, for whom both sections are
* empty and the catalogue *is* the page. Every figure is the signed-in user's
* own data — there are no illustrative numbers on this page.
*/
@@ -92,6 +101,8 @@ export function DashboardPage() {
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl, { isLoading: isDownloading }] =
useGetCertificateUrlMutation();
const [createApplication, { isLoading: isRenewing }] =
useCreateApplicationMutation();
const items = useMemo(() => applications?.items ?? [], [applications]);
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
@@ -103,6 +114,9 @@ export function DashboardPage() {
(a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT',
);
const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE');
// Nothing filed and nothing held: the two "my …" sections would both be
// empty, so they collapse into one panel and the catalogue carries the page.
const hasNoRecords = items.length === 0 && heldLicenses.length === 0;
const expiringSoon = activeLicenses.filter((l) => {
const days = daysUntil(l.expiryDate);
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
@@ -113,6 +127,27 @@ export function DashboardPage() {
window.open(result.url, '_blank', 'noopener');
}
/**
* Renewal reuses the ordinary application wizard — a renewal is an
* application of kind RENEWAL, asking for that licence type's renewal
* document set. `previousLicenseId` is what ties it to the certificate being
* replaced, and what the API requires.
*/
async function renewLicense(license: IssuedLicense) {
const typeKey = license.licenseType?.key;
if (!typeKey) return;
try {
const application = await createApplication({
licenseType: typeKey,
kind: 'RENEWAL',
previousLicenseId: license.id,
}).unwrap();
navigate(`/licensing/${typeKey}/applications/${application.id}`);
} catch (err) {
notify.error(extractErrorMessage(err), 'Could not start the renewal');
}
}
if (isLoading) {
return (
<Center h={400}>
@@ -167,50 +202,60 @@ export function DashboardPage() {
expiringSoon={expiringSoon.length}
/>
{hasNoRecords ? (
<GetStartedPanel />
) : (
<>
<Section title="My licences">
{heldLicenses.length === 0 ? (
<EmptyCard message="No licence has been issued to you yet. One appears here once an application is approved and paid." />
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{heldLicenses.map((license) => (
<LicenseCard
key={license.id}
license={license}
isDownloading={isDownloading}
isRenewing={isRenewing}
onDownload={() => downloadCertificate(license)}
onRenew={() => renewLicense(license)}
/>
))}
</SimpleGrid>
)}
</Section>
<Section
title="My applications"
action={
items.length > 0 ? (
<Anchor
size="sm"
onClick={() => navigate('/licensing/applications')}
>
View all
</Anchor>
) : undefined
}
>
{items.length === 0 ? (
<EmptyCard message="You have not filed any applications yet. Pick a licence below to get started." />
) : (
<ApplicationTable
applications={items.slice(0, 6)}
navigate={navigate}
/>
)}
</Section>
</>
)}
<Section
title="Apply for a licence"
description="Choose the licence that matches the service your company provides."
>
<LicenseCatalogue />
</Section>
<Section
title="My applications"
action={
items.length > 0 ? (
<Anchor
size="sm"
onClick={() => navigate('/licensing/applications')}
>
View all
</Anchor>
) : undefined
}
>
{items.length === 0 ? (
<EmptyCard message="You have not filed any applications yet. Pick a licence above to get started." />
) : (
<ApplicationTable
applications={items.slice(0, 6)}
navigate={navigate}
/>
)}
</Section>
{heldLicenses.length > 0 && (
<Section title="My licences">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{heldLicenses.map((license) => (
<LicenseCard
key={license.id}
license={license}
isDownloading={isDownloading}
onDownload={() => downloadCertificate(license)}
/>
))}
</SimpleGrid>
</Section>
)}
</Stack>
</Container>
);
@@ -507,14 +552,21 @@ function ApplicationTable({
function LicenseCard({
license,
isDownloading,
isRenewing,
onDownload,
onRenew,
}: {
license: IssuedLicense;
isDownloading: boolean;
isRenewing: boolean;
onDownload: () => void;
onRenew: () => void;
}) {
const days = daysUntil(license.expiryDate);
// The API computes both in the authority's timezone; the local fallbacks are
// only for a cached response from before those fields existed.
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
const expired = license.status === 'EXPIRED' || days < 0;
const renewable = license.renewable ?? false;
return (
<Card withBorder radius="md" padding="md">
@@ -559,6 +611,50 @@ function LicenseCard({
</ActionIcon>
</Tooltip>
</Group>
{/* Renewal opens inside the licence type's window and stays open after
expiry, so a lapsed licence is renewed rather than applied for afresh. */}
{renewable && (
<Button
fullWidth
mt="sm"
size="xs"
variant={expired ? 'filled' : 'light'}
color={expired ? 'orange' : undefined}
loading={isRenewing}
leftSection={<IconRefresh size={14} />}
onClick={onRenew}
>
{expired
? 'Renew — this licence has expired'
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
</Button>
)}
</Card>
);
}
/**
* The first-visit panel, in place of two empty sections saying the same thing.
* It carries no call to action of its own — the catalogue is directly beneath
* it, and a button that only scrolls the page is noise.
*/
function GetStartedPanel() {
return (
<Card withBorder radius="md" padding="xl">
<Group gap="md" wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="emaPrimary" size={44} radius="md">
<IconCertificate size={24} stroke={1.5} />
</ThemeIcon>
<Box>
<Title order={4}>Get started</Title>
<Text size="sm" c="dimmed" mt={4} maw={620}>
You have not filed an application yet. Choose the licence that
matches what your company does your applications and the licences
issued to you will appear here as you go.
</Text>
</Box>
</Group>
</Card>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Anchor,
Badge,
Box,
Button,
@@ -25,6 +26,7 @@ import {
localized,
useGetLicenseCategoriesQuery,
useGetLicenseTypesQuery,
useGetMyOperatorTypesQuery,
} from '@ema-platform/api';
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
@@ -52,9 +54,25 @@ export function LicenseCatalogue() {
const navigate = useNavigate();
const { data: types } = useGetLicenseTypesQuery();
const { data: categories } = useGetLicenseCategoriesQuery();
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
useGetMyOperatorTypesQuery();
// An escape hatch, not a preference: someone who wants to see what else
// exists can, without first editing their profile to find out.
const [showAll, setShowAll] = useState(false);
const declared = useMemo(
() => new Set((operatorTypes?.items ?? []).map((o) => o.licenseTypeId)),
[operatorTypes],
);
const hasDeclared = declared.size > 0;
const { groups, orphans } = useMemo(() => {
const active = (types?.items ?? []).filter((t) => t.isActive);
const active = (types?.items ?? [])
.filter((t) => t.isActive)
// Only what the applicant operates as. The server enforces the same rule
// on create; this is what stops them starting an application they will
// be refused at the end of.
.filter((t) => showAll || !hasDeclared || declared.has(t.id));
const catalogue = (categories?.items ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder);
@@ -71,7 +89,38 @@ export function LicenseCatalogue() {
// vanish from the page entirely.
orphans: active.filter((t) => !known.has(t.category)),
};
}, [types, categories]);
}, [types, categories, declared, hasDeclared, showAll]);
// A profile created before modes existed, or one whose modes were all
// removed. Showing an empty catalogue would read as "there is nothing for
// you here" when the truth is "tell us what you do".
if (!loadingOperatorTypes && !hasDeclared && !showAll) {
return (
<Card withBorder radius="md" padding="xl">
<Stack gap="xs" align="center">
<ThemeIcon variant="light" color="emaPrimary" size="lg" radius="xl">
<IconBuildingWarehouse size={18} />
</ThemeIcon>
<Text size="sm" fw={600}>
Tell us what you operate as
</Text>
<Text size="sm" c="dimmed" ta="center" maw={520}>
Licences are offered against your mode of operation freight
forwarder, shipping agent, multimodal transport operator and so on.
Choose yours and the licences you can apply for appear here.
</Text>
<Group gap="sm" mt="xs">
<Button size="xs" onClick={() => navigate('/profile#operations')}>
Set my operations
</Button>
<Button size="xs" variant="subtle" onClick={() => setShowAll(true)}>
Browse all licences
</Button>
</Group>
</Stack>
</Card>
);
}
if (groups.length === 0 && orphans.length === 0) {
return (
@@ -91,8 +140,26 @@ export function LicenseCatalogue() {
);
}
// In browse-all, a card the applicant cannot yet apply for sends them to the
// Operations tab rather than into a form the server would refuse to accept.
const canApply = (type: LicenseType) => !hasDeclared || declared.has(type.id);
const select = (type: LicenseType) =>
canApply(type)
? navigate(`/licensing/${type.key}/apply`)
: navigate('/profile#operations');
return (
<Stack gap="lg">
{showAll && hasDeclared && (
<Group gap="xs">
<Text size="xs" c="dimmed">
Showing every licence, including ones outside your operations.
</Text>
<Anchor size="xs" onClick={() => setShowAll(false)}>
Show only mine
</Anchor>
</Group>
)}
{groups.map(({ category, licenseTypes }) => (
<CategoryGroup
key={category.key}
@@ -100,7 +167,8 @@ export function LicenseCatalogue() {
title={localized(category.name)}
description={localized(category.description)}
licenseTypes={licenseTypes}
onSelect={(type) => navigate(`/licensing/${type.key}/apply`)}
canApply={canApply}
onSelect={select}
/>
))}
{orphans.length > 0 && (
@@ -109,9 +177,20 @@ export function LicenseCatalogue() {
title="Other licences"
description="Licence types that have not been assigned a category."
licenseTypes={orphans}
onSelect={(type) => navigate(`/licensing/${type.key}/apply`)}
canApply={canApply}
onSelect={select}
/>
)}
{hasDeclared && !showAll && (
<Group gap="xs">
<Text size="xs" c="dimmed">
Only licences matching your declared operations are shown.
</Text>
<Anchor size="xs" onClick={() => setShowAll(true)}>
Browse all licences
</Anchor>
</Group>
)}
</Stack>
);
}
@@ -121,12 +200,14 @@ function CategoryGroup({
title,
description,
licenseTypes,
canApply,
onSelect,
}: {
icon: typeof IconShip;
title: string;
description: string;
licenseTypes: LicenseType[];
canApply: (type: LicenseType) => boolean;
onSelect: (type: LicenseType) => void;
}) {
return (
@@ -144,7 +225,12 @@ function CategoryGroup({
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{licenseTypes.map((type) => (
<LicenseTypeCard key={type.id} type={type} onSelect={onSelect} />
<LicenseTypeCard
key={type.id}
type={type}
canApply={canApply(type)}
onSelect={onSelect}
/>
))}
</SimpleGrid>
</Box>
@@ -153,9 +239,11 @@ function CategoryGroup({
function LicenseTypeCard({
type,
canApply,
onSelect,
}: {
type: LicenseType;
canApply: boolean;
onSelect: (type: LicenseType) => void;
}) {
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
@@ -216,9 +304,10 @@ function LicenseTypeCard({
mt="sm"
size="xs"
variant="light"
color={canApply ? undefined : 'gray'}
rightSection={<IconArrowRight size={14} />}
>
Start application
{canApply ? 'Start application' : 'Add to my operations'}
</Button>
</Box>
</Stack>

View File

@@ -1,7 +1,6 @@
import { useNavigate } from 'react-router-dom';
import {
Badge,
Box,
Button,
Card,
Center,
@@ -54,7 +53,7 @@ export function MyApplicationsPage() {
color: 'teal',
title: 'Payment bypassed',
message: result.certificateIssued
? 'The licence has been issued — see My licences below.'
? 'The licence has been issued — see My licences above.'
: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
});
} catch (err) {
@@ -118,14 +117,9 @@ export function MyApplicationsPage() {
Licence applications
</Title>
<Text size="sm" c="dimmed" mb="md">
Choose a licence to apply for. Each one asks for its own forms and
supporting documents.
Your licences and applications, and the catalogue to file a new one.
</Text>
<Box mb="xl">
<LicenseCatalogue />
</Box>
{(licences?.items ?? []).length > 0 && (
<>
<Title order={4} mb="sm">
@@ -205,7 +199,7 @@ export function MyApplicationsPage() {
<Stack align="center" gap="xs">
<Text c="dimmed">You have not filed any applications yet.</Text>
<Text size="sm" c="dimmed">
Pick a licence above to get started.
Pick a licence below to get started.
</Text>
</Stack>
</Card>
@@ -314,6 +308,13 @@ export function MyApplicationsPage() {
</Table>
</Card>
)}
{/* Last, for the same reason as on the dashboard: someone opening this
page came to check on what they already filed, not to browse. */}
<Title order={4} mt="xl" mb="sm">
Apply for a licence
</Title>
<LicenseCatalogue />
</Container>
);
}

View File

@@ -0,0 +1,54 @@
import { Navigate, useLocation } from 'react-router-dom';
import { Center, Loader } from '@mantine/core';
import { useGetMyOperatorTypesQuery } from '@ema-platform/api';
/**
* Sends an applicant who has not said what they operate as to the step that
* asks. Everything else in the portal is keyed off that answer — the catalogue
* offers nothing without it, and the server refuses an application for a mode
* the profile does not hold — so it is asked once, up front.
*
* Deliberately not a hard gate on every route: the profile and support pages
* stay reachable, because someone who cannot answer the question yet must
* still be able to reach their account and ask for help.
*/
const ALWAYS_ALLOWED = ['/onboarding/operations', '/profile', '/support'];
export function RequireOperations({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
const { data, isLoading, isFetching, isError } = useGetMyOperatorTypesQuery();
if (ALWAYS_ALLOWED.some((path) => pathname.startsWith(path))) {
return <>{children}</>;
}
if (isLoading) {
return (
<Center h={200}>
<Loader />
</Center>
);
}
// A failed lookup must not lock anyone out of the portal — the server still
// enforces the rule on create, so the worst case is a catalogue that offers
// more than it should for one session.
if (isError) return <>{children}</>;
if ((data?.items ?? []).length === 0) {
// An empty set while a request is in flight is not an answer. Saving the
// onboarding form invalidates this query and navigates to the dashboard in
// the same tick; deciding on the pre-save cache bounced the applicant
// straight back to the screen they had just completed.
if (isFetching) {
return (
<Center h={200}>
<Loader />
</Center>
);
}
return <Navigate to="/onboarding/operations" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,38 @@
import { useNavigate } from 'react-router-dom';
import { Container, Paper, Stack, Text, Title } from '@mantine/core';
import { OperationsFormContent } from '../../profile/components/OperationsFormContent';
/**
* The one thing a new applicant is asked for beyond their credentials.
*
* It is a step of its own rather than a field on the signup form because the
* licence catalogue it offers is not readable without a session — signup
* issues the token, and this is the first screen behind it. An applicant who
* arrived before modes existed lands here once, for the same reason.
*
* Saving is what completes it: `RequireOperations` stops redirecting here as
* soon as the profile has at least one mode.
*/
export function OperationsOnboardingPage() {
const navigate = useNavigate();
return (
<Container size="sm" py="xl">
<Stack gap="lg">
<div>
<Title order={3}>What do you operate as?</Title>
<Text size="sm" c="dimmed" mt={4}>
The Authority licenses by mode of operation. Tell us what your
company does and we will show you the licences you can apply for
you can change this later from your profile.
</Text>
</div>
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<OperationsFormContent onSaved={() => navigate('/dashboard')} />
</Paper>
</Stack>
</Container>
);
}
export default OperationsOnboardingPage;

View File

@@ -0,0 +1,217 @@
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Modal,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react';
import {
extractErrorMessage,
localized,
useGetLicenseTypesQuery,
useGetMyOperatorTypesQuery,
useUpdateMyOperatorTypesMutation,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
/**
* The applicant's modes of operation — what they do, and therefore which
* licences the portal offers them.
*
* The options come from the licence-type catalogue rather than a list in the
* code, so a sixth licence type configured by EMA appears here without a
* release. Removing a mode is confirmed separately from adding one: adding
* only widens what is on offer, while removing changes what the applicant can
* still file.
*/
export function OperationsFormContent({
onSaved,
}: {
/** Where to go once the set is stored — used by the onboarding step. */
onSaved?: () => void;
} = {}) {
const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery();
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
const declaredIds = useMemo(
() => (mine?.items ?? []).map((o) => o.licenseTypeId),
[mine],
);
const [selected, setSelected] = useState<string[]>([]);
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
// Re-sync whenever the server's answer changes — including after a save, so
// the form reflects what was actually stored rather than what was typed.
useEffect(() => setSelected(declaredIds), [declaredIds]);
const options = useMemo(
() => (catalogue?.items ?? []).filter((t) => t.isActive),
[catalogue],
);
const removed = declaredIds.filter((id) => !selected.includes(id));
const dirty =
removed.length > 0 || selected.some((id) => !declaredIds.includes(id));
const lastChanged = useMemo(() => {
const dates = (mine?.items ?? [])
.map((o) => o.declaredAt)
.filter((d): d is string => Boolean(d))
.sort();
return dates.length > 0 ? dates[dates.length - 1] : null;
}, [mine]);
async function persist() {
try {
await save({ licenseTypeIds: selected }).unwrap();
setConfirmingRemoval(false);
notify.success(
'The licences you can apply for have been updated to match.',
'Operations updated',
);
onSaved?.();
} catch (err) {
notify.error(extractErrorMessage(err), 'Could not save');
}
}
if (loadingTypes || loadingMine) {
return <Loader size="sm" />;
}
const removedNames = options
.filter((t) => removed.includes(t.id))
.map((t) => localized(t.name));
return (
<Stack gap="xl">
<div>
<Title order={5}>Mode of operation</Title>
<Text size="sm" c="dimmed" mb="md">
What your company operates as. This decides which licences you are
offered you can change it whenever your business changes.
</Text>
<Checkbox.Group value={selected} onChange={setSelected}>
<Stack gap="sm">
{options.map((type) => (
<Checkbox
key={type.id}
value={type.id}
label={
<Group gap="xs" wrap="nowrap">
<Text size="sm">{localized(type.name)}</Text>
{declaredIds.includes(type.id) && (
<Badge size="xs" variant="light" color="teal">
Current
</Badge>
)}
</Group>
}
description={
type.description ? localized(type.description) : undefined
}
/>
))}
</Stack>
</Checkbox.Group>
{options.length === 0 && (
<Text size="sm" c="dimmed">
No licence types are configured yet. Contact EMA if you were
expecting one.
</Text>
)}
</div>
{selected.length === 0 && (
<Alert
variant="light"
color="orange"
icon={<IconAlertTriangle size={18} />}
title="No operations selected"
>
With none selected you will not be offered any licence to apply for.
Existing applications and issued licences are unaffected.
</Alert>
)}
<Group justify="space-between">
<Text size="xs" c="dimmed">
{lastChanged
? `Last changed ${new Date(lastChanged).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}`
: 'Not set yet'}
</Text>
<Group gap="sm">
{dirty && (
<Button
variant="subtle"
size="sm"
onClick={() => setSelected(declaredIds)}
>
Discard changes
</Button>
)}
<Button
size="sm"
loading={saving}
disabled={!dirty}
leftSection={<IconBuildingWarehouse size={16} />}
onClick={() =>
removed.length > 0 ? setConfirmingRemoval(true) : persist()
}
>
Save operations
</Button>
</Group>
</Group>
{/* Removal is the one direction that takes something away, so it is
spelled out rather than saved on a single click. */}
<Modal
opened={confirmingRemoval}
onClose={() => setConfirmingRemoval(false)}
title="Remove from your operations?"
centered
>
<Stack gap="md">
<Text size="sm">
You are removing{' '}
<Text span fw={600}>
{removedNames.join(', ')}
</Text>
.
</Text>
<Text size="sm" c="dimmed">
You will no longer be offered a new application of that type.
Applications already filed carry on as they are, and licences
already issued to you stay valid and can still be renewed.
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setConfirmingRemoval(false)}
>
Cancel
</Button>
<Button color="orange" loading={saving} onClick={persist}>
Remove and save
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -32,6 +32,7 @@ import {
IconDeviceFloppy,
IconLock,
IconMail,
IconBuildingWarehouse,
IconMapPin,
IconMoon,
IconPhone,
@@ -63,10 +64,18 @@ import {
addressSchema,
type AddressValues,
} from '../components/AddressFormContent';
import { OperationsFormContent } from '../components/OperationsFormContent';
import classes from './ProfilePage.module.css';
/** Tab keys addressable via the URL hash. */
const VALID_TABS = ['personal', 'profile', 'address', 'security', 'preferences'];
const VALID_TABS = [
'personal',
'profile',
'address',
'operations',
'security',
'preferences',
];
function getInitials(name: string, fallback: string) {
const source = name?.trim() || fallback?.trim() || '';
@@ -526,6 +535,12 @@ export function ProfilePage() {
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
Address
</Tabs.Tab>
<Tabs.Tab
value="operations"
leftSection={<IconBuildingWarehouse size={18} />}
>
Operations
</Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
</Tabs.Tab>
@@ -696,6 +711,13 @@ export function ProfilePage() {
</Paper>
</Tabs.Panel>
{/* ---- Operations (what the applicant may apply for) ---- */}
<Tabs.Panel value="operations" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<OperationsFormContent />
</Paper>
</Tabs.Panel>
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>

View File

@@ -9,6 +9,8 @@ import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '
// Portal feature pages
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
import { RequireOperations } from './features/onboarding/components/RequireOperations';
import { OperationsOnboardingPage } from './features/onboarding/pages/OperationsOnboardingPage';
import { ProfilePage } from './features/profile/pages/ProfilePage';
import { SupportPage } from './features/support/pages/SupportPage';
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
@@ -71,13 +73,18 @@ export const router = createBrowserRouter([
element: (
<ProtectedRoute>
<I18nextProvider i18n={i18n}>
<PortalLayout />
{/* Asks what the applicant operates as before the rest of the
portal, which is filtered by that answer. */}
<RequireOperations>
<PortalLayout />
</RequireOperations>
</I18nextProvider>
</ProtectedRoute>
),
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/onboarding/operations', element: <OperationsOnboardingPage /> },
// Config-driven licensing: one set of pages serves every licence type.
{ path: '/licensing/applications', element: <MyApplicationsPage /> },