mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Initial End to End functionality
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
164
libs/auth/src/lib/hooks/useCurrentProfile.ts
Normal file
164
libs/auth/src/lib/hooks/useCurrentProfile.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
80
libs/auth/src/lib/hooks/usePermissions.ts
Normal file
80
libs/auth/src/lib/hooks/usePermissions.ts
Normal 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]);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
47
libs/ui/src/lib/feedback/EmptyState.tsx
Normal file
47
libs/ui/src/lib/feedback/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
libs/ui/src/lib/feedback/ErrorState.tsx
Normal file
60
libs/ui/src/lib/feedback/ErrorState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
187
libs/ui/src/lib/layout/AppTopNav.tsx
Normal file
187
libs/ui/src/lib/layout/AppTopNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
libs/ui/src/lib/layout/nav-utils.ts
Normal file
79
libs/ui/src/lib/layout/nav-utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user