Initial End to End functionality

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

View File

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

View File

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

View File

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