Initial End to End functionality

This commit is contained in:
Mulu Mehari
2026-08-02 22:44:08 +03:00
parent c9d885356c
commit c62ec59655
53 changed files with 6791 additions and 1208 deletions

View File

@@ -14,10 +14,44 @@ import type {
LicenseStatus,
LicenseType,
LicenseTypeRequirements,
AssignableOfficer,
DocumentDecision,
DocumentReview,
ExportResult,
LicenseTemplate,
Paginated,
QueueCounts,
QueueFilter,
RemarkTargetType,
SavedQueueView,
TemplatePageOptions,
TemplateVariable,
} from './licensing.types';
/**
* Drops empty filters and flattens the status array.
*
* The grid keeps every facet in one object and serialises it to the URL, so
* cleared facets arrive as undefined/[]; sending those verbatim would put
* `status=` and `assignee=` on the wire and fail DTO validation.
*/
function serialiseQueueFilter(
filter: QueueFilter | void,
): Record<string, unknown> {
if (!filter) return {};
const params: Record<string, unknown> = {};
for (const [key, value] of Object.entries(filter)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
if (value.length === 0) continue;
params[key] = value.join(',');
continue;
}
params[key] = value;
}
return params;
}
const TAGS = [
'LicenseType',
'LicenseApplication',
@@ -26,6 +60,8 @@ const TAGS = [
'Notification',
'Inspection',
'License',
'SavedView',
'LicenseTemplate',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -86,6 +122,20 @@ export const licensingApi = baseApi
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
/** Validity is edited beside the certificate design, not with the fees. */
updateLicenseValidity: builder.mutation<
LicenseType,
{ id: string; validityMonths: number }
>({
query: ({ id, validityMonths }) => ({
url: `/license-types/${id}/validity`,
method: 'PATCH',
body: { validityMonths },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')],
}),
getLicenseTypeRequirements: builder.query<
LicenseTypeRequirements,
{ idOrKey: string; kind?: ApplicationKind }
@@ -234,30 +284,82 @@ export const licensingApi = baseApi
providesTags: () => [listTag('License')],
}),
suspendLicense: builder.mutation<IssuedLicense, { id: string; reason: string }>({
query: ({ id, reason }) => ({
url: `/licenses/${id}/suspend`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('License', id), listTag('License')],
}),
revokeLicense: builder.mutation<IssuedLicense, { id: string; reason: string }>({
query: ({ id, reason }) => ({
url: `/licenses/${id}/revoke`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('License', id), listTag('License')],
}),
reinstateLicense: builder.mutation<IssuedLicense, { id: string; reason: string }>({
query: ({ id, reason }) => ({
url: `/licenses/${id}/reinstate`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('License', id), listTag('License')],
}),
getCertificateUrl: builder.mutation<{ url: string }, string>({
query: (id) => ({ url: `/licenses/${id}/certificate` }),
}),
// ------------------------------------------------------------- review
getQueue: builder.query<
Paginated<LicenseApplication>,
{ licenseTypeId?: string; search?: string } | void
>({
query: (params) => ({ url: '/license-application-review/queue', params: params ?? {} }),
getQueue: builder.query<Paginated<LicenseApplication>, QueueFilter | void>({
query: (params) => ({
url: '/license-application-review/queue',
params: serialiseQueueFilter(params),
}),
providesTags: () => [listTag('ApplicationQueue')],
}),
getAssignedToMe: builder.query<
Paginated<LicenseApplication>,
{ licenseTypeId?: string; search?: string } | void
QueueFilter | void
>({
query: (params) => ({
url: '/license-application-review/assigned-to-me',
params: params ?? {},
params: serialiseQueueFilter(params),
}),
providesTags: () => [listTag('ApplicationQueue')],
}),
/** Every non-draft application — backs the grid's "All" view. */
getAllApplications: builder.query<
Paginated<LicenseApplication>,
QueueFilter | void
>({
query: (params) => ({
url: '/license-application-review/all',
params: serialiseQueueFilter(params),
}),
providesTags: () => [listTag('ApplicationQueue')],
}),
/**
* Counts for the saved-view tabs. Shares the ApplicationQueue tag, so a
* claim or a decision refreshes the badges along with the list and the
* numbers can never drift from what the grid is showing.
*/
getQueueCounts: builder.query<QueueCounts, void>({
query: () => ({ url: '/license-application-review/counts' }),
providesTags: () => [listTag('ApplicationQueue')],
}),
getApplicationForReview: builder.query<ApplicationDetail, string>({
query: (id) => ({ url: `/license-application-review/${id}` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
@@ -288,6 +390,8 @@ export const licensingApi = baseApi
id: string;
generalRemark?: string;
items: { targetType: RemarkTargetType; targetKey: string; remark: string }[];
/** Officer-edited wording for the applicant notification. */
notificationBody?: string;
}
>({
query: ({ id, ...body }) => ({
@@ -327,16 +431,213 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
rejectApplication: builder.mutation<LicenseApplication, { id: string; reason: string }>({
query: ({ id, reason }) => ({
rejectApplication: builder.mutation<
LicenseApplication,
{ id: string; reason: string; notificationBody?: string }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/reject`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
// ------------------------------------------------- certificate designs
getLicenseTemplates: builder.query<LicenseTemplate[], string | void>({
query: (licenseTypeId) => ({
url: '/license-templates',
params: licenseTypeId ? { licenseTypeId } : {},
}),
providesTags: () => [listTag('LicenseTemplate')],
}),
getTemplateVariables: builder.query<TemplateVariable[], void>({
query: () => ({ url: '/license-templates/variables' }),
}),
getBuiltInTemplate: builder.query<{ hbsSource: string }, void>({
query: () => ({ url: '/license-templates/built-in' }),
}),
createLicenseTemplate: builder.mutation<
LicenseTemplate,
{
licenseTypeId: string;
name: string;
hbsSource?: string;
pageOptions?: TemplatePageOptions;
}
>({
query: (body) => ({ url: '/license-templates', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
}),
updateLicenseTemplate: builder.mutation<
LicenseTemplate,
{
id: string;
name?: string;
hbsSource?: string;
pageOptions?: TemplatePageOptions;
}
>({
query: ({ id, ...body }) => ({
url: `/license-templates/${id}`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
}),
publishLicenseTemplate: builder.mutation<LicenseTemplate, string>({
query: (id) => ({ url: `/license-templates/${id}/publish`, method: 'POST' }),
// The previously published design is archived by the same call, so the
// whole list is refetched rather than patching two rows by hand.
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
}),
archiveLicenseTemplate: builder.mutation<LicenseTemplate, string>({
query: (id) => ({ url: `/license-templates/${id}/archive`, method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
}),
deleteLicenseTemplate: builder.mutation<unknown, string>({
query: (id) => ({ url: `/license-templates/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
}),
// ------------------------------------------------------ document review
getDocumentReviews: builder.query<DocumentReview[], string>({
query: (id) => ({ url: `/license-application-review/${id}/document-reviews` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
}),
reviewDocument: builder.mutation<
DocumentReview,
{
id: string;
documentKey: string;
decision: DocumentDecision;
reason?: string;
attachmentId?: string;
}
>({
query: ({ id, documentKey, ...body }) => ({
url: `/license-application-review/${id}/documents/${encodeURIComponent(documentKey)}/review`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id)],
}),
clearDocumentReview: builder.mutation<unknown, { id: string; documentKey: string }>({
query: ({ id, documentKey }) => ({
url: `/license-application-review/${id}/documents/${encodeURIComponent(documentKey)}/review`,
method: 'DELETE',
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id)],
}),
// ----------------------------------------------------------- saved views
getSavedViews: builder.query<SavedQueueView[], void>({
query: () => ({ url: '/license-application-review/saved-views' }),
providesTags: () => [listTag('SavedView')],
}),
createSavedView: builder.mutation<
SavedQueueView,
{ name: string; queryString: string; isShared?: boolean; isDefault?: boolean }
>({
query: (body) => ({
url: '/license-application-review/saved-views',
method: 'POST',
body,
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('SavedView')]),
}),
deleteSavedView: builder.mutation<unknown, string>({
query: (viewId) => ({
url: `/license-application-review/saved-views/${viewId}`,
method: 'DELETE',
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('SavedView')]),
}),
// ------------------------------------------------------------- export
/**
* The whole filtered result set, not the current page. Lazy so it only
* runs when the officer actually asks for a file.
*/
exportApplications: builder.query<ExportResult, QueueFilter | void>({
query: (params) => ({
url: '/license-application-review/export',
params: serialiseQueueFilter(params),
}),
}),
/** Officers the Assign and Escalate dialogs can offer. */
getAssignableOfficers: builder.query<AssignableOfficer[], void>({
query: () => ({ url: '/license-application-review/officers' }),
}),
// ----------------------------------------------------- workflow controls
assignApplication: builder.mutation<
LicenseApplication,
{ id: string; officerId: string; remark?: string }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/assign`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
holdApplication: builder.mutation<
LicenseApplication,
{ id: string; reason: string }
>({
query: ({ id, reason }) => ({
url: `/license-application-review/${id}/hold`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
resumeApplication: builder.mutation<
LicenseApplication,
{ id: string; remark?: string }
>({
query: ({ id, remark }) => ({
url: `/license-application-review/${id}/resume`,
method: 'POST',
body: { remark },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
escalateApplication: builder.mutation<
LicenseApplication,
{ id: string; supervisorId: string; reason: string }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/escalate`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
confirmPayment: builder.mutation<LicenseApplication, string>({
query: (id) => ({
url: `/license-application-review/${id}/confirm-payment`,
@@ -397,6 +698,7 @@ export const {
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
useUpdateLicenseValidityMutation,
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
useGetMyApplicationsQuery,
@@ -417,6 +719,31 @@ export const {
useDeleteAttachmentMutation,
useGetQueueQuery,
useGetAssignedToMeQuery,
useGetAllApplicationsQuery,
useGetQueueCountsQuery,
useGetLicenseTemplatesQuery,
useGetTemplateVariablesQuery,
useGetBuiltInTemplateQuery,
useCreateLicenseTemplateMutation,
useUpdateLicenseTemplateMutation,
usePublishLicenseTemplateMutation,
useArchiveLicenseTemplateMutation,
useDeleteLicenseTemplateMutation,
useGetDocumentReviewsQuery,
useReviewDocumentMutation,
useClearDocumentReviewMutation,
useGetSavedViewsQuery,
useCreateSavedViewMutation,
useDeleteSavedViewMutation,
useLazyExportApplicationsQuery,
useGetAssignableOfficersQuery,
useSuspendLicenseMutation,
useRevokeLicenseMutation,
useReinstateLicenseMutation,
useAssignApplicationMutation,
useHoldApplicationMutation,
useResumeApplicationMutation,
useEscalateApplicationMutation,
useGetApplicationForReviewQuery,
useClaimApplicationMutation,
useCompleteReviewMutation,

View File

@@ -57,6 +57,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
INSPECTION_COMPLETED: 'Inspection Completed',
APPROVED: 'Approved',
REJECTED: 'Rejected',
ON_HOLD: 'On Hold',
PAYMENT_PENDING: 'Payment Pending',
PAID: 'Paid',
PAYMENT_CONFIRMED: 'Preparing Certificate',
@@ -75,6 +76,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
INSPECTION_COMPLETED: 'cyan',
APPROVED: 'teal',
REJECTED: 'red',
ON_HOLD: 'gray',
PAYMENT_PENDING: 'yellow',
PAID: 'lime',
PAYMENT_CONFIRMED: 'teal',
@@ -97,6 +99,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
INSPECTION_PENDING: 55,
INSPECTION_COMPLETED: 65,
APPROVED: 75,
// Parked, so it keeps the progress of wherever it was held from.
ON_HOLD: 45,
PAYMENT_PENDING: 80,
PAID: 88,
PAYMENT_CONFIRMED: 94,

View File

@@ -16,6 +16,7 @@ export type LicenseStatus =
| 'INSPECTION_COMPLETED'
| 'APPROVED'
| 'REJECTED'
| 'ON_HOLD'
| 'PAYMENT_PENDING'
| 'PAID'
| 'PAYMENT_CONFIRMED'
@@ -97,6 +98,11 @@ export interface LicenseType {
feeCurrency: string;
capitalThreshold: string | number | null;
validityMonths: number;
/**
* Target turnaround in hours. Null means this type is not tracked against
* an SLA, which the grid renders as "—" rather than as instantly overdue.
*/
slaHours: number | null;
inspectionRequired: boolean;
issuesCertificate: boolean;
renewalEnabled: boolean;
@@ -202,6 +208,8 @@ export interface Attachment {
validFrom: string | null;
validTo: string | null;
files: AttachmentFile[];
/** From BaseEntity. Used to place the upload on the activity trail. */
createdAt?: string;
}
export interface StatusHistoryEntry {
@@ -264,6 +272,105 @@ export interface AppNotification {
createdAt: string;
}
/** Server-side queue filters. Mirrors ApplicationQueueFilterDto in the API. */
export interface QueueFilter {
licenseTypeId?: string;
search?: string;
status?: LicenseStatus[];
/** Officer uuid, or the literal 'unassigned'. */
assignee?: string;
submittedFrom?: string;
submittedTo?: string;
overdue?: boolean;
sortBy?: QueueSortField;
sortDir?: 'ASC' | 'DESC';
take?: number;
skip?: number;
}
export type QueueSortField =
| 'submittedAt'
| 'applicationNumber'
| 'companyName'
| 'status'
| 'dueAt';
/** Row counts behind the queue's saved-view tabs. */
export interface QueueCounts {
unassigned: number;
mine: number;
awaitingApplicant: number;
overdue: number;
readyToIssue: number;
all: number;
}
export type DocumentDecision = 'ACCEPTED' | 'REJECTED';
/** An officer's verdict on one uploaded document. */
export interface DocumentReview {
id: string;
applicationId: string;
documentKey: string;
attachmentId: string | null;
decision: DocumentDecision;
reason: string | null;
reviewedById: string | null;
reviewedByName: string | null;
updatedAt: string;
}
/** An officer-defined queue filter, persisted server-side. */
export interface SavedQueueView {
id: string;
ownerUserId: string;
name: string;
queryString: string;
isShared: boolean;
isDefault: boolean;
sortOrder: number;
}
export interface AssignableOfficer {
id: string;
name: string | null;
}
/** Full filtered result set for export. `truncated` when the cap was hit. */
export interface ExportResult {
items: LicenseApplication[];
total: number;
truncated: boolean;
}
export type TemplateStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
export interface TemplatePageOptions {
format?: 'A4' | 'A5' | 'Letter' | 'Legal';
landscape?: boolean;
printBackground?: boolean;
}
/** A certificate design authored in the backoffice. */
export interface LicenseTemplate {
id: string;
licenseTypeId: string;
name: string;
version: number;
hbsSource: string;
pageOptions: TemplatePageOptions | null;
status: TemplateStatus;
publishedAt: string | null;
createdAt: string;
updatedAt: string;
}
/** A placeholder a certificate design may use. */
export interface TemplateVariable {
key: string;
label: string;
}
export interface Paginated<T> {
total: number;
items: T[];
@@ -321,6 +428,10 @@ export interface ApplicationPayment {
/** A licence certificate issued to the applicant. */
export interface IssuedLicense {
/** Why the licence was suspended, revoked or cancelled. */
statusReason?: string | null;
statusChangedAt?: string | null;
statusChangedByName?: string | null;
id: string;
certificateNumber: string;
licenseTypeId: string;

View File

@@ -9,6 +9,17 @@ export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice';
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
export { usePermissions } from './lib/hooks/usePermissions';
export type { PermissionSet } from './lib/hooks/usePermissions';
export {
useCurrentProfile,
useGetMyProfileQuery,
useUpdateMyProfileMutation,
useUpdateMyAddressMutation,
PROFILE_FIELDS,
PROFILE_FIELD_SECTION,
} from './lib/hooks/useCurrentProfile';
export type { ProfileField, ProfileRequirement, ProfileMeResponse } from './lib/hooks/useCurrentProfile';
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
export { refreshAccessToken } from './lib/utils/refresh-token';
export type { AuthUser, AuthState, LoginPayload, CurrentProfile, CurrentProfileAddress, CurrentProfileProfession } from './lib/types/auth.types';

View File

@@ -0,0 +1,164 @@
import { useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { baseApi } from '@ema-platform/api';
import { authStorage } from '../utils/auth-storage';
import { setCurrentProfile } from '../store/auth.slice';
import type { CurrentProfile } from '../types/auth.types';
/**
* Profile fields the portal knows how to collect.
*
* Mirrors `PROFILE_FIELDS` in the API (`module/profile/profile-completeness.ts`).
* Screens name the fields they need from this list rather than checking
* properties ad hoc, so "what is missing" has one definition.
*/
export const PROFILE_FIELDS = [
'firstName',
'middleName',
'lastName',
'gender',
'dob',
'pob',
'maritalStatus',
'professionId',
'idType',
'idNumber',
'nationality',
'primaryPhoneNumber',
'email',
'regionId',
'cityId',
'subCityId',
'woredaId',
'streetAddress',
'emergencyContactName',
'emergencyContactPhone',
'emergencyContactRelation',
] as const;
export type ProfileField = (typeof PROFILE_FIELDS)[number];
/** Which `/profile` tab collects a field — used to build deep links. */
export const PROFILE_FIELD_SECTION: Record<ProfileField, 'personal' | 'address' | 'emergency'> = {
firstName: 'personal',
middleName: 'personal',
lastName: 'personal',
gender: 'personal',
dob: 'personal',
pob: 'personal',
maritalStatus: 'personal',
professionId: 'personal',
idType: 'address',
idNumber: 'address',
nationality: 'address',
primaryPhoneNumber: 'address',
email: 'address',
regionId: 'address',
cityId: 'address',
subCityId: 'address',
woredaId: 'address',
streetAddress: 'address',
emergencyContactName: 'emergency',
emergencyContactPhone: 'emergency',
emergencyContactRelation: 'emergency',
};
/** What a screen needs before it can do its job. */
export interface ProfileRequirement {
fields: ProfileField[];
/** Shown to the applicant — why this is being asked for, in plain language. */
reason: string;
}
export interface ProfileMeResponse {
profile: CurrentProfile;
completeness: number;
missing: ProfileField[];
}
const profileApi = baseApi
.enhanceEndpoints({ addTagTypes: ['CurrentProfile'] as const })
.injectEndpoints({
endpoints: (builder) => ({
getMyProfile: builder.query<ProfileMeResponse, void>({
query: () => ({ url: '/profiles/me' }),
providesTags: ['CurrentProfile'],
}),
/**
* Saves one tab of `/profile`. Invalidates the profile so the
* completeness meter and every requirement gate recompute at once.
*/
updateMyProfile: builder.mutation<
unknown,
{ id: string; body: Record<string, unknown> }
>({
query: ({ id, body }) => ({ url: `/profiles/${id}`, method: 'PATCH', body }),
invalidatesTags: ['CurrentProfile'],
}),
updateMyAddress: builder.mutation<
unknown,
{ id: string; body: Record<string, unknown> }
>({
query: ({ id, body }) => ({ url: `/addresses/${id}`, method: 'PATCH', body }),
invalidatesTags: ['CurrentProfile'],
}),
}),
overrideExisting: false,
});
export const {
useGetMyProfileQuery,
useUpdateMyProfileMutation,
useUpdateMyAddressMutation,
} = profileApi;
export const currentProfileApi = profileApi;
/**
* The one way to get at the signed-in user's profile.
*
* Replaces `authStorage.getProfileId()`, which only ever held a value if the
* user had been through the (now deleted) setup wizard. Pages that read it
* directly did `if (!profileId) return;` and rendered blank forever for anyone
* who signed up afterwards.
*
* Resolution order: RTK Query cache → `authStorage` (so the id is available
* synchronously on the very first render) → `GET /profiles/me`, which
* provisions a profile if the user has none. The resolved id is written back
* to storage. Never blocks render: `profileId` may be undefined for a tick,
* and callers should show a loading or empty state rather than bail out.
*/
export function useCurrentProfile() {
const dispatch = useDispatch();
const { data, isLoading, isFetching, error, refetch } = useGetMyProfileQuery();
const profileId = data?.profile?.id ?? authStorage.getProfileId();
useEffect(() => {
if (!data?.profile) return;
authStorage.setProfileId(data.profile.id);
dispatch(setCurrentProfile(data.profile));
}, [data?.profile, dispatch]);
const missing = useMemo(() => data?.missing ?? [], [data?.missing]);
return {
profileId,
profile: data?.profile,
isLoading,
isFetching,
error,
refetch,
completeness: data?.completeness ?? 0,
missing,
/**
* True when nothing the requirement asks for is still blank. Unknown
* profile (still loading) reads as not-ready, so a caller never submits
* against data it has not seen.
*/
isReadyFor: (requirement: ProfileRequirement) =>
Boolean(data) && requirement.fields.every((field) => !missing.includes(field)),
/** The subset of a requirement that is still outstanding. */
gapsFor: (requirement: ProfileRequirement) =>
requirement.fields.filter((field) => missing.includes(field)),
};
}

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { authStorage } from '../utils/auth-storage';
interface TokenClaims {
permissions?: string[];
roles?: string[];
}
/**
* Reads the claims out of the access token without verifying it.
*
* Verification is the API's job — this is only used to decide what to *show*.
* Every guarded route is enforced server-side by `PermissionGuard`, so the
* worst a wrong answer here can do is offer a menu item that then 403s.
*/
function decodeClaims(token: string | undefined): TokenClaims | null {
if (!token) return null;
const payload = token.split('.')[1];
if (!payload) return null;
try {
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
// The claim set is UTF-8; atob yields latin-1, so non-ASCII names would
// otherwise come back mangled.
const decoded = decodeURIComponent(
json
.split('')
.map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
.join(''),
);
return JSON.parse(decoded) as TokenClaims;
} catch {
return null;
}
}
export interface PermissionSet {
permissions: string[];
/** True if the user holds any one of `required`. Empty `required` = allowed. */
can: (required?: string[]) => boolean;
/**
* Whether permissions could be read at all. When false, callers should show
* everything rather than hide the whole application from someone whose token
* simply does not carry the claim.
*/
known: boolean;
}
/**
* What the signed-in user is allowed to do.
*
* Deliberately fails open: if the token carries no `permissions` claim we
* report `known: false` and `can()` returns true. Hiding navigation on a
* claim-shape mismatch would leave a legitimate officer staring at an empty
* sidebar with no way to tell why, whereas failing open costs at most a 403
* on a link they should not have seen.
*/
export function usePermissions(): PermissionSet {
// Re-read whenever the session changes rather than only on mount.
const token = useSelector(
(state: { auth?: { token?: string | null } }) => state.auth?.token,
);
return useMemo(() => {
const claims = decodeClaims(token ?? authStorage.getToken());
const permissions = claims?.permissions ?? [];
const known = Array.isArray(claims?.permissions);
const granted = new Set(permissions);
return {
permissions,
known,
can: (required?: string[]) => {
if (!required?.length) return true;
if (!known) return true;
return required.some((permission) => granted.has(permission));
},
};
}, [token]);
}

View File

@@ -48,7 +48,7 @@ export function LoginPage() {
const [serverError, setServerError] = useState<string | null>(null);
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const [profileCheckTrigger] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
const {
register,
@@ -74,22 +74,22 @@ export function LoginPage() {
}).unwrap();
dispatch(setUser(me));
// Load the seafarer profile if one exists, so those screens have it —
// but never gate sign-in on it.
// Warm the profile so screens that need an id have one on first paint.
// `/profiles/me` provisions an empty profile when the user has none, so
// unlike the old filtered lookup this cannot come back empty-handed.
// Sign-in is still never gated on it — a failure here is ignored and
// `useCurrentProfile` resolves it again on demand.
try {
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
const result = await profileCheckTrigger({
url: `/profiles?q=${encodeURIComponent(q)}`,
const { profile } = await profileTrigger({
url: '/profiles/me',
method: 'GET',
}).unwrap();
if (result.total > 0 && result.items.length > 0) {
const profile = result.items[0];
if (profile) {
authStorage.setProfileId(profile.id);
dispatch(setCurrentProfile(profile));
}
} catch {
// No profile yet. That is fine — a profile is only needed by the
// seafarer features, not to apply for a licence.
// Offline or a 5xx — the portal still works; the resolver retries.
}
if (!me.isPhoneNumberVerified) {

View File

@@ -3,8 +3,12 @@ export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';
export * from './lib/feedback/FeatureUnavailable';
export * from './lib/feedback/EmptyState';
export * from './lib/feedback/ErrorState';
export * from './lib/layout/AppHeader';
export * from './lib/layout/AppSidebar';
export * from './lib/layout/AppTopNav';
export * from './lib/layout/nav-utils';
export * from './lib/layout/BrandAvatar';
export * from './lib/layout/ColorSchemeToggle';
export * from './lib/layout/LanguageSwitcher';

View File

@@ -0,0 +1,47 @@
import { Button, Paper, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconInbox, type Icon } from '@tabler/icons-react';
import type { ReactNode } from 'react';
interface EmptyStateProps {
title: string;
description?: string;
icon?: Icon;
action?: { label: string; onClick: () => void; icon?: ReactNode };
}
/**
* The "nothing here" state.
*
* Distinct from an error: an empty queue is a normal, often good, outcome. It
* says so plainly and offers the next useful action rather than leaving a bare
* grey panel that reads as a failure.
*/
export function EmptyState({
title,
description,
icon: StateIcon = IconInbox,
action,
}: EmptyStateProps) {
return (
<Paper p="xl" withBorder>
<Stack align="center" gap="sm" py="xl">
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
<StateIcon size={28} stroke={1.5} />
</ThemeIcon>
<Text fw={600} fz="lg" ta="center">
{title}
</Text>
{description && (
<Text c="dimmed" size="sm" ta="center" maw={440}>
{description}
</Text>
)}
{action && (
<Button mt="xs" variant="light" leftSection={action.icon} onClick={action.onClick}>
{action.label}
</Button>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,60 @@
import { Button, Code, Paper, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconAlertTriangle, IconRefresh, type Icon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
interface ErrorStateProps {
title: string;
/** What actually failed, in the API's words where available. */
description?: string;
/**
* Request/trace id. Shown verbatim and selectable so a user can quote it in
* a support ticket — "it didn't work" is not something anyone can act on.
*/
correlationId?: string;
onRetry?: () => void;
icon?: Icon;
}
/** The error state every screen shows: what broke, how to retry, what to quote. */
export function ErrorState({
title,
description,
correlationId,
onRetry,
icon: CustomIcon = IconAlertTriangle,
}: ErrorStateProps) {
const { t } = useTranslation();
return (
<Paper p="xl" withBorder role="alert">
<Stack align="center" gap="sm" py="lg">
<ThemeIcon size={48} radius="xl" color="red" variant="light">
<CustomIcon size={26} />
</ThemeIcon>
<Text fw={600} fz="lg" ta="center">
{title}
</Text>
{description && (
<Text c="dimmed" size="sm" ta="center" maw={460}>
{description}
</Text>
)}
{correlationId && (
<Text size="xs" c="dimmed">
{t('error.reference', 'Reference')}: <Code>{correlationId}</Code>
</Text>
)}
{onRetry && (
<Button
mt="xs"
variant="light"
leftSection={<IconRefresh size={16} />}
onClick={onRetry}
>
{t('error.retry', 'Try again')}
</Button>
)}
</Stack>
</Paper>
);
}

View File

@@ -53,8 +53,10 @@ export function AppHeader({
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
{/* Hamburger — styled like user-management Top.tsx */}
{/* The Burger itself owns the click so the control is a real, keyboard
reachable <button>; the Box is chrome only. It previously wrapped a
no-op button, which no keyboard user could operate. */}
<Box
onClick={isMobile ? onToggleNav : onToggleSidebar}
style={{
display: 'flex',
alignItems: 'center',
@@ -84,7 +86,7 @@ export function AppHeader({
>
<Burger
opened={navOpened}
onClick={() => {}}
onClick={isMobile ? onToggleNav : onToggleSidebar}
size="sm"
aria-label="Toggle navigation"
styles={{

View File

@@ -2,6 +2,7 @@ import {
AppShell,
Badge,
NavLink,
Popover,
ScrollArea,
Stack,
Text,
@@ -14,13 +15,32 @@ import {
IconChevronRight,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { BrandMark } from '@ema-platform/auth';
import {
badgeLabel,
isBranchActive,
isItemActive,
toSections,
type NavEntries,
} from './nav-utils';
export interface NavItem {
label: string;
icon: Icon;
to?: string;
/**
* One level only. A parent with children is a disclosure, not a destination,
* so give it either `to` or `children` — not both.
*/
children?: NavItem[];
/**
* Pending count, or `'dot'` for "something is waiting" without a number.
* Zero renders nothing: a badge reading 0 is worse than no badge.
*/
badge?: number | 'dot';
/** Hidden unless the user holds at least one of these. */
permissions?: string[];
/** Not yet connected to real data — surfaced as a "Soon" badge. */
soon?: boolean;
}
@@ -32,15 +52,174 @@ export interface NavSection {
items: NavItem[];
}
/** Both shapes are accepted so callers can migrate to sections gradually. */
export type NavEntries = NavItem[] | NavSection[];
export type { NavEntries } from './nav-utils';
function toSections(entries: NavEntries): NavSection[] {
if (entries.length === 0) return [];
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
return isSectioned
? (entries as NavSection[])
: [{ items: entries as NavItem[] }];
/** Shown when an item is disabled, so no control is ever dead without a reason. */
function itemTooltip(item: NavItem, soonLabel: string): string | undefined {
return item.soon ? soonLabel : undefined;
}
interface SidebarItemProps {
item: NavItem;
collapsed: boolean;
activePath: string;
onNavigate: (item: NavItem) => void;
}
/**
* One nav entry, at either level.
*
* A parent with children renders as a Mantine NavLink disclosure that starts
* open when the current route is inside it, so the user can always see where
* they are without hunting. Collapsed to the icon rail there is no room to
* nest, so a parent becomes a hover flyout instead of silently losing its
* children.
*/
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) {
const { t } = useTranslation();
const ItemIcon = item.icon;
const hasChildren = Boolean(item.children?.length);
const active = isItemActive(item, activePath);
const branchActive = isBranchActive(item, activePath);
const soonLabel = `${t(item.label)}${t('nav.soon', 'Soon')}`;
const badge = badgeLabel(item.badge);
const badgeNode = badge !== null && (
<Badge
size="xs"
circle={item.badge === 'dot'}
variant="filled"
color="red"
radius="sm"
aria-label={
item.badge === 'dot'
? t('nav.pending', 'Items pending')
: t('nav.pendingCount', { count: Number(item.badge), defaultValue: '{{count}} pending' })
}
>
{badge}
</Badge>
);
const rightSection = item.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : (
badgeNode || undefined
);
if (collapsed) {
const trigger = (
<UnstyledButton
onClick={() => !hasChildren && onNavigate(item)}
aria-label={t(item.label)}
aria-current={active ? 'page' : undefined}
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
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,
}}
>
<ItemIcon size={20} stroke={1.6} />
{badge !== null && (
<Badge
size="xs"
circle
variant="filled"
color="red"
style={{ position: 'absolute', top: rem(6), right: rem(10) }}
/>
)}
</UnstyledButton>
);
// Children would be unreachable behind an icon, so open them in a flyout.
if (hasChildren) {
return (
<Popover position="right-start" withArrow shadow="md" trapFocus>
<Popover.Target>{trigger}</Popover.Target>
<Popover.Dropdown p={4}>
<Text size="xs" fw={700} c="dimmed" px="xs" py={4}>
{t(item.label)}
</Text>
{item.children?.map((child) => (
<NavLink
key={child.label}
active={isItemActive(child, activePath)}
label={t(child.label)}
leftSection={<child.icon size={17} stroke={1.6} />}
onClick={() => onNavigate(child)}
variant="light"
styles={{ root: { borderRadius: rem(8) } }}
/>
))}
</Popover.Dropdown>
</Popover>
);
}
return (
<Tooltip
label={itemTooltip(item, soonLabel) ?? t(item.label)}
position="right"
withArrow
>
{trigger}
</Tooltip>
);
}
return (
<NavLink
active={hasChildren ? branchActive && !active : active}
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
rightSection={rightSection}
// Auto-expands the group the user is currently inside.
defaultOpened={hasChildren ? branchActive : undefined}
onClick={() => !hasChildren && onNavigate(item)}
variant="light"
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },
}}
>
{hasChildren
? item.children?.map((child) => (
<NavLink
key={child.label}
active={isItemActive(child, activePath)}
label={t(child.label)}
leftSection={<child.icon size={17} stroke={1.6} />}
rightSection={
child.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : (
badgeLabel(child.badge) !== null && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badgeLabel(child.badge)}
</Badge>
)
) || undefined
}
onClick={() => onNavigate(child)}
variant="light"
styles={{ root: { borderRadius: rem(8) }, label: { fontWeight: 500 } }}
/>
))
: null}
</NavLink>
);
}
interface AppSidebarProps {
@@ -51,6 +230,12 @@ interface AppSidebarProps {
onNavigate: (item: NavItem) => void;
brandName: string;
brandSubtitle: string;
/**
* Rendered in the brand header. Passed in rather than imported so this
* library stays free of a dependency on `@ema-platform/auth`, which depends
* on it — the two formed an import cycle.
*/
brandLogo?: ReactNode;
}
export function AppSidebar({
@@ -61,13 +246,10 @@ export function AppSidebar({
onNavigate,
brandName,
brandSubtitle,
brandLogo,
}: AppSidebarProps) {
const { t } = useTranslation();
const activeNavItem = (item: NavItem) =>
!!item.to &&
(activePath === item.to || activePath.startsWith(`${item.to}/`));
return (
<>
{/* Brand header */}
@@ -83,7 +265,7 @@ export function AppSidebar({
flexShrink: 0,
}}
>
<BrandMark size={32} />
{brandLogo}
{!collapsed && (
<div style={{ minWidth: 0 }}>
<Text
@@ -141,61 +323,15 @@ export function AppSidebar({
}}
/>
)}
{section.items.map((item) => {
const ItemIcon = item.icon;
const active = activeNavItem(item);
if (collapsed) {
return (
<Tooltip
{section.items.map((item) => (
<SidebarItem
key={item.label}
label={item.soon ? `${t(item.label)}${t('nav.soon', 'Soon')}` : t(item.label)}
position="right"
withArrow
>
<UnstyledButton
onClick={() => onNavigate(item)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: rem(40),
borderRadius: rem(10),
opacity: item.soon ? 0.55 : 1,
color: active ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: active ? 'var(--mantine-color-blue-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
</UnstyledButton>
</Tooltip>
);
}
return (
<NavLink
key={item.label}
active={active}
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
// Tells a reviewer at a glance which screens are wired up.
rightSection={
item.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : undefined
}
onClick={() => onNavigate(item)}
variant="light"
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },
}}
/>
);
})}
item={item}
collapsed={collapsed}
activePath={activePath}
onNavigate={onNavigate}
/>
))}
</Stack>
))}
</Stack>

View File

@@ -0,0 +1,187 @@
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
import { IconChevronDown } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { NavItem } from './AppSidebar';
import {
badgeLabel,
isBranchActive,
isItemActive,
toSections,
type NavEntries,
} from './nav-utils';
interface AppTopNavProps {
navItems: NavEntries;
activePath: string;
onNavigate: (item: NavItem) => void;
}
/**
* Horizontal navigation for the top-bar layout.
*
* Every destination used to render as a sibling button in one horizontally
* scrolling strip, so with twenty-odd of them most were off-screen and the
* grouping that the sidebar already had was thrown away. Here each section
* collapses to a single labelled dropdown, which fits and keeps the same
* information architecture as the sidebar.
*/
export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
const { t } = useTranslation();
const sections = toSections(navItems);
return (
<Group
gap={rem(2)}
h="100%"
wrap="nowrap"
role="navigation"
aria-label={t('nav.primary', 'Primary')}
>
{sections.map((section, index) => {
// An unlabelled leading block (Dashboard) is a plain link, not a menu.
if (!section.label) {
return section.items.map((item) => (
<TopNavButton
key={item.label}
label={t(item.label)}
active={isBranchActive(item, activePath)}
badge={badgeLabel(item.badge)}
soon={item.soon}
onClick={() => onNavigate(item)}
/>
));
}
const sectionActive = section.items.some((item) =>
isBranchActive(item, activePath),
);
const pending = section.items.reduce((sum, item) => {
const own = typeof item.badge === 'number' ? item.badge : 0;
const nested = (item.children ?? []).reduce(
(n, child) => n + (typeof child.badge === 'number' ? child.badge : 0),
0,
);
return sum + own + nested;
}, 0);
return (
<Menu
key={section.label ?? `section-${index}`}
trigger="click-hover"
openDelay={80}
closeDelay={140}
position="bottom-start"
withinPortal
shadow="md"
>
<Menu.Target>
<TopNavButton
label={t(section.label)}
active={sectionActive}
badge={badgeLabel(pending)}
withChevron
/>
</Menu.Target>
<Menu.Dropdown>
{section.items.map((item) =>
item.children?.length ? (
<Menu.Sub key={item.label} position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<item.icon size={16} stroke={1.6} />}>
{t(item.label)}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
{item.children.map((child) => (
<Menu.Item
key={child.label}
leftSection={<child.icon size={16} stroke={1.6} />}
onClick={() => onNavigate(child)}
disabled={child.soon}
>
{t(child.label)}
</Menu.Item>
))}
</Menu.Sub.Dropdown>
</Menu.Sub>
) : (
<Menu.Item
key={item.label}
leftSection={<item.icon size={16} stroke={1.6} />}
rightSection={
item.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : (
badgeLabel(item.badge) !== null && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badgeLabel(item.badge)}
</Badge>
)
) || undefined
}
onClick={() => onNavigate(item)}
// `soon` screens have no backend; the Menu.Item's own
// disabled styling plus the badge explains why.
disabled={item.soon}
aria-current={isItemActive(item, activePath) ? 'page' : undefined}
>
{t(item.label)}
</Menu.Item>
),
)}
</Menu.Dropdown>
</Menu>
);
})}
</Group>
);
}
interface TopNavButtonProps {
label: string;
active: boolean;
badge?: string | null;
soon?: boolean;
withChevron?: boolean;
onClick?: () => void;
}
function TopNavButton({
label,
active,
badge,
soon,
withChevron,
onClick,
}: TopNavButtonProps) {
return (
<UnstyledButton
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: `0 ${rem(14)}`,
height: '100%',
borderBottom: '2px solid',
borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent',
color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
opacity: soon ? 0.55 : 1,
marginBottom: -1,
}}
>
<span>{label}</span>
{badge !== null && badge !== undefined && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badge}
</Badge>
)}
{withChevron && <IconChevronDown size={14} stroke={2} />}
</UnstyledButton>
);
}

View File

@@ -0,0 +1,79 @@
import type { NavItem, NavSection } from './AppSidebar';
/** Both shapes are accepted so callers can migrate to sections gradually. */
export type NavEntries = NavItem[] | NavSection[];
export function toSections(entries: NavEntries): NavSection[] {
if (entries.length === 0) return [];
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
return isSectioned
? (entries as NavSection[])
: [{ items: entries as NavItem[] }];
}
/**
* Whether a nav entry points at the current route.
*
* Prefix-matches on a path boundary so `/licensing` lights up for
* `/licensing/FREIGHT_FORWARDER` but not for an unrelated `/licensing-report`.
*/
export function isItemActive(item: NavItem, activePath: string): boolean {
if (!item.to) return false;
return activePath === item.to || activePath.startsWith(`${item.to}/`);
}
/** True when the item, or anything nested under it, matches the route. */
export function isBranchActive(item: NavItem, activePath: string): boolean {
if (isItemActive(item, activePath)) return true;
return (item.children ?? []).some((child) => isItemActive(child, activePath));
}
/**
* Removes entries the user may not see.
*
* An item with no `permissions` is visible to everyone. A parent survives if
* it is itself permitted and at least one child is — a disclosure that opens
* onto nothing is worse than no disclosure at all. Sections left empty are
* dropped so their heading does not hang over a gap.
*/
export function filterByPermissions(
sections: NavSection[],
granted: readonly string[],
): NavSection[] {
const permitted = new Set(granted);
const allows = (item: NavItem) =>
!item.permissions?.length ||
item.permissions.some((permission) => permitted.has(permission));
return sections
.map((section) => ({
...section,
items: section.items
.filter(allows)
.map((item) =>
item.children
? { ...item, children: item.children.filter(allows) }
: item,
)
.filter((item) => !item.children || item.children.length > 0),
}))
.filter((section) => section.items.length > 0);
}
/** Flattens parents and children into one list, for search and breadcrumbs. */
export function flattenNav(sections: NavSection[]): NavItem[] {
return sections.flatMap((section) =>
section.items.flatMap((item) => [item, ...(item.children ?? [])]),
);
}
/** Badge text, or null when there is nothing worth showing. */
export function badgeLabel(badge: NavItem['badge']): string | null {
if (badge === 'dot') return '';
if (typeof badge === 'number' && badge > 0) {
// Three digits of pending work is already "a lot"; the exact number
// stops being actionable and starts breaking the layout.
return badge > 99 ? '99+' : String(badge);
}
return null;
}