mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-01 19:23:28 +00:00
Four screens were telling the applicant things that were not true, and two back-office actions were writing state nobody should be able to write by hand. The exam form is a stepper. Basic Info and Settings each answer only for their own fields, and a third step reviews the whole thing before anything is sent — "Create Exam" exists on that step alone, so there is no path from an earlier one into the API. Clicking it from Basic Info used to report the Settings fields as missing: a correct error the user could not act on, since that step had not been shown yet. The rules move to ./validation, which is what the per-step check and the final whole-payload check both read, so the two cannot drift apart. Back keeps everything entered — state lives in the component. The portal derives the examination stage from the records rather than from the application status alone. Registering, attendance and the sitting itself all happen on the registration and the attempt, and the portal read none of them — which is how an application still said "Awaiting Exam Date" after the back office had already evaluated the paper. examStageFor() names what the records add up to: eligible to register, registered, attendance confirmed, sitting, under evaluation, passed, failed. Both the applications list and the certificates page use it, and both poll the registrations they read it from, because attendance is recorded by an invigilator while the candidate is watching the screen. EXAM_PAID is no longer a waiting state. Nobody assigns a date: the fee having cleared is exactly what makes the candidate eligible to pick a published sitting, so that row now offers Register rather than a disabled button. Schedule Exam and Record Exam Outcome are gone from the COC queue — actions, modals, RTK endpoints and strings. Their server endpoints are gone too, so this is not a hidden button over a live route.
1125 lines
40 KiB
TypeScript
1125 lines
40 KiB
TypeScript
import { baseApi } from '../../base-api';
|
|
import type {
|
|
AppNotification,
|
|
ApplicationDetail,
|
|
ApplicationKind,
|
|
ApplicationPayment,
|
|
ApplicationStaff,
|
|
Attachment,
|
|
Department,
|
|
DocumentRequirement,
|
|
FormSchemaPalette,
|
|
FormSectionConfig,
|
|
InitiatePaymentResult,
|
|
IssuedLicense,
|
|
Inspection,
|
|
LicenseApplication,
|
|
LicenseCategoryDefinition,
|
|
LicenseStatus,
|
|
LicenseType,
|
|
LicenseTypeRequirements,
|
|
OperatorType,
|
|
AssignableOfficer,
|
|
DocumentDecision,
|
|
DocumentReview,
|
|
ExportResult,
|
|
LicenseTemplate,
|
|
Paginated,
|
|
QueueCounts,
|
|
QueueFilter,
|
|
Rank,
|
|
RankCertificateCategory,
|
|
RemarkTargetType,
|
|
SavedQueueView,
|
|
SchemaIssue,
|
|
TemplateFieldPlacement,
|
|
TemplateLogoPlacement,
|
|
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',
|
|
'OperatorType',
|
|
'LicenseApplication',
|
|
'ApplicationQueue',
|
|
'Attachment',
|
|
'Notification',
|
|
'Inspection',
|
|
'License',
|
|
'SavedView',
|
|
'LicenseTemplate',
|
|
'DocumentRequirement',
|
|
'Department',
|
|
'Rank',
|
|
] 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')],
|
|
}),
|
|
|
|
/**
|
|
* The signed-in applicant's declared modes of operation.
|
|
*
|
|
* Separate from the licence-type catalogue on purpose: the catalogue is
|
|
* the same for everyone and heavily cached, while this is per-user and
|
|
* changes the moment they edit their profile.
|
|
*/
|
|
getMyOperatorTypes: builder.query<{ items: OperatorType[] }, void>({
|
|
query: () => ({ url: '/profiles/me/operations' }),
|
|
providesTags: () => [listTag('OperatorType')],
|
|
}),
|
|
|
|
/** Replaces the set — see the Operations tab in the portal profile. */
|
|
updateMyOperatorTypes: builder.mutation<
|
|
{ items: OperatorType[] },
|
|
{ licenseTypeIds: string[] }
|
|
>({
|
|
query: (body) => ({
|
|
url: '/profiles/me/operations',
|
|
method: 'PUT',
|
|
body,
|
|
}),
|
|
// The catalogue is filtered by this, so it has to refetch too.
|
|
invalidatesTags: (_r, error) =>
|
|
error ? [] : [listTag('OperatorType'), 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)],
|
|
}),
|
|
|
|
/** 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 }
|
|
>({
|
|
query: ({ idOrKey, kind = 'NEW' }) => ({
|
|
url: `/license-types/requirements/${idOrKey}`,
|
|
params: { kind },
|
|
}),
|
|
providesTags: (_r, _e, arg) => [itemTag('LicenseType', arg.idOrKey)],
|
|
}),
|
|
|
|
// ------------------------------------------------ form-schema builder
|
|
/** Replaces a licence type's form schema. Server re-validates on save. */
|
|
updateFormSchema: builder.mutation<
|
|
LicenseType,
|
|
{ id: string; formSchema: { sections: FormSectionConfig[] } }
|
|
>({
|
|
query: ({ id, formSchema }) => ({
|
|
url: `/license-types/${id}/form-schema`,
|
|
method: 'PUT',
|
|
body: { formSchema },
|
|
}),
|
|
invalidatesTags: (_r, error, { id }) =>
|
|
error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')],
|
|
}),
|
|
|
|
/** Dry-run lint, for inline feedback while the schema is being edited. */
|
|
validateFormSchema: builder.mutation<
|
|
{ valid: boolean; issues: SchemaIssue[] },
|
|
{ formSchema: { sections: FormSectionConfig[] }; licenseTypeId?: string }
|
|
>({
|
|
query: (body) => ({
|
|
url: '/license-types/form-schema/validate',
|
|
method: 'POST',
|
|
body,
|
|
}),
|
|
}),
|
|
|
|
/** Field types, condition operators and prefill sources the builder may offer. */
|
|
getFormSchemaPalette: builder.query<FormSchemaPalette, void>({
|
|
query: () => ({ url: '/license-types/form-schema/palette' }),
|
|
}),
|
|
|
|
// ------------------------------------------------- document requirements
|
|
/**
|
|
* Every document requirement, for the admin editor to filter by licence
|
|
* type client-side. The collection-query `q` filter syntax (`w=column:op:
|
|
* value`) has no typed builder on this side, and the table is small
|
|
* configuration data with no pagination need — see `licenseTypeId` usage
|
|
* at the call site.
|
|
*/
|
|
getDocumentRequirements: builder.query<Paginated<DocumentRequirement>, void>({
|
|
query: () => ({ url: '/document-requirements' }),
|
|
providesTags: () => [listTag('DocumentRequirement')],
|
|
}),
|
|
|
|
createDocumentRequirement: builder.mutation<
|
|
DocumentRequirement,
|
|
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
|
|
>({
|
|
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
|
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
|
}),
|
|
|
|
updateDocumentRequirement: builder.mutation<
|
|
DocumentRequirement,
|
|
{ id: string } & Partial<DocumentRequirement>
|
|
>({
|
|
query: ({ id, ...body }) => ({
|
|
url: `/document-requirements/${id}`,
|
|
method: 'PUT',
|
|
body,
|
|
}),
|
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
|
}),
|
|
|
|
deleteDocumentRequirement: builder.mutation<unknown, string>({
|
|
query: (id) => ({ url: `/document-requirements/${id}`, method: 'DELETE' }),
|
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
|
}),
|
|
|
|
// --------------------------------------------------- departments & ranks
|
|
/** Every department, for the admin editor. */
|
|
getDepartments: builder.query<Paginated<Department>, void>({
|
|
query: () => ({ url: '/departments' }),
|
|
providesTags: () => [listTag('Department')],
|
|
}),
|
|
|
|
/** Active departments only — the applicant-facing picker. */
|
|
getActiveDepartments: builder.query<Department[], void>({
|
|
query: () => ({ url: '/departments/active/list' }),
|
|
providesTags: () => [listTag('Department')],
|
|
}),
|
|
|
|
createDepartment: builder.mutation<
|
|
Department,
|
|
Partial<Department> & { code: string; name: Department['name'] }
|
|
>({
|
|
query: (body) => ({ url: '/departments', method: 'POST', body }),
|
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
|
|
}),
|
|
|
|
updateDepartment: builder.mutation<Department, { id: string } & Partial<Department>>({
|
|
query: ({ id, ...body }) => ({ url: `/departments/${id}`, method: 'PUT', body }),
|
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
|
|
}),
|
|
|
|
deleteDepartment: builder.mutation<unknown, string>({
|
|
query: (id) => ({ url: `/departments/${id}`, method: 'DELETE' }),
|
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
|
|
}),
|
|
|
|
/** Every rank, for the admin editor to filter/group by department client-side. */
|
|
getRanks: builder.query<Paginated<Rank>, void>({
|
|
query: () => ({ url: '/ranks' }),
|
|
providesTags: () => [listTag('Rank')],
|
|
}),
|
|
|
|
/** One department's ladder for a category, ordered — the applicant wizard's rank picker. */
|
|
getRankLadder: builder.query<
|
|
Rank[],
|
|
{ departmentId: string; certificateCategory: RankCertificateCategory }
|
|
>({
|
|
query: (params) => ({ url: '/ranks/ladder', params }),
|
|
providesTags: () => [listTag('Rank')],
|
|
}),
|
|
|
|
createRank: builder.mutation<
|
|
Rank,
|
|
Partial<Rank> & {
|
|
departmentId: string;
|
|
certificateCategory: RankCertificateCategory;
|
|
key: string;
|
|
name: Rank['name'];
|
|
}
|
|
>({
|
|
query: (body) => ({ url: '/ranks', method: 'POST', body }),
|
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
|
|
}),
|
|
|
|
updateRank: builder.mutation<Rank, { id: string } & Partial<Rank>>({
|
|
query: ({ id, ...body }) => ({ url: `/ranks/${id}`, method: 'PUT', body }),
|
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
|
|
}),
|
|
|
|
deleteRank: builder.mutation<unknown, string>({
|
|
query: (id) => ({ url: `/ranks/${id}`, method: 'DELETE' }),
|
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
|
|
}),
|
|
|
|
// -------------------------------------------------------- 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
|
|
/** The licence register for enforcement officers. */
|
|
getLicenses: builder.query<
|
|
Paginated<IssuedLicense>,
|
|
{ search?: string } | void
|
|
>({
|
|
query: (args) => ({
|
|
url: '/licenses',
|
|
params: args?.search ? { search: args.search } : undefined,
|
|
}),
|
|
providesTags: () => [listTag('License')],
|
|
}),
|
|
|
|
getMyLicenses: builder.query<Paginated<IssuedLicense>, void>({
|
|
query: () => ({ url: '/licenses/mine' }),
|
|
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` }),
|
|
}),
|
|
|
|
/**
|
|
* Same download link, backoffice side. Separate endpoint from
|
|
* `getCertificateUrl`: the applicant route only ever hands the
|
|
* certificate to its holder, and an officer reviewing what they just
|
|
* issued is never the holder.
|
|
*/
|
|
getCertificateUrlForOfficer: builder.mutation<{ url: string }, string>({
|
|
query: (id) => ({ url: `/licenses/${id}/certificate-backoffice` }),
|
|
}),
|
|
|
|
// ------------------------------------------------------------- review
|
|
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>,
|
|
QueueFilter | void
|
|
>({
|
|
query: (params) => ({
|
|
url: '/license-application-review/assigned-to-me',
|
|
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, string | void>({
|
|
query: (licenseTypeKey) => ({
|
|
url: '/license-application-review/counts',
|
|
params: licenseTypeKey ? { licenseTypeKey } : undefined,
|
|
}),
|
|
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 }[];
|
|
/** Officer-edited wording for the applicant notification. */
|
|
notificationBody?: 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; notificationBody?: string }
|
|
>({
|
|
query: ({ id, ...body }) => ({
|
|
url: `/license-application-review/${id}/reject`,
|
|
method: 'POST',
|
|
body,
|
|
}),
|
|
invalidatesTags: (_r, error, { id }) =>
|
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
|
}),
|
|
|
|
// No `getEligibleExams`, `scheduleExam` or `recordExamOutcome`: the
|
|
// endpoints behind them are gone. Sittings are published as master
|
|
// schedules and candidates register for one themselves, and an outcome
|
|
// is what the exam engine computed and a supervisor approved — neither
|
|
// is the COC queue's to write.
|
|
|
|
/**
|
|
* A failed candidate asks for another sitting. Re-opens the examination
|
|
* fee (EXAM_FAILED -> EXAM_PAYMENT_PENDING); eligibility was already
|
|
* assessed and paid for on the first attempt.
|
|
*/
|
|
retakeExam: builder.mutation<LicenseApplication, string>({
|
|
query: (id) => ({
|
|
url: `/license-applications/${id}/retake`,
|
|
method: 'POST',
|
|
}),
|
|
invalidatesTags: (_r, error, id) =>
|
|
error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication')],
|
|
}),
|
|
|
|
// ------------------------------------------------- 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;
|
|
/** Scopes the draft to one rank's certificate. Omit for the type's default design. */
|
|
rankId?: string | null;
|
|
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;
|
|
rankId?: string | null;
|
|
name?: string;
|
|
hbsSource?: string;
|
|
pageOptions?: TemplatePageOptions;
|
|
backgroundUrl?: string;
|
|
logoUrl?: string;
|
|
logoPlacement?: TemplateLogoPlacement;
|
|
fieldPlacements?: TemplateFieldPlacement[];
|
|
}
|
|
>({
|
|
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' }),
|
|
}),
|
|
|
|
// ------------------------------------------------- team-leader dispatch
|
|
/**
|
|
* Handing work out, and handing it back.
|
|
*
|
|
* `assignApplication` below only re-points an application already in
|
|
* flight; these two *start* a stage, because under the push model
|
|
* assignment is how work begins — nothing is claimed from a queue.
|
|
*/
|
|
assignInspector: builder.mutation<
|
|
LicenseApplication,
|
|
{ id: string; inspectorId: string; remark?: string }
|
|
>({
|
|
query: ({ id, ...body }) => ({
|
|
url: `/license-application-review/${id}/assign-inspector`,
|
|
method: 'POST',
|
|
body,
|
|
}),
|
|
invalidatesTags: (_r, error, { id }) =>
|
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
|
}),
|
|
|
|
reportReview: builder.mutation<
|
|
LicenseApplication,
|
|
{ id: string; remark?: string }
|
|
>({
|
|
query: ({ id, ...body }) => ({
|
|
url: `/license-application-review/${id}/report-review`,
|
|
method: 'POST',
|
|
body,
|
|
}),
|
|
invalidatesTags: (_r, error, { id }) =>
|
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
|
}),
|
|
|
|
reportInspection: builder.mutation<
|
|
LicenseApplication,
|
|
{ id: string; remark?: string }
|
|
>({
|
|
query: ({ id, ...body }) => ({
|
|
url: `/license-application-review/${id}/report-inspection`,
|
|
method: 'POST',
|
|
body,
|
|
}),
|
|
invalidatesTags: (_r, error, { id }) =>
|
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
|
}),
|
|
|
|
// ----------------------------------------------------- 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')],
|
|
}),
|
|
|
|
/**
|
|
* Starts the review: the team leader hands the file to an employee.
|
|
* `assign` above only re-points an application already in flight.
|
|
*/
|
|
assignReviewer: builder.mutation<
|
|
LicenseApplication,
|
|
{ id: string; officerId: string; remark?: string }
|
|
>({
|
|
query: ({ id, ...body }) => ({
|
|
url: `/license-application-review/${id}/assign-reviewer`,
|
|
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`,
|
|
method: 'POST',
|
|
}),
|
|
invalidatesTags: (_r, error, id) =>
|
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
|
}),
|
|
|
|
scheduleIssuance: builder.mutation<
|
|
LicenseApplication,
|
|
{ id: string; scheduledDate: string }
|
|
>({
|
|
query: ({ id, scheduledDate }) => ({
|
|
url: `/license-application-review/${id}/schedule-issuance`,
|
|
method: 'POST',
|
|
body: { scheduledDate },
|
|
}),
|
|
invalidatesTags: (_r, error, { id }) =>
|
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
|
}),
|
|
|
|
issueCertificate: builder.mutation<LicenseApplication, string>({
|
|
query: (id) => ({
|
|
url: `/license-application-review/${id}/issue-certificate`,
|
|
method: 'POST',
|
|
}),
|
|
invalidatesTags: (_r, error, id) =>
|
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
|
}),
|
|
|
|
// --------------------------------------------------------- inspection
|
|
scheduleInspection: builder.mutation<
|
|
Inspection,
|
|
{
|
|
applicationId: string;
|
|
scheduledDate: string;
|
|
timeSlot: 'MORNING' | 'AFTERNOON';
|
|
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;
|
|
/** Structured per-item outcomes (office, warehouse, vehicles, …). */
|
|
checklist?: {
|
|
key: string;
|
|
label: string;
|
|
outcome: 'PASS' | 'FAIL' | 'NEEDS_CORRECTION';
|
|
note?: string;
|
|
}[];
|
|
}
|
|
>({
|
|
query: ({ inspectionId, result, findings, checklist }) => ({
|
|
url: `/inspections/${inspectionId}/result`,
|
|
method: 'PATCH',
|
|
body: { result, findings, ...(checklist?.length ? { checklist } : {}) },
|
|
}),
|
|
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,
|
|
useUpdateFormSchemaMutation,
|
|
useValidateFormSchemaMutation,
|
|
useGetFormSchemaPaletteQuery,
|
|
useGetDocumentRequirementsQuery,
|
|
useCreateDocumentRequirementMutation,
|
|
useUpdateDocumentRequirementMutation,
|
|
useDeleteDocumentRequirementMutation,
|
|
useGetDepartmentsQuery,
|
|
useGetActiveDepartmentsQuery,
|
|
useCreateDepartmentMutation,
|
|
useUpdateDepartmentMutation,
|
|
useDeleteDepartmentMutation,
|
|
useGetRanksQuery,
|
|
useGetRankLadderQuery,
|
|
useCreateRankMutation,
|
|
useUpdateRankMutation,
|
|
useDeleteRankMutation,
|
|
useUpdateLicenseValidityMutation,
|
|
useGetLicenseTypeRequirementsQuery,
|
|
useCreateApplicationMutation,
|
|
useGetMyApplicationsQuery,
|
|
useGetApplicationQuery,
|
|
useInitiatePaymentMutation,
|
|
useBypassPaymentMutation,
|
|
useGetPaymentCapabilitiesQuery,
|
|
useGetMyLicensesQuery,
|
|
useGetLicensesQuery,
|
|
useGetCertificateUrlMutation,
|
|
useGetCertificateUrlForOfficerMutation,
|
|
useGetApplicationPaymentQuery,
|
|
usePatchSectionMutation,
|
|
useAddStaffMutation,
|
|
useRemoveStaffMutation,
|
|
useSubmitApplicationMutation,
|
|
useResolveRemarkMutation,
|
|
useResubmitApplicationMutation,
|
|
useGetAttachmentsQuery,
|
|
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,
|
|
useAssignReviewerMutation,
|
|
useAssignInspectorMutation,
|
|
useReportReviewMutation,
|
|
useReportInspectionMutation,
|
|
useHoldApplicationMutation,
|
|
useResumeApplicationMutation,
|
|
useEscalateApplicationMutation,
|
|
useGetApplicationForReviewQuery,
|
|
useClaimApplicationMutation,
|
|
useCompleteReviewMutation,
|
|
useRequestAdjustmentMutation,
|
|
useApproveDocumentsMutation,
|
|
useFinalApproveMutation,
|
|
useRejectApplicationMutation,
|
|
useRetakeExamMutation,
|
|
useConfirmPaymentMutation,
|
|
useScheduleIssuanceMutation,
|
|
useIssueCertificateMutation,
|
|
useScheduleInspectionMutation,
|
|
useGetInspectionsQuery,
|
|
useRecordInspectionResultMutation,
|
|
useGetNotificationsQuery,
|
|
useGetUnseenNotificationsQuery,
|
|
useMarkNotificationReadMutation,
|
|
useGetMyOperatorTypesQuery,
|
|
useUpdateMyOperatorTypesMutation,
|
|
} = licensingApi;
|