mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-06 15:55:03 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -6,6 +6,7 @@ export * from './lib/features/location';
|
||||
export * from './lib/features/seafarer';
|
||||
export * from './lib/features/seafarer-registration';
|
||||
export * from './lib/features/seafarer-document';
|
||||
export * from './lib/features/personal-document';
|
||||
export * from './lib/features/biometric-enrollment';
|
||||
export * from './lib/features/vessel';
|
||||
export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
|
||||
@@ -21,6 +21,8 @@ import type {
|
||||
LicenseTypeRequirements,
|
||||
OperatorType,
|
||||
AssignableOfficer,
|
||||
CertificateCategory,
|
||||
CompletionEffect,
|
||||
DocumentDecision,
|
||||
DocumentReview,
|
||||
EligibleExam,
|
||||
@@ -37,10 +39,15 @@ import type {
|
||||
RemarkTargetType,
|
||||
SavedQueueView,
|
||||
SchemaIssue,
|
||||
ServiceKind,
|
||||
StaffRoleRequirement,
|
||||
TemplateFieldPlacement,
|
||||
TemplateLogoPlacement,
|
||||
TemplatePageOptions,
|
||||
TemplateVariable,
|
||||
PersonalDocumentFilter,
|
||||
PersonalDocumentGroup,
|
||||
WorkflowProfile,
|
||||
} from './licensing.types';
|
||||
|
||||
/**
|
||||
@@ -67,6 +74,16 @@ function serialiseQueueFilter(
|
||||
return params;
|
||||
}
|
||||
|
||||
/** Sends only the facets that are set; `search=` would match nothing. */
|
||||
function dropEmpty(filter: object): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (value === undefined || value === null || value === '' || value === false) continue;
|
||||
params[key] = value;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
const TAGS = [
|
||||
'LicenseType',
|
||||
'OperatorType',
|
||||
@@ -83,6 +100,10 @@ const TAGS = [
|
||||
'PickupAppointment',
|
||||
'Department',
|
||||
'Rank',
|
||||
'StaffRoleRequirement',
|
||||
// Owned by the personal-document slice; named here so declaring a mode of
|
||||
// operation can invalidate the vault, whose slots depend on it.
|
||||
'PersonalDocument',
|
||||
] as const;
|
||||
|
||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||
@@ -127,9 +148,17 @@ export const licensingApi = baseApi
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
// The catalogue is filtered by this, so it has to refetch too.
|
||||
// The catalogue is filtered by this, so it has to refetch too — and so
|
||||
// is the personal document vault, which asks for the documents the
|
||||
// declared modes of operation need.
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('OperatorType'), listTag('LicenseType')],
|
||||
error
|
||||
? []
|
||||
: [
|
||||
listTag('OperatorType'),
|
||||
listTag('LicenseType'),
|
||||
listTag('PersonalDocument'),
|
||||
],
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -159,6 +188,12 @@ export const licensingApi = baseApi
|
||||
feeNewApplication?: number | null;
|
||||
feeRenewal?: number | null;
|
||||
feeCurrency?: string;
|
||||
// The examined-certificate stages. Unlike the two above, these are
|
||||
// read live off the licence type rather than snapshotted, so the
|
||||
// server refuses to clear one a candidate is currently waiting on.
|
||||
feeEligibility?: number | null;
|
||||
feeExamination?: number | null;
|
||||
feeCertificate?: number | null;
|
||||
}
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
@@ -170,6 +205,53 @@ export const licensingApi = baseApi
|
||||
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
||||
}),
|
||||
|
||||
/**
|
||||
* How a licence type behaves: its workflow, eligibility gates, renewal
|
||||
* policy and applicant rules — everything that used to be settable only
|
||||
* by editing a seed file.
|
||||
*
|
||||
* Three of these (`workflowProfile`, `completionEffect`,
|
||||
* `requiresExamination`) decide the course an application runs, and are
|
||||
* read live rather than snapshotted. The server answers 409
|
||||
* `license_type_in_use` when changing one would strand applications that
|
||||
* have not yet had their approval decision.
|
||||
*/
|
||||
updateLicenseBehavior: builder.mutation<
|
||||
LicenseType,
|
||||
{
|
||||
id: string;
|
||||
workflowProfile?: WorkflowProfile;
|
||||
serviceKind?: ServiceKind;
|
||||
completionEffect?: CompletionEffect | null;
|
||||
certificateCategory?: CertificateCategory | null;
|
||||
requiresExamination?: boolean;
|
||||
inspectionRequired?: boolean;
|
||||
issuesCertificate?: boolean;
|
||||
renewalEnabled?: boolean;
|
||||
requiresSeafarerRegistration?: boolean;
|
||||
requiresValidMedical?: boolean;
|
||||
minSeaTimeDays?: number | null;
|
||||
validityMonths?: number;
|
||||
validityDays?: number | null;
|
||||
capitalThreshold?: number | null;
|
||||
renewalWindowDays?: number;
|
||||
expiryReminderDays?: number[];
|
||||
requiresOperatorMode?: boolean;
|
||||
allowMultipleOpenDrafts?: boolean;
|
||||
requiresIssuanceScheduling?: boolean;
|
||||
uniqueFormKeyPath?: string | null;
|
||||
slaHours?: number | null;
|
||||
}
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-types/${id}/behavior`,
|
||||
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,
|
||||
@@ -240,9 +322,30 @@ export const licensingApi = baseApi
|
||||
providesTags: () => [listTag('DocumentRequirement')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Personal document slots, grouped by key and paged by the server.
|
||||
*
|
||||
* Its own endpoint rather than filtering `getDocumentRequirements` in the
|
||||
* browser: one document can be configured against several licence types,
|
||||
* so a page of rows would split a document in half and misreport its
|
||||
* scope. The server groups first, then pages.
|
||||
*/
|
||||
getPersonalDocuments: builder.query<
|
||||
Paginated<PersonalDocumentGroup>,
|
||||
PersonalDocumentFilter | void
|
||||
>({
|
||||
query: (filter) => ({
|
||||
url: '/document-requirements/personal',
|
||||
params: dropEmpty(filter ?? {}),
|
||||
}),
|
||||
providesTags: () => [listTag('DocumentRequirement')],
|
||||
}),
|
||||
|
||||
createDocumentRequirement: builder.mutation<
|
||||
DocumentRequirement,
|
||||
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
|
||||
// No `licenseTypeId` means a personal document, required for every
|
||||
// licence and served from the applicant's own vault.
|
||||
Partial<DocumentRequirement> & { key: string; name: DocumentRequirement['name'] }
|
||||
>({
|
||||
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||
@@ -265,6 +368,42 @@ export const licensingApi = baseApi
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||
}),
|
||||
|
||||
// ----------------------------------------------- staff role requirements
|
||||
/**
|
||||
* Every staff role requirement, filtered by licence type at the call
|
||||
* site — same reasoning as `getDocumentRequirements`: small
|
||||
* configuration data with no pagination need.
|
||||
*/
|
||||
getStaffRoleRequirements: builder.query<Paginated<StaffRoleRequirement>, void>({
|
||||
query: () => ({ url: '/staff-role-requirements' }),
|
||||
providesTags: () => [listTag('StaffRoleRequirement')],
|
||||
}),
|
||||
|
||||
createStaffRoleRequirement: builder.mutation<
|
||||
StaffRoleRequirement,
|
||||
Partial<StaffRoleRequirement> & { licenseTypeId: string; roleKey: string; name: StaffRoleRequirement['name'] }
|
||||
>({
|
||||
query: (body) => ({ url: '/staff-role-requirements', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
|
||||
}),
|
||||
|
||||
updateStaffRoleRequirement: builder.mutation<
|
||||
StaffRoleRequirement,
|
||||
{ id: string } & Partial<StaffRoleRequirement>
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/staff-role-requirements/${id}`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
|
||||
}),
|
||||
|
||||
deleteStaffRoleRequirement: builder.mutation<unknown, string>({
|
||||
query: (id) => ({ url: `/staff-role-requirements/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
|
||||
}),
|
||||
|
||||
// --------------------------------------------------- departments & ranks
|
||||
/** Every department, for the admin editor. */
|
||||
getDepartments: builder.query<Paginated<Department>, void>({
|
||||
@@ -423,6 +562,36 @@ export const licensingApi = baseApi
|
||||
providesTags: (_r, _e, arg) => [itemTag('Attachment', arg.ownerId)],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Copies the applicant's personal documents onto an application, for
|
||||
* every requirement their vault answers and this application has not
|
||||
* already got. Idempotent — a filled slot is never touched.
|
||||
*/
|
||||
fillApplicationDocumentsFromVault: builder.mutation<
|
||||
{ filled: string[] },
|
||||
string
|
||||
>({
|
||||
query: (applicationId) => ({
|
||||
url: `/license-applications/${applicationId}/documents/fill-from-vault`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: (_r, error, applicationId) =>
|
||||
error ? [] : [itemTag('Attachment', applicationId)],
|
||||
}),
|
||||
|
||||
/** The same, for a seafarer registration. */
|
||||
fillRegistrationDocumentsFromVault: builder.mutation<
|
||||
{ filled: string[] },
|
||||
string
|
||||
>({
|
||||
query: (registrationId) => ({
|
||||
url: `/seafarer-registrations/${registrationId}/documents/fill-from-vault`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: (_r, error, registrationId) =>
|
||||
error ? [] : [itemTag('Attachment', registrationId)],
|
||||
}),
|
||||
|
||||
deleteAttachment: builder.mutation<unknown, { attachmentId: string; ownerId: string }>({
|
||||
query: ({ attachmentId }) => ({
|
||||
url: `/attachments/${attachmentId}`,
|
||||
@@ -1210,13 +1379,19 @@ export const {
|
||||
useGetLicenseTypesQuery,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
useUpdateLicenseBehaviorMutation,
|
||||
useUpdateFormSchemaMutation,
|
||||
useValidateFormSchemaMutation,
|
||||
useGetFormSchemaPaletteQuery,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useGetPersonalDocumentsQuery,
|
||||
useCreateDocumentRequirementMutation,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
useGetStaffRoleRequirementsQuery,
|
||||
useCreateStaffRoleRequirementMutation,
|
||||
useUpdateStaffRoleRequirementMutation,
|
||||
useDeleteStaffRoleRequirementMutation,
|
||||
useGetDepartmentsQuery,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useCreateDepartmentMutation,
|
||||
@@ -1248,6 +1423,8 @@ export const {
|
||||
useResolveRemarkMutation,
|
||||
useResubmitApplicationMutation,
|
||||
useGetAttachmentsQuery,
|
||||
useFillApplicationDocumentsFromVaultMutation,
|
||||
useFillRegistrationDocumentsFromVaultMutation,
|
||||
useDeleteAttachmentMutation,
|
||||
useGetQueueQuery,
|
||||
useGetAssignedToMeQuery,
|
||||
|
||||
@@ -400,6 +400,15 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
inspection_not_passed:
|
||||
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
|
||||
license_type_inactive: 'This licence type is not currently accepting applications.',
|
||||
|
||||
// Licence-type configuration guards. Each of these refuses a change that
|
||||
// would leave applications already in progress unable to move.
|
||||
license_type_in_use:
|
||||
'Applications of this type are already in progress, and this setting decides the course they run. Wait until those have been decided, or change something else.',
|
||||
stage_fee_in_use:
|
||||
'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
|
||||
form_schema_missing_protected_paths:
|
||||
'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
|
||||
};
|
||||
|
||||
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
|
||||
|
||||
@@ -109,6 +109,14 @@ export interface FormFieldConfig {
|
||||
showWhen?: FieldCondition;
|
||||
readOnly?: boolean;
|
||||
source?: string;
|
||||
/**
|
||||
* Marks `min` as bound to live configuration rather than stored with the
|
||||
* field — `"licenseType.capitalThreshold"` for a capital amount. The server
|
||||
* resolves it on every read, so `min` already carries the real floor by the
|
||||
* time the wizard sees it; this is only here so the builder round-trips the
|
||||
* binding instead of dropping it on save.
|
||||
*/
|
||||
minSource?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
@@ -187,6 +195,11 @@ export interface LicenseType {
|
||||
feeCurrency: string;
|
||||
capitalThreshold: string | number | null;
|
||||
validityMonths: number;
|
||||
/**
|
||||
* A term in days instead of months, for licences shorter than a month can
|
||||
* express. Wins over `validityMonths` when set; null keeps calendar months.
|
||||
*/
|
||||
validityDays?: number | null;
|
||||
/**
|
||||
* Target turnaround in hours. Null means this type is not tracked against
|
||||
* an SLA, which the grid renders as "—" rather than as instantly overdue.
|
||||
@@ -216,11 +229,39 @@ export interface LicenseType {
|
||||
// --------------------------------------------------- examined certificates
|
||||
/** Approval establishes eligibility; the certificate is earned by exam. */
|
||||
requiresExamination?: boolean;
|
||||
/** Assessment fee, due on submission before any officer review. */
|
||||
feeEligibility?: string | number | null;
|
||||
/** Fee per sitting. Falls back to `feeNewApplication` when null. */
|
||||
feeExamination?: string | number | null;
|
||||
/** Fee to issue after a pass. Falls back to `feeNewApplication` when null. */
|
||||
feeCertificate?: string | number | null;
|
||||
|
||||
// ------------------------------------------------------- behaviour config
|
||||
// Editable from the backoffice Behavior tab. Optional because the API has
|
||||
// only recently begun returning them and older mocks/fixtures omit them.
|
||||
|
||||
/** Licence or registration. Catalogue metadata; no behaviour hangs off it. */
|
||||
serviceKind?: ServiceKind;
|
||||
/** Platform side effect fired when an application of this type completes. */
|
||||
completionEffect?: CompletionEffect | null;
|
||||
/** Only ACTIVE registered seafarers may apply. */
|
||||
requiresSeafarerRegistration?: boolean;
|
||||
/** Submission requires a current, unexpired medical certificate. */
|
||||
requiresValidMedical?: boolean;
|
||||
/** Minimum VERIFIED sea time in days at submission. Null means no floor. */
|
||||
minSeaTimeDays?: number | null;
|
||||
/** Whether several open drafts may exist at once, for per-asset registrations. */
|
||||
allowMultipleOpenDrafts?: boolean;
|
||||
/** Days before expiry that renewal opens. */
|
||||
renewalWindowDays?: number;
|
||||
/** Days before expiry to remind the holder, most distant first. */
|
||||
expiryReminderDays?: number[];
|
||||
/**
|
||||
* Dotted `formData` path whose answer may appear on only one live
|
||||
* application of this type. Null means no such rule.
|
||||
*/
|
||||
uniqueFormKeyPath?: string | null;
|
||||
|
||||
// ---------------------------------------------------------- STCW mapping
|
||||
certificateCategory?: CertificateCategory | null;
|
||||
stcwControlled?: boolean;
|
||||
@@ -264,7 +305,12 @@ export interface StcwCapacityRow {
|
||||
|
||||
export interface DocumentRequirement {
|
||||
id: string;
|
||||
licenseTypeId: string;
|
||||
/**
|
||||
* Null for a personal document — one every applicant keeps in their own
|
||||
* vault regardless of what they apply for, rather than a slot on one
|
||||
* licence's application form.
|
||||
*/
|
||||
licenseTypeId: string | null;
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description?: Bilingual;
|
||||
@@ -275,6 +321,14 @@ export interface DocumentRequirement {
|
||||
maxSizeMb: number;
|
||||
requiresValidityDates: boolean;
|
||||
allowMultiple: boolean;
|
||||
/**
|
||||
* True for a personal document — one the applicant keeps in their own vault
|
||||
* — rather than an upload slot on an application form. Orthogonal to
|
||||
* `licenseTypeId`, which still says which licences it applies to.
|
||||
*/
|
||||
isPersonal: boolean;
|
||||
/** Files the slot accepts: 1, 2 for a front and back, null for unlimited. */
|
||||
maxFiles: number | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
@@ -310,6 +364,7 @@ export interface StaffEvidenceRequirement {
|
||||
|
||||
export interface StaffRoleRequirement {
|
||||
id: string;
|
||||
licenseTypeId: string;
|
||||
roleKey: string;
|
||||
name: Bilingual;
|
||||
minCount: number;
|
||||
@@ -318,6 +373,7 @@ export interface StaffRoleRequirement {
|
||||
minYearsExperience: number | null;
|
||||
requiredEvidence: StaffEvidenceRequirement[];
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface LicenseTypeRequirements {
|
||||
@@ -387,6 +443,12 @@ export interface Attachment {
|
||||
ownerType: string;
|
||||
ownerId: string;
|
||||
documentKey: string;
|
||||
/**
|
||||
* Set when this was filled from the applicant's personal document vault
|
||||
* rather than uploaded here — what both apps badge as "From My Documents".
|
||||
* Null for an ordinary upload, and cleared the moment the file is replaced.
|
||||
*/
|
||||
copiedFromAttachmentId?: string | null;
|
||||
title: string | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
@@ -566,6 +628,15 @@ export type QueueSortField =
|
||||
/** Review → evaluation → (inspection) → approval, or the short registration course. */
|
||||
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
|
||||
|
||||
/** A permission to operate, or a registration granting a status and a number. */
|
||||
export type ServiceKind = "LICENSE" | "REGISTRATION";
|
||||
|
||||
/** Platform side effect fired when an application reaches COMPLETED. */
|
||||
export type CompletionEffect =
|
||||
| "REGISTER_SEAFARER"
|
||||
| "REGISTER_VESSEL"
|
||||
| "OPEN_SEAFARER_DOCUMENTS";
|
||||
|
||||
/** Row counts behind the queue's saved-view tabs. */
|
||||
export interface QueueCounts {
|
||||
unassigned: number;
|
||||
@@ -815,6 +886,31 @@ export interface IssuedLicense {
|
||||
certificateFileKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One personal document as the backoffice manages it: every configured row
|
||||
* sharing a key, which is one slot in the applicant's vault. Several rows mean
|
||||
* the document is scoped to several licence types.
|
||||
*/
|
||||
export interface PersonalDocumentGroup {
|
||||
key: string;
|
||||
rows: DocumentRequirement[];
|
||||
}
|
||||
|
||||
export interface PersonalDocumentFilter {
|
||||
/** Matches the key and the name in either locale. */
|
||||
search?: string;
|
||||
/** A licence type also matches the documents every licence asks for. */
|
||||
licenseTypeId?: string;
|
||||
/** Narrows to the documents configured against no licence type at all. */
|
||||
globalOnly?: boolean;
|
||||
sortBy?: 'sortOrder' | 'key' | 'name';
|
||||
sortDir?: 'ASC' | 'DESC';
|
||||
take?: number;
|
||||
skip?: number;
|
||||
/** Which locale `sortBy: "name"` sorts on. */
|
||||
locale?: 'en' | 'am';
|
||||
}
|
||||
|
||||
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
|
||||
export interface EligibleExam {
|
||||
id: string;
|
||||
|
||||
3
libs/api/src/lib/features/personal-document/index.ts
Normal file
3
libs/api/src/lib/features/personal-document/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './personal-document.types';
|
||||
export * from './personal-document.upload';
|
||||
export * from './personal-document-api';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type { PersonalDocumentSlot } from './personal-document.types';
|
||||
|
||||
const TAG = 'PersonalDocument' as const;
|
||||
const LIST = { type: TAG, id: 'LIST' } as const;
|
||||
|
||||
/**
|
||||
* The applicant's own documents — identity card, photograph, education —
|
||||
* kept against their profile rather than any one application, so they survive
|
||||
* having no seafarer registration yet.
|
||||
*
|
||||
* Reads and deletes live here; the two uploads do not. `fetch` — what
|
||||
* `fetchBaseQuery` runs on — cannot report how much of a request body has gone
|
||||
* up, so they use XHR instead (`personal-document.upload.ts`) and the page
|
||||
* refetches this query when one finishes.
|
||||
*/
|
||||
export const personalDocumentApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: [TAG] })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getMyPersonalDocuments: builder.query<{ slots: PersonalDocumentSlot[] }, void>({
|
||||
query: () => ({ url: '/profiles/me/documents' }),
|
||||
providesTags: () => [LIST],
|
||||
}),
|
||||
|
||||
deletePersonalDocumentFile: builder.mutation<{ deleted: boolean }, string>({
|
||||
query: (fileId) => ({
|
||||
url: `/profiles/me/documents/files/${fileId}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetMyPersonalDocumentsQuery,
|
||||
useDeletePersonalDocumentFileMutation,
|
||||
} = personalDocumentApi;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AttachmentFile, Bilingual } from '../licensing/licensing.types';
|
||||
|
||||
/**
|
||||
* One slot in the applicant's personal document vault, with whatever they
|
||||
* have put in it.
|
||||
*
|
||||
* The slot itself is configuration: a document requirement that names no
|
||||
* licence type applies to every licence, so the backoffice adds and retires
|
||||
* these without a release. That is why the label, the accepted types and the
|
||||
* limits arrive from the API rather than living in the portal.
|
||||
*/
|
||||
export interface PersonalDocumentSlot {
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description: Bilingual | null;
|
||||
/** How many files the slot holds; null means as many as the holder has. */
|
||||
maxFiles: number | null;
|
||||
allowedMimeTypes: string[];
|
||||
maxSizeMb: number;
|
||||
sortOrder: number;
|
||||
files: AttachmentFile[];
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import type { PersonalDocumentSlot } from './personal-document.types';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
|
||||
/** What the server says when it refuses a file — see ProfileDocumentsService. */
|
||||
export interface PersonalDocumentError {
|
||||
message?: string;
|
||||
[detail: string]: unknown;
|
||||
}
|
||||
|
||||
export type PersonalDocumentUploadResult =
|
||||
| { ok: true; slot: PersonalDocumentSlot }
|
||||
| { ok: false; error: PersonalDocumentError };
|
||||
|
||||
/**
|
||||
* Uploads one file and reports how far it has got.
|
||||
*
|
||||
* XHR rather than `fetch`, and therefore outside RTK Query: `fetch` has no
|
||||
* upload progress event, so a request body of any size is a spinner with
|
||||
* nothing behind it. That is tolerable for a 5 MB scan and not for the video a
|
||||
* slot can now be opened to, where the difference between "uploading" and
|
||||
* "uploading, 12%" is the difference between waiting and reloading the page.
|
||||
*
|
||||
* The caller refetches the vault afterwards; nothing here touches the cache.
|
||||
*/
|
||||
function upload(
|
||||
path: string,
|
||||
method: 'POST' | 'PUT',
|
||||
body: FormData,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<PersonalDocumentUploadResult> {
|
||||
return new Promise((resolve) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open(method, `${BASE_API_URL}${path}`);
|
||||
|
||||
const token = resolveTokenFromStorage();
|
||||
if (token) request.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
|
||||
request.upload.onprogress = (event) => {
|
||||
// Not every browser knows the total for a streamed body; without it a
|
||||
// percentage would be invented, so the caller keeps its spinner.
|
||||
if (!event.lengthComputable || !onProgress) return;
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
|
||||
request.onload = () => {
|
||||
const parsed = parseBody(request.responseText);
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
resolve({ ok: true, slot: parsed as PersonalDocumentSlot });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: false, error: toError(parsed, request.status) });
|
||||
};
|
||||
|
||||
// A dropped connection and a cancelled request both land here; neither
|
||||
// carries a server message, so the caller falls back to its own wording.
|
||||
request.onerror = () => resolve({ ok: false, error: {} });
|
||||
request.onabort = () => resolve({ ok: false, error: {} });
|
||||
|
||||
request.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
function parseBody(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nest wraps a thrown `BadRequestException({ message, ... })` as
|
||||
* `{ message: { message, ... } }`, and a plain string message as
|
||||
* `{ message: "slot_full" }`. Both are flattened to the object the UI
|
||||
* translates by its `message` key.
|
||||
*/
|
||||
function toError(parsed: unknown, status: number): PersonalDocumentError {
|
||||
const message = (parsed as { message?: unknown } | null)?.message;
|
||||
if (typeof message === 'object' && message !== null) {
|
||||
return message as PersonalDocumentError;
|
||||
}
|
||||
if (typeof message === 'string') return { message };
|
||||
return { message: `http_${status}` };
|
||||
}
|
||||
|
||||
/** Adds one file to a personal document slot. */
|
||||
export function uploadPersonalDocumentFile(params: {
|
||||
documentKey: string;
|
||||
file: File;
|
||||
onProgress?: (percent: number) => void;
|
||||
}): Promise<PersonalDocumentUploadResult> {
|
||||
const body = new FormData();
|
||||
body.append('documentKey', params.documentKey);
|
||||
body.append('file', params.file, params.file.name);
|
||||
return upload('/profiles/me/documents', 'POST', body, params.onProgress);
|
||||
}
|
||||
|
||||
/** Swaps one file for another in the same slot. */
|
||||
export function replacePersonalDocumentFile(params: {
|
||||
fileId: string;
|
||||
file: File;
|
||||
onProgress?: (percent: number) => void;
|
||||
}): Promise<PersonalDocumentUploadResult> {
|
||||
const body = new FormData();
|
||||
body.append('file', params.file, params.file.name);
|
||||
return upload(
|
||||
`/profiles/me/documents/files/${params.fileId}`,
|
||||
'PUT',
|
||||
body,
|
||||
params.onProgress,
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from "./lib/input/BilingualInput";
|
||||
export * from "./lib/input/AmharicDatePicker";
|
||||
export * from "./lib/feedback/ConfirmModal";
|
||||
export * from "./lib/feedback/PdfPreviewModal";
|
||||
export * from "./lib/feedback/FilePreviewModal";
|
||||
export * from "./lib/feedback/ModalFooter";
|
||||
export * from "./lib/feedback/ApiErrorAlert";
|
||||
export * from "./lib/feedback/notify";
|
||||
|
||||
169
libs/ui/src/lib/feedback/FilePreviewModal.tsx
Normal file
169
libs/ui/src/lib/feedback/FilePreviewModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Anchor, Button, Group, Modal, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { IconExternalLink, IconFileUnknown } from '@tabler/icons-react';
|
||||
|
||||
/** How a file is shown, once its type is known. */
|
||||
type PreviewKind = 'image' | 'video' | 'audio' | 'embed' | 'unsupported';
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'svg'];
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'm4a'];
|
||||
const EMBED_EXTENSIONS = ['pdf', 'txt', 'csv', 'json', 'xml'];
|
||||
|
||||
/**
|
||||
* What the browser can actually render, decided from the mime type where there
|
||||
* is one and the URL's extension where there is not.
|
||||
*
|
||||
* Presigned links carry the storage key in the path, so the extension survives
|
||||
* even when the caller only has a URL. `image/tiff` and `image/heic` are
|
||||
* deliberately treated as images: Safari renders both, and everywhere else the
|
||||
* `<img>` fails visibly rather than an iframe offering a silent download.
|
||||
*/
|
||||
export function resolvePreviewKind(url: string, mimeType?: string | null): PreviewKind {
|
||||
const mime = mimeType?.toLowerCase() ?? '';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime === 'application/pdf' || mime.startsWith('text/')) return 'embed';
|
||||
// Word, Excel and the rest: nothing renders them inline, and an iframe would
|
||||
// quietly start a download instead of previewing anything.
|
||||
if (mime) return 'unsupported';
|
||||
|
||||
const extension = extensionOf(url);
|
||||
if (!extension) return 'embed';
|
||||
if (IMAGE_EXTENSIONS.includes(extension)) return 'image';
|
||||
if (VIDEO_EXTENSIONS.includes(extension)) return 'video';
|
||||
if (AUDIO_EXTENSIONS.includes(extension)) return 'audio';
|
||||
if (EMBED_EXTENSIONS.includes(extension)) return 'embed';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
function extensionOf(url: string): string | null {
|
||||
// Presigned URLs carry a query string; the path is the part with the name.
|
||||
const path = url.split(/[?#]/)[0];
|
||||
const name = path.slice(path.lastIndexOf('/') + 1);
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a stored file gets opened anywhere in the app.
|
||||
*
|
||||
* Never `window.open` / `target="_blank"` a file that can be shown here —
|
||||
* route it through this modal so the reviewer never loses their place to a new
|
||||
* tab. What a slot accepts is configuration now, so this had to grow past the
|
||||
* PDF it started as: a national ID arrives as a photograph, evidence arrives
|
||||
* as video, and an academic record sometimes arrives as the Word file its
|
||||
* institution issued. The last of those genuinely cannot be rendered by a
|
||||
* browser, so it gets an honest panel and a link out rather than an iframe
|
||||
* that silently downloads it.
|
||||
*/
|
||||
export function FilePreviewModal({
|
||||
opened,
|
||||
onClose,
|
||||
url,
|
||||
title = 'Document',
|
||||
mimeType,
|
||||
/** Overrides the detected kind — for a blob URL with no extension. */
|
||||
kind,
|
||||
labels,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
title?: string;
|
||||
mimeType?: string | null;
|
||||
kind?: PreviewKind;
|
||||
/** Supplied by the app so this stays out of the i18n bundles. */
|
||||
labels?: { unsupported?: string; openInNewTab?: string; close?: string };
|
||||
}) {
|
||||
const resolved = kind ?? resolvePreviewKind(url, mimeType);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="80%"
|
||||
centered
|
||||
trapFocus
|
||||
returnFocus
|
||||
styles={
|
||||
resolved === 'image' || resolved === 'video'
|
||||
? // A photograph on a white sheet loses its own edges; the dark mat
|
||||
// is what tells the eye where the file ends.
|
||||
{ body: { background: 'var(--mantine-color-dark-8)', padding: 0 } }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{url && resolved === 'image' && (
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
style={{
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '85vh',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{url && resolved === 'video' && (
|
||||
// Controls only, no autoplay: a review screen that starts making noise
|
||||
// on open is a review screen people mute and then miss the audio on.
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
preload="metadata"
|
||||
style={{ display: 'block', width: '100%', maxHeight: '85vh' }}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)}
|
||||
|
||||
{url && resolved === 'audio' && (
|
||||
<Stack p="md">
|
||||
<audio src={url} controls style={{ width: '100%' }}>
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{url && resolved === 'embed' && (
|
||||
<iframe
|
||||
src={url}
|
||||
title={title}
|
||||
style={{ width: '100%', height: '85vh', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{url && resolved === 'unsupported' && (
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<ThemeIcon size={56} radius="xl" variant="light" color="gray">
|
||||
<IconFileUnknown size={28} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed" ta="center" maw={420}>
|
||||
{labels?.unsupported ??
|
||||
'This file type cannot be shown here. Open it in a new tab to download it.'}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
component="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="light"
|
||||
leftSection={<IconExternalLink size={15} />}
|
||||
>
|
||||
{labels?.openInNewTab ?? 'Open in a new tab'}
|
||||
</Button>
|
||||
<Anchor component="button" type="button" fz="sm" onClick={onClose}>
|
||||
{labels?.close ?? 'Close'}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal } from '@mantine/core';
|
||||
import { FilePreviewModal } from './FilePreviewModal';
|
||||
|
||||
interface PdfPreviewModalProps {
|
||||
opened: boolean;
|
||||
@@ -8,9 +8,14 @@ interface PdfPreviewModalProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a PDF gets opened anywhere in the app. Never `window.open` /
|
||||
* `target="_blank"` a PDF directly — route it through this modal instead, so
|
||||
* the reviewer never loses their place to a new tab.
|
||||
* A PDF viewer, kept as its own name because most callers only ever open a
|
||||
* PDF and say so at the call site.
|
||||
*
|
||||
* The rendering lives in {@link FilePreviewModal}, which also handles images,
|
||||
* video and the file types no browser can show. Callers that know the mime
|
||||
* type should use that directly; the ones here pass a URL alone and get the
|
||||
* same iframe they always had, since a link with no `.something` on the end
|
||||
* resolves to the embed view.
|
||||
*/
|
||||
export function PdfPreviewModal({
|
||||
opened,
|
||||
@@ -19,22 +24,6 @@ export function PdfPreviewModal({
|
||||
title = 'Document',
|
||||
}: PdfPreviewModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="80%"
|
||||
centered
|
||||
trapFocus
|
||||
returnFocus
|
||||
>
|
||||
{url && (
|
||||
<iframe
|
||||
src={url}
|
||||
title={title}
|
||||
style={{ width: '100%', height: '85vh', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
<FilePreviewModal opened={opened} onClose={onClose} url={url} title={title} />
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user