Adding license generation functionality

This commit is contained in:
Muluhabt
2026-07-29 16:53:50 +03:00
parent 26cc1f4fd2
commit 5161e278ce
102 changed files with 5995 additions and 21326 deletions

View File

@@ -1,4 +1,5 @@
export * from './lib/base-api';
export * from './lib/query-and-mutation';
export * from './lib/session';
export * from './lib/features/licensing';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';

View File

@@ -0,0 +1,3 @@
export * from './licensing.types';
export * from './licensing-api';
export * from './licensing.helpers';

View File

@@ -0,0 +1,434 @@
import { baseApi } from '../../base-api';
import type {
AppNotification,
ApplicationDetail,
ApplicationKind,
ApplicationPayment,
ApplicationStaff,
Attachment,
InitiatePaymentResult,
IssuedLicense,
Inspection,
LicenseApplication,
LicenseCategoryDefinition,
LicenseStatus,
LicenseType,
LicenseTypeRequirements,
Paginated,
RemarkTargetType,
} from './licensing.types';
const TAGS = [
'LicenseType',
'LicenseApplication',
'ApplicationQueue',
'Attachment',
'Notification',
'Inspection',
'License',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
const itemTag = (type: (typeof TAGS)[number], id: string) => ({ type, id }) as const;
/**
* Typed licensing endpoints, shared by the portal and the backoffice.
*
* Each resource gets its own cache tag so a transition invalidates the one
* application and its queue, rather than the whole store.
*/
export const licensingApi = baseApi
.enhanceEndpoints({ addTagTypes: TAGS })
.injectEndpoints({
endpoints: (builder) => ({
// ------------------------------------------------------------- config
/** Active licence types an applicant can choose from. */
getLicenseTypes: builder.query<Paginated<LicenseType>, void>({
query: () => ({ url: '/license-types' }),
providesTags: () => [listTag('LicenseType')],
}),
/**
* Category catalogue used to group the licence cards. Static on the
* server, so it is cached under the licence-type list tag.
*/
getLicenseCategories: builder.query<
Paginated<LicenseCategoryDefinition>,
void
>({
query: () => ({ url: '/license-categories' }),
providesTags: () => [listTag('LicenseType')],
}),
/**
* Fee configuration, for licensing administrators.
*
* `null` is a meaningful value for both amounts and must survive the
* round trip: null on the new-application fee means the licence is free,
* null on the renewal fee means it is charged at the new-application
* rate. Sending `undefined` instead would leave the column untouched.
*/
updateLicenseFees: builder.mutation<
LicenseType,
{
id: string;
feeNewApplication?: number | null;
feeRenewal?: number | null;
feeCurrency?: string;
}
>({
query: ({ id, ...body }) => ({
url: `/license-types/${id}/fees`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
getLicenseTypeRequirements: builder.query<
LicenseTypeRequirements,
{ idOrKey: string; kind?: ApplicationKind }
>({
query: ({ idOrKey, kind = 'NEW' }) => ({
url: `/license-types/requirements/${idOrKey}`,
params: { kind },
}),
providesTags: (_r, _e, arg) => [itemTag('LicenseType', arg.idOrKey)],
}),
// -------------------------------------------------------- application
createApplication: builder.mutation<
LicenseApplication,
{ licenseType: string; kind?: ApplicationKind; previousLicenseId?: string }
>({
query: (body) => ({ url: '/license-applications', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
}),
getMyApplications: builder.query<Paginated<LicenseApplication>, void>({
query: () => ({ url: '/license-applications/mine' }),
providesTags: () => [listTag('LicenseApplication')],
}),
getApplication: builder.query<ApplicationDetail, string>({
query: (id) => ({ url: `/license-applications/${id}` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
}),
patchSection: builder.mutation<
LicenseApplication,
{ id: string; sectionKey: string; values: Record<string, unknown> }
>({
query: ({ id, sectionKey, values }) => ({
url: `/license-applications/${id}/sections/${sectionKey}`,
method: 'PATCH',
body: { values },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id)],
}),
addStaff: builder.mutation<
ApplicationStaff,
{ id: string; roleKey: string; fullName: string; position?: string; yearsOfExperience?: number }
>({
query: ({ id, ...body }) => ({
url: `/license-applications/${id}/staff`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id)],
}),
removeStaff: builder.mutation<unknown, { id: string; staffId: string }>({
query: ({ id, staffId }) => ({
url: `/license-applications/${id}/staff/${staffId}`,
method: 'DELETE',
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id)],
}),
submitApplication: builder.mutation<LicenseApplication, string>({
query: (id) => ({ url: `/license-applications/${id}/submit`, method: 'POST' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication'), listTag('ApplicationQueue')],
}),
resolveRemark: builder.mutation<unknown, { id: string; remarkId: string }>({
query: ({ id, remarkId }) => ({
url: `/license-applications/${id}/remarks/${remarkId}/resolve`,
method: 'PATCH',
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id)],
}),
resubmitApplication: builder.mutation<LicenseApplication, string>({
query: (id) => ({ url: `/license-applications/${id}/resubmit`, method: 'POST' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication')],
}),
// -------------------------------------------------------- attachments
getAttachments: builder.query<Attachment[], { ownerType: string; ownerId: string }>({
query: ({ ownerType, ownerId }) => ({
url: '/attachments',
params: { ownerType, ownerId, withUrls: true },
}),
providesTags: (_r, _e, arg) => [itemTag('Attachment', arg.ownerId)],
}),
deleteAttachment: builder.mutation<unknown, { attachmentId: string; ownerId: string }>({
query: ({ attachmentId }) => ({
url: `/attachments/${attachmentId}`,
method: 'DELETE',
}),
invalidatesTags: (_r, error, { ownerId }) =>
error ? [] : [itemTag('Attachment', ownerId)],
}),
// ----------------------------------------------------------- payments
initiatePayment: builder.mutation<
InitiatePaymentResult,
{ id: string; provider?: string; platform?: 'web' | 'mobile'; payerAccount?: string }
>({
query: ({ id, ...body }) => ({
url: `/license-applications/${id}/payments/initiate`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id)],
}),
getApplicationPayment: builder.query<ApplicationPayment, string>({
query: (id) => ({ url: `/license-applications/${id}/payments` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
}),
/** Testing shortcut — only offered when the API reports it enabled. */
bypassPayment: builder.mutation<
{ status: LicenseStatus; certificateIssued: boolean },
string
>({
query: (id) => ({
url: `/license-applications/${id}/payments/bypass`,
method: 'POST',
}),
invalidatesTags: (_r, error, id) =>
error
? []
: [itemTag('LicenseApplication', id), listTag('LicenseApplication'), listTag('License')],
}),
getPaymentCapabilities: builder.query<{ bypassEnabled: boolean }, void>({
query: () => ({ url: '/license-applications/payments/capabilities' }),
}),
// ------------------------------------------------------------ licences
getMyLicenses: builder.query<Paginated<IssuedLicense>, void>({
query: () => ({ url: '/licenses/mine' }),
providesTags: () => [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 ?? {} }),
providesTags: () => [listTag('ApplicationQueue')],
}),
getAssignedToMe: builder.query<
Paginated<LicenseApplication>,
{ licenseTypeId?: string; search?: string } | void
>({
query: (params) => ({
url: '/license-application-review/assigned-to-me',
params: params ?? {},
}),
providesTags: () => [listTag('ApplicationQueue')],
}),
getApplicationForReview: builder.query<ApplicationDetail, string>({
query: (id) => ({ url: `/license-application-review/${id}` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
}),
claimApplication: builder.mutation<LicenseApplication, string>({
query: (id) => ({ url: `/license-application-review/${id}/claim`, method: 'POST' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
completeReview: builder.mutation<
LicenseApplication,
{ id: string; capitalAmountVerified?: number; remark?: string }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/complete-review`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
requestAdjustment: builder.mutation<
LicenseApplication,
{
id: string;
generalRemark?: string;
items: { targetType: RemarkTargetType; targetKey: string; remark: string }[];
}
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/request-adjustment`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
approveDocuments: builder.mutation<LicenseApplication, { id: string; remark?: string }>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/approve-documents`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
/**
* `capitalAmountVerified` is what the officer confirms against the bank
* letter. Licence types with a capital threshold reject the approval
* without it, so it is part of the contract, not an optional extra.
*/
finalApprove: builder.mutation<
LicenseApplication,
{ id: string; remark?: string; capitalAmountVerified?: number }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/final-approve`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
rejectApplication: builder.mutation<LicenseApplication, { id: string; reason: string }>({
query: ({ id, reason }) => ({
url: `/license-application-review/${id}/reject`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
confirmPayment: builder.mutation<LicenseApplication, string>({
query: (id) => ({
url: `/license-application-review/${id}/confirm-payment`,
method: 'POST',
}),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
// --------------------------------------------------------- inspection
scheduleInspection: builder.mutation<
Inspection,
{ applicationId: string; scheduledDate: string; location?: string }
>({
query: (body) => ({ url: '/inspections', method: 'POST', body }),
invalidatesTags: (_r, error, { applicationId }) =>
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
getInspections: builder.query<Inspection[], string>({
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
providesTags: () => [listTag('Inspection')],
}),
recordInspectionResult: builder.mutation<
Inspection,
{ inspectionId: string; applicationId: string; result: 'PASSED' | 'FAILED'; findings: string }
>({
query: ({ inspectionId, result, findings }) => ({
url: `/inspections/${inspectionId}/result`,
method: 'PATCH',
body: { result, findings },
}),
invalidatesTags: (_r, error, { applicationId }) =>
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
// ------------------------------------------------------ notifications
getNotifications: builder.query<{ count: number; items: AppNotification[] }, void>({
query: () => ({ url: '/notifications' }),
providesTags: () => [listTag('Notification')],
}),
getUnseenNotifications: builder.query<{ count: number; items: AppNotification[] }, void>({
query: () => ({ url: '/notifications/unseen' }),
providesTags: () => [listTag('Notification')],
}),
markNotificationRead: builder.mutation<unknown, string>({
query: (id) => ({ url: `/notifications/${id}/read`, method: 'PATCH' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Notification')]),
}),
}),
overrideExisting: false,
});
export const {
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
useGetMyApplicationsQuery,
useGetApplicationQuery,
useInitiatePaymentMutation,
useBypassPaymentMutation,
useGetPaymentCapabilitiesQuery,
useGetMyLicensesQuery,
useGetCertificateUrlMutation,
useGetApplicationPaymentQuery,
usePatchSectionMutation,
useAddStaffMutation,
useRemoveStaffMutation,
useSubmitApplicationMutation,
useResolveRemarkMutation,
useResubmitApplicationMutation,
useGetAttachmentsQuery,
useDeleteAttachmentMutation,
useGetQueueQuery,
useGetAssignedToMeQuery,
useGetApplicationForReviewQuery,
useClaimApplicationMutation,
useCompleteReviewMutation,
useRequestAdjustmentMutation,
useApproveDocumentsMutation,
useFinalApproveMutation,
useRejectApplicationMutation,
useConfirmPaymentMutation,
useScheduleInspectionMutation,
useGetInspectionsQuery,
useRecordInspectionResultMutation,
useGetNotificationsQuery,
useGetUnseenNotificationsQuery,
useMarkNotificationReadMutation,
} = licensingApi;

View File

@@ -0,0 +1,341 @@
import { resolveTokenFromStorage } from '../../session';
import type {
Bilingual,
FormSectionConfig,
LicenseStatus,
ValidationIssue,
} from './licensing.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
/**
* Uploads a document straight to the API.
*
* Files go up the moment they're picked rather than being held until submit —
* the application already exists as a draft, so there is a real owner to
* attach them to, and nothing can be lost if the browser closes mid-wizard.
*/
export async function uploadDocument(params: {
ownerType: 'APPLICATION' | 'APPLICATION_STAFF' | 'INSPECTION' | 'LICENSE';
ownerId: string;
documentKey: string;
file: File;
title?: string;
validFrom?: string;
validTo?: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const form = new FormData();
form.append('ownerType', params.ownerType);
form.append('ownerId', params.ownerId);
form.append('documentKey', params.documentKey);
if (params.title) form.append('title', params.title);
if (params.validFrom) form.append('validFrom', params.validFrom);
if (params.validTo) form.append('validTo', params.validTo);
form.append('files', params.file, params.file.name);
const token = resolveTokenFromStorage();
const res = await fetch(`${BASE_API_URL}/attachments`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (res.ok) return { ok: true };
const body = await res.json().catch(() => null);
return { ok: false, error: body?.message ?? `Upload failed (${res.status})` };
}
/** Human label for a status, in the vocabulary the user stories use. */
export const STATUS_LABELS: Record<LicenseStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
INSPECTION_COMPLETED: 'Inspection Completed',
APPROVED: 'Approved',
REJECTED: 'Rejected',
PAYMENT_PENDING: 'Payment Pending',
PAID: 'Paid',
PAYMENT_CONFIRMED: 'Preparing Certificate',
CERTIFICATE_ISSUED: 'Certificate Issued',
COMPLETED: 'Completed',
};
/** Mantine colour per status — green progresses, orange needs the applicant. */
export const STATUS_COLORS: Record<LicenseStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'indigo',
UNDER_EVALUATION: 'indigo',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'cyan',
INSPECTION_COMPLETED: 'cyan',
APPROVED: 'teal',
REJECTED: 'red',
PAYMENT_PENDING: 'yellow',
PAID: 'lime',
PAYMENT_CONFIRMED: 'teal',
CERTIFICATE_ISSUED: 'green',
COMPLETED: 'green',
};
/**
* Roughly how far through the lifecycle a status sits, as a percentage.
*
* Used only to draw a progress bar. The two terminal outcomes both read as
* finished — a rejection is the end of the road, not 60% of the way somewhere.
*/
export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
DRAFT: 5,
SUBMITTED: 15,
UNDER_REVIEW: 30,
UNDER_EVALUATION: 45,
RESUBMIT_REQUIRED: 30,
INSPECTION_PENDING: 55,
INSPECTION_COMPLETED: 65,
APPROVED: 75,
PAYMENT_PENDING: 80,
PAID: 88,
PAYMENT_CONFIRMED: 94,
CERTIFICATE_ISSUED: 100,
COMPLETED: 100,
REJECTED: 100,
};
/** Statuses where nothing moves until the applicant does something. */
export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
'DRAFT',
'RESUBMIT_REQUIRED',
'PAYMENT_PENDING',
];
/** Statuses that are finished, whichever way they went. */
export const TERMINAL_STATUSES: LicenseStatus[] = [
'CERTIFICATE_ISSUED',
'COMPLETED',
'REJECTED',
];
/** Reads a bilingual value for the active language, falling back to English. */
export function localized(value: Bilingual | undefined, language = 'en'): string {
if (!value) return '';
return (language === 'am' ? value.am : value.en) ?? value.en ?? value.am ?? '';
}
/**
* Pulls the per-field issue list out of a failed submit so the wizard can say
* exactly what is missing instead of a generic error.
*/
export function extractValidationIssues(error: unknown): ValidationIssue[] {
const data = (error as { data?: Record<string, unknown> })?.data;
if (!data) return [];
const issues = data['issues'];
return Array.isArray(issues) ? (issues as ValidationIssue[]) : [];
}
/**
* Server error keys rendered for a human.
*
* The API answers with stable machine keys; showing those raw (e.g.
* "capital_verification_required") tells an officer nothing about what to do.
*/
const ERROR_MESSAGES: Record<string, string> = {
capital_verification_required:
'Record the verified capital before approving — it must be checked against the bank letter.',
capital_below_threshold:
'The verified capital is below the minimum required for this licence type.',
inspection_required_before_approval:
'A physical inspection must be recorded before this application can be approved.',
application_already_claimed: 'Another officer has already claimed this application.',
assigned_to_another_officer:
'This application is assigned to another officer, so only they can act on it.',
not_application_owner: 'This application belongs to another applicant.',
application_incomplete: 'The application is missing required information.',
unresolved_remarks: 'Every requested correction must be addressed first.',
adjustment_items_required: 'Flag at least one item before requesting an adjustment.',
remark_text_required: 'Each flagged item needs a remark explaining what to fix.',
rejection_reason_required: 'A rejection reason is required.',
section_not_flagged_for_adjustment:
'Only the sections the officer flagged can be changed in this round.',
application_not_editable: 'This application can no longer be edited.',
invalid_transition: 'That action is not available at this stage.',
inspection_not_required_use_final_approve:
'This licence type needs no inspection — approve it directly.',
application_not_awaiting_inspection:
'This application is not waiting for an inspection.',
inspection_already_completed: 'This inspection has already been recorded.',
license_type_inactive: 'This licence type is not currently accepting applications.',
};
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
const data = (error as { data?: Record<string, unknown> })?.data;
const raw = data?.['message'];
// The filter passes structured errors through as `{ message: { message } }`.
const key =
typeof raw === 'string'
? raw
: typeof (raw as { message?: unknown })?.message === 'string'
? ((raw as { message: string }).message)
: undefined;
if (key && ERROR_MESSAGES[key]) return ERROR_MESSAGES[key];
if (typeof raw === 'string') return raw;
if (Array.isArray(raw)) return raw.join(', ');
return fallback;
}
/** One wizard step: either a group of form sections, or a fixed stage. */
export interface WizardStep {
key: string;
label: string;
kind: 'sections' | 'staff' | 'documents' | 'review';
sections: FormSectionConfig[];
}
/**
* Turns the configured sections into wizard steps.
*
* Sections that share a `group` collapse onto one step, which is what keeps
* the stepper short. Anything ungrouped keeps a step of its own, so a licence
* type that has not been grouped still behaves exactly as before.
*/
export function buildWizardSteps(
sections: FormSectionConfig[],
formData: Record<string, Record<string, unknown>>,
): WizardStep[] {
const visible = [...sections]
.filter((section) => conditionHolds(section.showWhen, formData))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const steps: WizardStep[] = [];
const byGroup = new Map<string, WizardStep>();
for (const section of visible) {
const group = section.group?.trim();
if (!group) {
steps.push({
key: `section:${section.key}`,
label: localized(section.title),
kind: 'sections',
sections: [section],
});
continue;
}
const existing = byGroup.get(group);
if (existing) {
existing.sections.push(section);
continue;
}
const step: WizardStep = {
key: `group:${group}`,
label: group,
kind: 'sections',
sections: [section],
};
byGroup.set(group, step);
steps.push(step);
}
// Honour explicit group ordering where given, keeping config order otherwise.
const orderOf = (step: WizardStep) =>
Math.min(...step.sections.map((s) => s.groupOrder ?? s.sortOrder ?? 0));
steps.sort((a, b) => orderOf(a) - orderOf(b));
// "Review" is a reserved group: those sections render on the final step
// beside the summary rather than earning a step of their own. It is what
// keeps a one-checkbox declaration from costing a whole page.
const isReviewGroup = (step: WizardStep) =>
step.sections.some((s) => s.group?.trim().toLowerCase() === 'review');
const reviewSections = steps.filter(isReviewGroup).flatMap((s) => s.sections);
const formSteps = steps.filter((step) => !isReviewGroup(step));
return [
...formSteps,
{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] },
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
];
}
/** `${sectionKey}.${fieldKey}` → message, for inline display under a field. */
export type FieldErrors = Record<string, string>;
/**
* Checks the required fields of the given sections against the answers,
* skipping anything a condition currently hides. Used to stop the wizard
* advancing past an incomplete step rather than failing only at submit.
*/
export function validateSections(
sections: FormSectionConfig[],
formData: Record<string, Record<string, unknown>>,
): FieldErrors {
const errors: FieldErrors = {};
for (const section of sections) {
if (!conditionHolds(section.showWhen, formData)) continue;
const values = formData[section.key] ?? {};
for (const field of section.fields ?? []) {
if (!conditionHolds(field.showWhen, formData)) continue;
// Read-only values are supplied by the server, not the applicant.
if (field.readOnly && field.source) continue;
const value = values[field.key];
const empty =
value === undefined ||
value === null ||
(typeof value === 'string' && value.trim() === '') ||
(typeof value === 'boolean' && value === false) ||
(Array.isArray(value) && value.length === 0);
if (field.required && empty) {
errors[`${section.key}.${field.key}`] =
field.type === 'BOOLEAN'
? `${localized(field.label)} must be accepted`
: `${localized(field.label)} is required`;
continue;
}
if (empty) continue;
const numeric = Number(value);
if (!Number.isNaN(numeric)) {
if (field.min !== undefined && numeric < field.min) {
errors[`${section.key}.${field.key}`] =
`Must be at least ${field.min.toLocaleString()}`;
} else if (field.max !== undefined && numeric > field.max) {
errors[`${section.key}.${field.key}`] =
`Must not exceed ${field.max.toLocaleString()}`;
}
}
}
}
return errors;
}
/** Evaluates a config condition against the current form answers. */
export function conditionHolds(
condition:
| { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean }
| undefined
| null,
formData: Record<string, Record<string, unknown>>,
): boolean {
if (!condition?.field) return true;
const value = condition.field
.split('.')
.reduce<unknown>(
(acc, key) =>
acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined,
formData as unknown,
);
const isEmpty = value === undefined || value === null || value === '';
if (condition.isSet !== undefined) return condition.isSet ? !isEmpty : isEmpty;
if (condition.equals !== undefined) return value === condition.equals;
if (condition.notEquals !== undefined) return value !== condition.notEquals;
if (condition.in !== undefined) return condition.in.includes(value as string | number);
return true;
}

View File

@@ -0,0 +1,336 @@
/** Shared licensing contract — mirrors the emaapi domain model. */
export type Bilingual = { en?: string; am?: string };
/**
* The application status vocabulary. Single source of truth for both apps —
* previously this lived in a backoffice mock page.
*/
export type LicenseStatus =
| 'DRAFT'
| 'SUBMITTED'
| 'UNDER_REVIEW'
| 'UNDER_EVALUATION'
| 'RESUBMIT_REQUIRED'
| 'INSPECTION_PENDING'
| 'INSPECTION_COMPLETED'
| 'APPROVED'
| 'REJECTED'
| 'PAYMENT_PENDING'
| 'PAID'
| 'PAYMENT_CONFIRMED'
| 'CERTIFICATE_ISSUED'
| 'COMPLETED';
export type ApplicationKind = 'NEW' | 'RENEWAL';
export type FormFieldType =
| 'TEXT'
| 'TEXTAREA'
| 'NUMBER'
| 'MONEY'
| 'DATE'
| 'SELECT'
| 'BOOLEAN'
| 'EMAIL'
| 'PHONE'
| 'TIN';
export interface FieldCondition {
field: string;
equals?: string | number | boolean;
notEquals?: string | number | boolean;
in?: (string | number)[];
isSet?: boolean;
}
export interface FormFieldConfig {
key: string;
label: Bilingual;
type: FormFieldType;
required?: boolean;
helpText?: Bilingual;
options?: { value: string; label: Bilingual }[];
min?: number;
max?: number;
showWhen?: FieldCondition;
readOnly?: boolean;
source?: string;
sortOrder?: number;
}
export interface FormSectionConfig {
key: string;
title: Bilingual;
description?: Bilingual;
fields: FormFieldConfig[];
showWhen?: FieldCondition;
sortOrder?: number;
/** Sections sharing a group render together on one wizard step. */
group?: string;
/** Position of the group in the stepper; lowest value in a group wins. */
groupOrder?: number;
}
/** Grouping the portal organises the licence catalogue by. */
export type LicenseCategory =
| 'CARGO_FREIGHT'
| 'SHIPPING_AGENCY'
| 'INVESTMENT';
export interface LicenseCategoryDefinition {
key: LicenseCategory;
name: Bilingual;
description: Bilingual;
sortOrder: number;
}
export interface LicenseType {
id: string;
key: string;
name: Bilingual;
description?: Bilingual;
category: LicenseCategory;
certificatePrefix: string;
feeNewApplication: string | number | null;
feeRenewal: string | number | null;
feeCurrency: string;
capitalThreshold: string | number | null;
validityMonths: number;
inspectionRequired: boolean;
issuesCertificate: boolean;
renewalEnabled: boolean;
formSchema: { sections: FormSectionConfig[] };
isActive: boolean;
/** Display order set by EMA; lower comes first. */
sortOrder: number;
}
export interface DocumentRequirement {
id: string;
key: string;
name: Bilingual;
description?: Bilingual;
applicationKind: ApplicationKind;
mode: 'ALWAYS' | 'CONDITIONAL' | 'OPTIONAL';
conditionExpression?: FieldCondition & { previousDocExpired?: string };
allowedMimeTypes: string[];
maxSizeMb: number;
requiresValidityDates: boolean;
sortOrder: number;
}
export interface StaffEvidenceRequirement {
docKey: string;
label: Bilingual;
mandatory: boolean;
}
export interface StaffRoleRequirement {
id: string;
roleKey: string;
name: Bilingual;
minCount: number;
maxCount: number | null;
requiresExperience: boolean;
minYearsExperience: number | null;
requiredEvidence: StaffEvidenceRequirement[];
sortOrder: number;
}
export interface LicenseTypeRequirements {
licenseType: LicenseType;
applicationKind: ApplicationKind;
fee: number | null;
feeCurrency: string;
documentRequirements: DocumentRequirement[];
staffRoleRequirements: StaffRoleRequirement[];
}
export interface LicenseApplication {
id: string;
applicationNumber: string;
licenseTypeId: string;
licenseType?: LicenseType;
applicantUserId: string;
kind: ApplicationKind;
status: LicenseStatus;
assignedOfficerId: string | null;
claimedAt: string | null;
formData: Record<string, Record<string, unknown>>;
companyName: string | null;
tradeName: string | null;
tinNumber: string | null;
businessAddress: string | null;
capitalAmountDeclared: string | null;
capitalAmountVerified: string | null;
adjustmentRound: number;
submittedAt: string | null;
decidedAt: string | null;
rejectionReason: string | null;
feeAmount: string | null;
feeCurrency: string;
issuedLicenseId: string | null;
createdAt: string;
}
export interface ApplicationStaff {
id: string;
roleKey: string;
fullName: string;
position: string | null;
roleCategory: string | null;
yearsOfExperience: number | null;
documents?: { id: string; documentKey: string; files: AttachmentFile[] }[];
}
export interface AttachmentFile {
id: string;
originalName: string;
mimeType: string;
sizeBytes: number;
storageKey: string;
url?: string;
}
export interface Attachment {
id: string;
ownerType: string;
ownerId: string;
documentKey: string;
title: string | null;
validFrom: string | null;
validTo: string | null;
files: AttachmentFile[];
}
export interface StatusHistoryEntry {
id: string;
fromStatus: LicenseStatus | null;
toStatus: LicenseStatus;
event: string;
actorUserId: string | null;
actorName: string | null;
actorRole: string | null;
remark: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
}
export type RemarkTargetType = 'FORM_SECTION' | 'DOCUMENT' | 'STAFF';
export interface ApplicationRemark {
id: string;
roundNumber: number;
targetType: RemarkTargetType;
targetKey: string;
remark: string;
isResolved: boolean;
resolvedAt: string | null;
createdAt: string;
}
export interface ApplicationDetail {
application: LicenseApplication;
staff: ApplicationStaff[];
attachments: Attachment[];
history: StatusHistoryEntry[];
remarks: ApplicationRemark[];
openRemarks: ApplicationRemark[];
availableEvents: string[];
}
export interface Inspection {
id: string;
applicationId: string;
inspectorId: string | null;
inspectorName: string | null;
scheduledDate: string | null;
conductedDate: string | null;
location: string | null;
status: 'SCHEDULED' | 'COMPLETED' | 'CANCELLED';
result: 'PASSED' | 'FAILED' | null;
findings: string | null;
}
export interface AppNotification {
id: string;
subject: Bilingual;
content: Bilingual;
isSeen: boolean;
itemId: string | null;
itemType: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
}
export interface Paginated<T> {
total: number;
items: T[];
}
/** Per-field problems returned by the server when a submission is incomplete. */
export interface ValidationIssue {
kind: 'field' | 'document' | 'staff';
target: string;
field?: string;
message: string;
}
/** What the browser must do to complete a payment. */
export interface ClientAction {
type: 'REDIRECT' | 'LAUNCH_APP' | 'NONE';
url?: string;
appId?: string;
receiveCode?: string;
shortCode?: string;
}
export type PaymentStatus =
| 'PENDING'
| 'PROCESSING'
| 'PAID'
| 'FAILED'
| 'EXPIRED'
| 'CANCELLED';
export interface InitiatePaymentResult {
paymentId: string;
intentId: string;
status: PaymentStatus;
amount: number;
currency: string;
provider: string;
clientAction?: ClientAction;
}
export interface ApplicationPayment {
id: string;
applicationId: string;
paymentIntentId: string;
amount: string;
currency: string;
provider: string;
providerRef: string | null;
merchantOrderId: string | null;
status: PaymentStatus | null;
checkoutUrl: string | null;
paidAt: string | null;
failureReason: string | null;
}
/** A licence certificate issued to the applicant. */
export interface IssuedLicense {
id: string;
certificateNumber: string;
licenseTypeId: string;
licenseType?: LicenseType;
applicationId: string;
companyName: string | null;
tinNumber: string | null;
issueDate: string;
expiryDate: string;
status: 'ACTIVE' | 'EXPIRED' | 'SUSPENDED' | 'CANCELLED' | 'SUPERSEDED';
verificationCode: string;
certificateFileKey: string | null;
}

View File

@@ -2,6 +2,7 @@ export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig';
export type { AuthConfigValue } from './lib/AuthConfig';
export { AuthShell, BrandMark } from './lib/components/AuthShell';
export { ProtectedRoute } from './lib/components/ProtectedRoute';
export { AuthBootstrap } from './lib/components/AuthBootstrap';
export { LoginPage } from './lib/pages/LoginPage';
export { SignupPage } from './lib/pages/SignupPage';
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';

View File

@@ -0,0 +1,70 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useDispatch } from 'react-redux';
import { authStorage } from '../utils/auth-storage';
import { hydrateAuth, logout, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
/**
* Restores the signed-in session before the router renders.
*
* Redux starts empty on every page load while the token lives in
* localStorage, so without this a refresh leaves the app halfsigned-in: the
* route guards see a token and let you through, but pages that read
* `auth.user` think you are a stranger.
*
* A token the server no longer accepts is cleared here rather than left to
* strand the user on a page they cannot act on or sign out of.
*/
export function AuthBootstrap({ children }: { children: ReactNode }) {
const dispatch = useDispatch();
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
async function restore() {
dispatch(hydrateAuth());
const token = authStorage.getToken();
if (!token) {
if (!cancelled) setReady(true);
return;
}
try {
const response = await fetch(`${BASE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const user = (await response.json()) as AuthUser;
if (!cancelled) dispatch(setUser(user));
} else if (response.status === 401 || response.status === 403) {
// Expired or revoked — drop it so the user gets a login screen
// instead of a dead end.
if (!cancelled) dispatch(logout());
}
} catch {
// Offline or the API is down: keep the stored session and let the
// individual screens surface their own errors.
} finally {
if (!cancelled) setReady(true);
}
}
restore();
return () => {
cancelled = true;
};
}, [dispatch]);
// Rendering the router before the session resolves would let the guards
// redirect based on a state that is about to change.
if (!ready) return null;
return <>{children}</>;
}

View File

@@ -74,7 +74,8 @@ export function LoginPage() {
}).unwrap();
dispatch(setUser(me));
let hasProfile = false;
// Load the seafarer profile if one exists, so those screens have it —
// but never gate sign-in on it.
try {
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
const result = await profileCheckTrigger({
@@ -85,10 +86,10 @@ export function LoginPage() {
const profile = result.items[0];
authStorage.setProfileId(profile.id);
dispatch(setCurrentProfile(profile));
hasProfile = true;
}
} catch {
// profile not found — redirect to setup
// No profile yet. That is fine — a profile is only needed by the
// seafarer features, not to apply for a licence.
}
if (!me.isPhoneNumberVerified) {
@@ -96,17 +97,11 @@ export function LoginPage() {
state: {
email: me.email,
phoneNumber: me.phoneNumber,
needsProfile: !hasProfile,
},
});
return;
}
if (!hasProfile) {
navigate('/profile-setup');
return;
}
navigate(loginRedirectPath);
} catch (err: unknown) {
const msg =

View File

@@ -38,11 +38,10 @@ export function OTPVerificationPage() {
const location = useLocation();
const { loginRedirectPath } = useAuthConfig();
const state = location.state as
| { email?: string; phoneNumber?: string; needsProfile?: boolean }
| { email?: string; phoneNumber?: string }
| null;
const email = state?.email ?? '';
const phoneNumber = state?.phoneNumber ?? '';
const needsProfile = state?.needsProfile ?? false;
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
const [resendTrigger, { isLoading: resending }] = useApiMutation();
@@ -73,7 +72,7 @@ export function OTPVerificationPage() {
}).unwrap();
notify.success('Phone number verified successfully');
navigate(needsProfile ? '/profile-setup' : loginRedirectPath);
navigate(loginRedirectPath);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
@@ -142,7 +141,7 @@ export function OTPVerificationPage() {
<Stack gap={6} align="center">
<PinInput
length={CODE_LENGTH}
type="text"
type="alphanumeric"
inputMode="text"
oneTimeCode
size="md"

View File

@@ -115,13 +115,12 @@ export function SignupPage() {
dispatch(setUser(me));
if (data.isPhoneNumberVerified) {
navigate('/profile-setup');
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
state: {
email: values.email,
phoneNumber: values.phoneNumber,
needsProfile: true,
},
});
}

View File

@@ -47,7 +47,7 @@ export interface CurrentProfileAddress {
postalAddress: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
emergencycontactRelation: string | null;
emergencyContactRelation: string | null;
isActive: boolean;
}

View File

@@ -13,7 +13,9 @@ export async function refreshAccessToken(): Promise<string> {
const refreshToken = authStorage.getRefreshToken();
if (!refreshToken) throw new Error('No refresh token available');
const response = await fetch(`${BASE_API_URL}/auth/refresh`, {
// The IAM service exposes this as `refresh-token`; posting to `/auth/refresh`
// 404s, which the catch below turns into a silent logout.
const response = await fetch(`${BASE_API_URL}/auth/refresh-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),

View File

@@ -2,6 +2,7 @@ export * from './lib/input/BilingualInput';
export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';
export * from './lib/feedback/FeatureUnavailable';
export * from './lib/layout/AppHeader';
export * from './lib/layout/AppSidebar';
export * from './lib/layout/BrandAvatar';

View File

@@ -0,0 +1,45 @@
import { Card, Center, Stack, Text, ThemeIcon, Title } from '@mantine/core';
import { IconTool } from '@tabler/icons-react';
import type { ReactNode } from 'react';
interface FeatureUnavailableProps {
/** What the screen will eventually do, e.g. "Seaman Book applications". */
title: string;
/** Optional extra context — what is missing, or what to use instead. */
description?: string;
action?: ReactNode;
}
/**
* Placeholder for a screen whose backend does not exist yet.
*
* These pages previously rendered hardcoded sample rows, which read as real
* records: reviewers saw queues of applications that had never been filed and
* dashboards counting things nobody had done. Showing nothing is the honest
* option — an empty screen cannot be mistaken for data.
*/
export function FeatureUnavailable({
title,
description,
action,
}: FeatureUnavailableProps) {
return (
<Center mih={320}>
<Card withBorder radius="md" padding="xl" maw={520} w="100%">
<Stack align="center" gap="sm">
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
<IconTool size={24} stroke={1.5} />
</ThemeIcon>
<Title order={4} ta="center">
{title}
</Title>
<Text size="sm" c="dimmed" ta="center">
{description ??
'This feature is not connected to the backend yet, so there is nothing to show.'}
</Text>
{action}
</Stack>
</Card>
</Center>
);
}

View File

@@ -1,5 +1,6 @@
import {
AppShell,
Badge,
NavLink,
ScrollArea,
Stack,
@@ -20,11 +21,30 @@ export interface NavItem {
label: string;
icon: Icon;
to?: string;
/** Not yet connected to real data — surfaced as a "Soon" badge. */
soon?: boolean;
}
/** A labelled group of nav items. */
export interface NavSection {
/** i18n key or literal heading. Omit for an ungrouped leading block. */
label?: string;
items: NavItem[];
}
/** Both shapes are accepted so callers can migrate to sections gradually. */
export type NavEntries = NavItem[] | NavSection[];
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[] }];
}
interface AppSidebarProps {
navItems: NavItem[];
navItems: NavEntries;
collapsed: boolean;
activePath: string;
onToggleCollapse: () => void;
@@ -96,14 +116,43 @@ export function AppSidebar({
{/* Navigation items */}
<AppShell.Section grow component={ScrollArea} p="md">
<Stack gap={4}>
{navItems.map((item) => {
<Stack gap={2}>
{toSections(navItems).map((section, sectionIndex) => (
<Stack gap={2} key={section.label ?? `section-${sectionIndex}`}>
{/* Headings are noise when only icons are visible. */}
{section.label && !collapsed && (
<Text
size="xs"
fw={700}
c="dimmed"
mt={sectionIndex === 0 ? 0 : rem(14)}
pl={rem(12)}
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
>
{t(section.label)}
</Text>
)}
{section.label && collapsed && sectionIndex > 0 && (
<div
style={{
height: 1,
margin: `${rem(8)} ${rem(6)}`,
backgroundColor: 'var(--mantine-color-gray-2)',
}}
/>
)}
{section.items.map((item) => {
const ItemIcon = item.icon;
const active = activeNavItem(item);
if (collapsed) {
return (
<Tooltip key={item.label} label={t(item.label)} position="right" withArrow>
<Tooltip
key={item.label}
label={item.soon ? `${t(item.label)}${t('nav.soon', 'Soon')}` : t(item.label)}
position="right"
withArrow
>
<UnstyledButton
onClick={() => onNavigate(item)}
style={{
@@ -113,6 +162,7 @@ export function AppSidebar({
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,
}}
@@ -129,12 +179,25 @@ export function AppSidebar({
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) }, label: { fontWeight: 500 } }}
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },
}}
/>
);
})}
})}
</Stack>
))}
</Stack>
</AppShell.Section>