mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-27 22:30:56 +00:00
feat(certificate-designer): add custom page size options and update related components
This commit is contained in:
@@ -23,6 +23,10 @@ interface Props {
|
||||
onLogoPlacementChange: (placement: TemplateLogoPlacement) => void;
|
||||
landscape: boolean;
|
||||
onLandscapeChange: (landscape: boolean) => void;
|
||||
pageWidth: string;
|
||||
onPageWidthChange: (width: string) => void;
|
||||
pageHeight: string;
|
||||
onPageHeightChange: (height: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
@@ -51,6 +55,10 @@ export function TemplateBackgroundPanel({
|
||||
onLogoPlacementChange,
|
||||
landscape,
|
||||
onLandscapeChange,
|
||||
pageWidth,
|
||||
onPageWidthChange,
|
||||
pageHeight,
|
||||
onPageHeightChange,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -91,9 +99,40 @@ export function TemplateBackgroundPanel({
|
||||
]}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{landscape
|
||||
? t('designer.a4Landscape', 'A4 — 297 × 210 mm')
|
||||
: t('designer.a4Portrait', 'A4 — 210 × 297 mm')}
|
||||
{pageWidth && pageHeight
|
||||
? t('designer.customSize', 'Custom size — see below')
|
||||
: landscape
|
||||
? t('designer.a4Landscape', 'A4 — 297 × 210 mm')
|
||||
: t('designer.a4Portrait', 'A4 — 210 × 297 mm')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{t('designer.customPageSize', 'Custom page size')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
placeholder={t('designer.pageWidth', 'Width')}
|
||||
value={pageWidth}
|
||||
onChange={(e) => onPageWidthChange(e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
w={100}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">×</Text>
|
||||
<TextInput
|
||||
placeholder={t('designer.pageHeight', 'Height')}
|
||||
value={pageHeight}
|
||||
onChange={(e) => onPageHeightChange(e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
w={100}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
'designer.customSizeHint',
|
||||
'e.g. 4.92in × 3.46in. Leave both blank to use A4. Overrides orientation\'s A4 size when set.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@@ -8,6 +8,8 @@ interface Props {
|
||||
logoUrl: string;
|
||||
logoPlacement: TemplateLogoPlacement;
|
||||
landscape: boolean;
|
||||
pageWidth?: string;
|
||||
pageHeight?: string;
|
||||
placements: TemplateFieldPlacement[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
@@ -15,9 +17,17 @@ interface Props {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** A4 aspect ratio, the only page size the renderer is configured for. */
|
||||
/** A4 aspect ratio, the fallback for a version with no custom page size. */
|
||||
const A4_RATIO = 297 / 210;
|
||||
|
||||
/** Parses a CSS length like "4.92in" or "125mm" into a unitless number, unit-agnostic — only the ratio between width and height matters here. */
|
||||
function parseLength(value: string): number | null {
|
||||
const match = value.trim().match(/^([\d.]+)/);
|
||||
if (!match) return null;
|
||||
const n = Number(match[1]);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
const LOGO_CORNER_STYLE: Record<string, (offset: number) => React.CSSProperties> = {
|
||||
TOP_LEFT: (o) => ({ top: `${o}%`, left: `${o}%` }),
|
||||
TOP_CENTER: (o) => ({ top: `${o}%`, left: '50%', transform: 'translateX(-50%)' }),
|
||||
@@ -53,6 +63,8 @@ export function TemplateCanvas({
|
||||
logoUrl,
|
||||
logoPlacement,
|
||||
landscape,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
placements,
|
||||
selectedId,
|
||||
onSelect,
|
||||
@@ -180,6 +192,18 @@ export function TemplateCanvas({
|
||||
logoPlacement.offsetPct ?? 5,
|
||||
);
|
||||
|
||||
// A custom size already states its own orientation (4.92in × 3.46in is
|
||||
// landscape on its own), so it is used as-is rather than flipped again by
|
||||
// `landscape` — that flag only disambiguates the A4 fallback below.
|
||||
const customWidth = pageWidth ? parseLength(pageWidth) : null;
|
||||
const customHeight = pageHeight ? parseLength(pageHeight) : null;
|
||||
const aspectRatio =
|
||||
customWidth && customHeight
|
||||
? customWidth / customHeight
|
||||
: landscape
|
||||
? A4_RATIO
|
||||
: 1 / A4_RATIO;
|
||||
|
||||
return (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
@@ -195,7 +219,7 @@ export function TemplateCanvas({
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: landscape ? String(A4_RATIO) : String(1 / A4_RATIO),
|
||||
aspectRatio: String(aspectRatio),
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--mantine-color-gray-4)',
|
||||
overflow: 'hidden',
|
||||
|
||||
@@ -11,8 +11,16 @@ export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
ARCHIVED: 'dark',
|
||||
};
|
||||
|
||||
/** Page options sent with every save and preview — A4, background printed. */
|
||||
export function pageOptionsFor(landscape: boolean) {
|
||||
/**
|
||||
* Page options sent with every save and preview.
|
||||
*
|
||||
* A4 unless the version carries an explicit page size — set for documents
|
||||
* like the Seaman Book, whose ICAO 9303 passport-booklet dimensions have no
|
||||
* named `format` preset. `width`/`height` win over `format` in Puppeteer, so
|
||||
* a custom size is sent alone rather than alongside `format: 'A4'`.
|
||||
*/
|
||||
export function pageOptionsFor(landscape: boolean, size?: { width: string; height: string }) {
|
||||
if (size) return { width: size.width, height: size.height, landscape, printBackground: true };
|
||||
return { format: 'A4' as const, landscape, printBackground: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
const [source, setSource] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [landscape, setLandscape] = useState(true);
|
||||
// Empty string means "use the A4 default" — only a version whose
|
||||
// pageOptions already carries a custom size (e.g. the Seaman Book) starts
|
||||
// with these populated.
|
||||
const [pageWidth, setPageWidth] = useState('');
|
||||
const [pageHeight, setPageHeight] = useState('');
|
||||
const [backgroundUrl, setBackgroundUrl] = useState('');
|
||||
const [logoUrl, setLogoUrl] = useState('');
|
||||
const [logoPlacement, setLogoPlacement] = useState<TemplateLogoPlacement>({});
|
||||
@@ -49,6 +54,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
setSource(selected.hbsSource);
|
||||
setName(selected.name);
|
||||
setLandscape(selected.pageOptions?.landscape ?? true);
|
||||
setPageWidth(selected.pageOptions?.width ?? '');
|
||||
setPageHeight(selected.pageOptions?.height ?? '');
|
||||
setBackgroundUrl(selected.backgroundUrl ?? '');
|
||||
setLogoUrl(selected.logoUrl ?? '');
|
||||
setLogoPlacement(selected.logoPlacement ?? {});
|
||||
@@ -70,6 +77,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
(source !== selected?.hbsSource ||
|
||||
name !== selected?.name ||
|
||||
landscape !== (selected?.pageOptions?.landscape ?? true) ||
|
||||
pageWidth !== (selected?.pageOptions?.width ?? '') ||
|
||||
pageHeight !== (selected?.pageOptions?.height ?? '') ||
|
||||
backgroundUrl !== (selected?.backgroundUrl ?? '') ||
|
||||
logoUrl !== (selected?.logoUrl ?? '') ||
|
||||
logoPlacementChanged ||
|
||||
@@ -137,6 +146,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
setName,
|
||||
landscape,
|
||||
setLandscape,
|
||||
pageWidth,
|
||||
setPageWidth,
|
||||
pageHeight,
|
||||
setPageHeight,
|
||||
backgroundUrl,
|
||||
setBackgroundUrl,
|
||||
logoUrl,
|
||||
|
||||
@@ -9,6 +9,8 @@ interface PreviewArgs {
|
||||
hbsSource: string;
|
||||
licenseTypeId: string | null;
|
||||
landscape: boolean;
|
||||
pageWidth?: string;
|
||||
pageHeight?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,7 +23,7 @@ export function useTemplatePreview() {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const open = useCallback(
|
||||
async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => {
|
||||
async ({ hbsSource, licenseTypeId, landscape, pageWidth, pageHeight }: PreviewArgs) => {
|
||||
try {
|
||||
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
|
||||
// and calls the API directly — which means spelling out the base URL and
|
||||
@@ -36,7 +38,10 @@ export function useTemplatePreview() {
|
||||
body: JSON.stringify({
|
||||
hbsSource,
|
||||
licenseTypeId,
|
||||
pageOptions: pageOptionsFor(landscape),
|
||||
pageOptions: pageOptionsFor(
|
||||
landscape,
|
||||
pageWidth && pageHeight ? { width: pageWidth, height: pageHeight } : undefined,
|
||||
),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
|
||||
@@ -182,6 +182,10 @@ export function CertificateDesignerPage() {
|
||||
onLogoPlacementChange={draft.setLogoPlacement}
|
||||
landscape={draft.landscape}
|
||||
onLandscapeChange={draft.setLandscape}
|
||||
pageWidth={draft.pageWidth}
|
||||
onPageWidthChange={draft.setPageWidth}
|
||||
pageHeight={draft.pageHeight}
|
||||
onPageHeightChange={draft.setPageHeight}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
|
||||
@@ -239,6 +243,8 @@ export function CertificateDesignerPage() {
|
||||
logoUrl={draft.logoUrl}
|
||||
logoPlacement={draft.logoPlacement}
|
||||
landscape={draft.landscape}
|
||||
pageWidth={draft.pageWidth}
|
||||
pageHeight={draft.pageHeight}
|
||||
placements={draft.placements}
|
||||
selectedId={draft.selectedBlockId}
|
||||
onSelect={draft.setSelectedBlockId}
|
||||
@@ -306,6 +312,8 @@ export function CertificateDesignerPage() {
|
||||
: draft.source,
|
||||
licenseTypeId: typeId,
|
||||
landscape: draft.landscape,
|
||||
pageWidth: draft.pageWidth || undefined,
|
||||
pageHeight: draft.pageHeight || undefined,
|
||||
})
|
||||
}
|
||||
onSave={() =>
|
||||
@@ -318,7 +326,12 @@ export function CertificateDesignerPage() {
|
||||
// canvas layout is present, so sending the stale source
|
||||
// alongside it would only fight that.
|
||||
hbsSource: draft.usesCanvas ? undefined : draft.source,
|
||||
pageOptions: pageOptionsFor(draft.landscape),
|
||||
pageOptions: pageOptionsFor(
|
||||
draft.landscape,
|
||||
draft.pageWidth && draft.pageHeight
|
||||
? { width: draft.pageWidth, height: draft.pageHeight }
|
||||
: undefined,
|
||||
),
|
||||
backgroundUrl: draft.backgroundUrl || undefined,
|
||||
logoUrl: draft.logoUrl || undefined,
|
||||
logoPlacement: draft.logoPlacement,
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Container,
|
||||
FileButton,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
@@ -24,8 +27,11 @@ import {
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconEye,
|
||||
IconFileDownload,
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconPaperclip,
|
||||
IconQuestionMark,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -63,6 +69,8 @@ import {
|
||||
useRequestAdjustmentMutation,
|
||||
useResumeApplicationMutation,
|
||||
useScheduleInspectionMutation,
|
||||
useGetCertificateUrlForOfficerMutation,
|
||||
uploadDocument,
|
||||
type RemarkTargetType,
|
||||
type StaffEvidenceRequirement,
|
||||
} from "@ema-platform/api";
|
||||
@@ -212,6 +220,11 @@ export function LicenseReviewPage() {
|
||||
const [confirmPayment] = useConfirmPaymentMutation();
|
||||
const [scheduleIssuance] = useScheduleIssuanceMutation();
|
||||
const [issueCertificate] = useIssueCertificateMutation();
|
||||
const [getCertificateUrlForOfficer] = useGetCertificateUrlForOfficerMutation();
|
||||
const [certificateBusy, setCertificateBusy] = useState<"view" | "download" | null>(
|
||||
null,
|
||||
);
|
||||
const [certificatePreview, setCertificatePreview] = useState<string | null>(null);
|
||||
const [scheduleExam, { isLoading: schedulingExam }] =
|
||||
useScheduleExamMutation();
|
||||
const [recordExamOutcome] = useRecordExamOutcomeMutation();
|
||||
@@ -243,6 +256,9 @@ export function LicenseReviewPage() {
|
||||
const [railOpen, setRailOpen] = useState(true);
|
||||
const [inspectionOpen, setInspectionOpen] = useState(false);
|
||||
const [inspectionDate, setInspectionDate] = useState("");
|
||||
const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
|
||||
"MORNING",
|
||||
);
|
||||
const [issuanceOpen, setIssuanceOpen] = useState(false);
|
||||
const [issuanceDate, setIssuanceDate] = useState("");
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
@@ -250,6 +266,10 @@ export function LicenseReviewPage() {
|
||||
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
||||
const [examScore, setExamScore] = useState<number | undefined>();
|
||||
const [findings, setFindings] = useState("");
|
||||
const [findingsUploadBusy, setFindingsUploadBusy] = useState(false);
|
||||
const [findingsPreview, setFindingsPreview] = useState<
|
||||
{ url: string; title: string } | null
|
||||
>(null);
|
||||
const [checklist, setChecklist] = useState<
|
||||
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
||||
>({});
|
||||
@@ -289,6 +309,12 @@ export function LicenseReviewPage() {
|
||||
|
||||
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
|
||||
|
||||
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
|
||||
useGetAttachmentsQuery(
|
||||
{ ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' },
|
||||
{ skip: !pendingInspection },
|
||||
);
|
||||
|
||||
// Approving means every uploaded document was accepted — one unjudged or
|
||||
// rejected file is enough to keep the decision buttons dead. The counts feed
|
||||
// the hover explanation, so the officer sees how much is left rather than
|
||||
@@ -488,6 +514,34 @@ export function LicenseReviewPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a fresh presigned link and either opens the in-app PDF preview
|
||||
* or a new tab, depending which button was pressed — the link itself is
|
||||
* short-lived, so each click gets its own rather than caching one.
|
||||
*/
|
||||
async function openCertificate(mode: "view" | "download") {
|
||||
if (!app.issuedLicenseId) return;
|
||||
setCertificateBusy(mode);
|
||||
try {
|
||||
const { url } = await getCertificateUrlForOfficer(
|
||||
app.issuedLicenseId,
|
||||
).unwrap();
|
||||
if (mode === "view") {
|
||||
setCertificatePreview(url);
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: t("review.certificateError", "Could not open the certificate"),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
} finally {
|
||||
setCertificateBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Actions with their own dedicated form open that; the rest confirm. */
|
||||
function handleAction(action: ResolvedAction) {
|
||||
switch (action.id) {
|
||||
@@ -875,6 +929,38 @@ export function LicenseReviewPage() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Only once the certificate actually exists — before that there
|
||||
is nothing to view or download yet. */}
|
||||
{app.issuedLicenseId && (
|
||||
<Paper withBorder p="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
{t("review.certificate", "Certificate")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEye size={14} />}
|
||||
loading={certificateBusy === "view"}
|
||||
disabled={Boolean(certificateBusy)}
|
||||
onClick={() => openCertificate("view")}
|
||||
>
|
||||
{t("review.view", "View")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconFileDownload size={14} />}
|
||||
loading={certificateBusy === "download"}
|
||||
disabled={Boolean(certificateBusy)}
|
||||
onClick={() => openCertificate("download")}
|
||||
>
|
||||
{t("review.download", "Download")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Eligibility, checked and shown — not applied invisibly. */}
|
||||
{eligibility.length > 0 && (
|
||||
<Paper withBorder p="md">
|
||||
@@ -1123,7 +1209,15 @@ export function LicenseReviewPage() {
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{inspection.scheduledDate
|
||||
? showDate(inspection.scheduledDate)
|
||||
? `${showDate(inspection.scheduledDate)}${
|
||||
inspection.timeSlot
|
||||
? ` — ${
|
||||
inspection.timeSlot === "MORNING"
|
||||
? t("review.morning", "Morning")
|
||||
: t("review.afternoon", "Afternoon")
|
||||
}`
|
||||
: ""
|
||||
}`
|
||||
: t("review.unscheduled", "Not scheduled")}
|
||||
</Text>
|
||||
{inspection.findings && (
|
||||
@@ -1307,14 +1401,21 @@ export function LicenseReviewPage() {
|
||||
>
|
||||
<Stack>
|
||||
<AmharicDatePicker
|
||||
label={t("review.dateTime", "Date and time")}
|
||||
label={t("review.date", "Date")}
|
||||
value={inspectionDate}
|
||||
onChange={setInspectionDate}
|
||||
withTime
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={inspectionTimeSlot}
|
||||
onChange={(value) => setInspectionTimeSlot(value as "MORNING" | "AFTERNOON")}
|
||||
data={[
|
||||
{ value: "MORNING", label: t("review.morning", "Morning") },
|
||||
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
|
||||
]}
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Tooltip
|
||||
label={t("review.pickDate", "Pick a date and time first")}
|
||||
label={t("review.pickDate", "Pick a date first")}
|
||||
disabled={Boolean(inspectionDate)}
|
||||
>
|
||||
<span>
|
||||
@@ -1332,6 +1433,7 @@ export function LicenseReviewPage() {
|
||||
await scheduleInspection({
|
||||
applicationId: id,
|
||||
scheduledDate: inspectionDate,
|
||||
timeSlot: inspectionTimeSlot,
|
||||
}).unwrap();
|
||||
setInspectionOpen(false);
|
||||
},
|
||||
@@ -1430,6 +1532,60 @@ export function LicenseReviewPage() {
|
||||
autosize
|
||||
minRows={3}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("review.findingsEvidence", "Supporting documents")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{findingsEvidence.flatMap((attachment) =>
|
||||
(attachment.files ?? []).map((file) => (
|
||||
<ActionIcon
|
||||
key={file.id}
|
||||
variant="light"
|
||||
size="lg"
|
||||
aria-label={file.originalName}
|
||||
onClick={() =>
|
||||
file.url &&
|
||||
setFindingsPreview({ url: file.url, title: file.originalName })
|
||||
}
|
||||
>
|
||||
<IconEye size={16} />
|
||||
</ActionIcon>
|
||||
)),
|
||||
)}
|
||||
<FileButton
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
onChange={async (file) => {
|
||||
if (!file || !pendingInspection) return;
|
||||
setFindingsUploadBusy(true);
|
||||
await uploadDocument({
|
||||
ownerType: "INSPECTION",
|
||||
ownerId: pendingInspection.id,
|
||||
documentKey: `evidence-${Date.now()}`,
|
||||
file,
|
||||
});
|
||||
setFindingsUploadBusy(false);
|
||||
refetchFindingsEvidence();
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<ActionIcon
|
||||
{...props}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={!pendingInspection || findingsUploadBusy}
|
||||
aria-label={t("review.uploadEvidence", "Upload document")}
|
||||
>
|
||||
{findingsUploadBusy ? (
|
||||
<Loader size={14} type="oval" />
|
||||
) : (
|
||||
<IconPaperclip size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
<ModalFooter grow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
@@ -1488,6 +1644,20 @@ export function LicenseReviewPage() {
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(findingsPreview)}
|
||||
onClose={() => setFindingsPreview(null)}
|
||||
url={findingsPreview?.url ?? ""}
|
||||
title={findingsPreview?.title}
|
||||
/>
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(certificatePreview)}
|
||||
onClose={() => setCertificatePreview(null)}
|
||||
url={certificatePreview ?? ""}
|
||||
title={t("review.certificate", "Certificate")}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -947,9 +947,14 @@ export const am: Translations = {
|
||||
unscheduled: "አልተያዘም",
|
||||
inspectionResult: "የምርመራ ውጤት",
|
||||
findings: "ግኝቶች",
|
||||
findingsEvidence: "አጋዥ ሰነዶች",
|
||||
uploadEvidence: "ሰነድ መጫን",
|
||||
dateTime: "ቀን እና ሰዓት",
|
||||
date: "ቀን",
|
||||
morning: "ጠዋት",
|
||||
afternoon: "ከሰዓት በኋላ",
|
||||
schedule: "ያዝ",
|
||||
pickDate: "መጀመሪያ ቀን እና ሰዓት ይምረጡ",
|
||||
pickDate: "መጀመሪያ ቀን ይምረጡ",
|
||||
passed: "አልፏል",
|
||||
failed: "ወድቋል",
|
||||
round_one: "ዙር {{count}}",
|
||||
@@ -958,6 +963,10 @@ export const am: Translations = {
|
||||
linkCopied: "አገናኝ ተቀድቷል",
|
||||
actionFailed: "ተግባሩ አልተሳካም",
|
||||
errorTitle: "ይህን ማመልከቻ መጫን አልተቻለም",
|
||||
certificate: "ምስክር ወረቀት",
|
||||
view: "ይመልከቱ",
|
||||
download: "አውርድ",
|
||||
certificateError: "ምስክር ወረቀቱን መክፈት አልተቻለም",
|
||||
hideActivity: "እንቅስቃሴ ደብቅ",
|
||||
showActivity: "እንቅስቃሴ አሳይ",
|
||||
awaitingPayment: "አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።",
|
||||
|
||||
@@ -955,9 +955,14 @@ export const en = {
|
||||
unscheduled: 'Not scheduled',
|
||||
inspectionResult: 'Inspection result',
|
||||
findings: 'Findings',
|
||||
findingsEvidence: 'Supporting documents',
|
||||
uploadEvidence: 'Upload document',
|
||||
dateTime: 'Date and time',
|
||||
date: 'Date',
|
||||
morning: 'Morning',
|
||||
afternoon: 'Afternoon',
|
||||
schedule: 'Schedule',
|
||||
pickDate: 'Pick a date and time first',
|
||||
pickDate: 'Pick a date first',
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
round_one: 'round {{count}}',
|
||||
@@ -966,6 +971,10 @@ export const en = {
|
||||
linkCopied: 'Link copied',
|
||||
actionFailed: 'Action failed',
|
||||
errorTitle: 'Could not load this application',
|
||||
certificate: 'Certificate',
|
||||
view: 'View',
|
||||
download: 'Download',
|
||||
certificateError: 'Could not open the certificate',
|
||||
hideActivity: 'Hide activity',
|
||||
showActivity: 'Show activity',
|
||||
awaitingPayment: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
|
||||
|
||||
@@ -96,13 +96,13 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
label: 'nav.groupSeafarer',
|
||||
items: [
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||
],
|
||||
@@ -112,8 +112,8 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
items: [
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -21,9 +21,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
value={{
|
||||
appName: 'Portal',
|
||||
storagePrefix: 'ema-portal',
|
||||
// Applicants land on the licence list rather than the seafarer
|
||||
// dashboard: signing up here is the first step of applying.
|
||||
loginRedirectPath: '/licensing/applications',
|
||||
loginRedirectPath: '/dashboard',
|
||||
enableSignup: true,
|
||||
enableForgotPassword: true,
|
||||
}}
|
||||
|
||||
@@ -436,6 +436,16 @@ export const licensingApi = baseApi
|
||||
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) => ({
|
||||
@@ -902,7 +912,12 @@ export const licensingApi = baseApi
|
||||
// --------------------------------------------------------- inspection
|
||||
scheduleInspection: builder.mutation<
|
||||
Inspection,
|
||||
{ applicationId: string; scheduledDate: string; location?: string }
|
||||
{
|
||||
applicationId: string;
|
||||
scheduledDate: string;
|
||||
timeSlot: 'MORNING' | 'AFTERNOON';
|
||||
location?: string;
|
||||
}
|
||||
>({
|
||||
query: (body) => ({ url: '/inspections', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error, { applicationId }) =>
|
||||
@@ -980,6 +995,7 @@ export const {
|
||||
useGetMyLicensesQuery,
|
||||
useGetLicensesQuery,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetCertificateUrlForOfficerMutation,
|
||||
useGetApplicationPaymentQuery,
|
||||
usePatchSectionMutation,
|
||||
useAddStaffMutation,
|
||||
|
||||
@@ -443,6 +443,8 @@ export interface Inspection {
|
||||
inspectorId: string | null;
|
||||
inspectorName: string | null;
|
||||
scheduledDate: string | null;
|
||||
/** Half-day slot the site visit is booked into. */
|
||||
timeSlot: "MORNING" | "AFTERNOON" | null;
|
||||
conductedDate: string | null;
|
||||
location: string | null;
|
||||
status: "SCHEDULED" | "COMPLETED" | "CANCELLED";
|
||||
@@ -540,6 +542,13 @@ export type TemplateStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
|
||||
export interface TemplatePageOptions {
|
||||
format?: "A4" | "A5" | "Letter" | "Legal";
|
||||
/**
|
||||
* Explicit page dimensions (e.g. "4.92in"), for a size `format` has no
|
||||
* named preset for — an ID-3 passport-booklet page, for one. Takes
|
||||
* precedence over `format` when both are present.
|
||||
*/
|
||||
width?: string;
|
||||
height?: string;
|
||||
landscape?: boolean;
|
||||
printBackground?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user