mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 10:58:16 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user