From c62ec596559dad26e46ee8706783a71e2f305a03 Mon Sep 17 00:00:00 2001 From: Mulu Mehari Date: Sun, 2 Aug 2026 22:44:08 +0300 Subject: [PATCH] Initial End to End functionality --- .../pages/CertificateDesignerPage.tsx | 560 +++++++++ .../components/ActivityRail.tsx | 183 +++ .../license-review/components/DecisionBar.tsx | 243 ++++ .../components/DecisionConfirmModal.tsx | 311 +++++ .../components/DocumentsTab.tsx | 429 +++++++ .../features/license-review/config/actions.ts | 312 +++++ .../license-review/config/license-types.ts | 177 +++ .../src/app/features/license-review/export.ts | 72 ++ .../license-review/pages/LicenseQueuePage.tsx | 758 ++++++++++-- .../pages/LicenseReviewPage.tsx | 1074 +++++++++++------ .../features/license-review/queue-views.ts | 152 +++ .../src/app/features/license-review/sla.ts | 89 ++ .../license-review/useQueueKeyboard.ts | 100 ++ .../location/components/LocationTree.tsx | 2 +- apps/backoffice/src/app/i18n/locales/am.ts | 304 +++++ apps/backoffice/src/app/i18n/locales/en.ts | 304 +++++ .../src/app/layouts/BackofficeLayout.tsx | 198 +-- .../src/app/layouts/CommandPalette.tsx | 96 ++ apps/backoffice/src/app/layouts/nav-config.ts | 144 +++ apps/backoffice/src/app/router/index.tsx | 5 + .../src/app/store/preferences.slice.ts | 29 +- apps/backoffice/src/main.tsx | 22 + apps/backoffice/src/styles.css | 48 + .../src/app/components/ProfileGuard.tsx | 17 - .../dashboard/pages/DashboardPage.tsx | 4 + .../licensing/pages/MyApplicationsPage.tsx | 41 +- .../profile-setup/pages/ProfileSetupPage.tsx | 378 ------ .../components/ProfileCompletionNudge.tsx | 119 ++ .../components/ProfileRequirementGate.tsx | 91 ++ .../features/profile/pages/ProfilePage.tsx | 115 +- .../pages/VesselRegistrationPage.tsx | 8 +- apps/portal/src/app/i18n/locales/am.ts | 50 + apps/portal/src/app/i18n/locales/en.ts | 50 + apps/portal/src/app/layouts/PortalLayout.tsx | 3 +- .../src/app/layouts/VesselOwnerLayout.tsx | 3 +- apps/portal/src/app/router.tsx | 16 +- eslint.config.mjs | 13 + .../lib/features/licensing/licensing-api.ts | 345 +++++- .../features/licensing/licensing.helpers.ts | 4 + .../lib/features/licensing/licensing.types.ts | 111 ++ libs/auth/src/index.ts | 11 + libs/auth/src/lib/hooks/useCurrentProfile.ts | 164 +++ libs/auth/src/lib/hooks/usePermissions.ts | 80 ++ libs/auth/src/lib/pages/LoginPage.tsx | 20 +- libs/ui/src/index.ts | 4 + libs/ui/src/lib/feedback/EmptyState.tsx | 47 + libs/ui/src/lib/feedback/ErrorState.tsx | 60 + libs/ui/src/lib/layout/AppHeader.tsx | 6 +- libs/ui/src/lib/layout/AppSidebar.tsx | 272 +++-- libs/ui/src/lib/layout/AppTopNav.tsx | 187 +++ libs/ui/src/lib/layout/nav-utils.ts | 79 ++ package-lock.json | 87 +- package.json | 2 + 53 files changed, 6791 insertions(+), 1208 deletions(-) create mode 100644 apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx create mode 100644 apps/backoffice/src/app/features/license-review/components/ActivityRail.tsx create mode 100644 apps/backoffice/src/app/features/license-review/components/DecisionBar.tsx create mode 100644 apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx create mode 100644 apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx create mode 100644 apps/backoffice/src/app/features/license-review/config/actions.ts create mode 100644 apps/backoffice/src/app/features/license-review/config/license-types.ts create mode 100644 apps/backoffice/src/app/features/license-review/export.ts create mode 100644 apps/backoffice/src/app/features/license-review/queue-views.ts create mode 100644 apps/backoffice/src/app/features/license-review/sla.ts create mode 100644 apps/backoffice/src/app/features/license-review/useQueueKeyboard.ts create mode 100644 apps/backoffice/src/app/layouts/CommandPalette.tsx create mode 100644 apps/backoffice/src/app/layouts/nav-config.ts delete mode 100644 apps/portal/src/app/components/ProfileGuard.tsx delete mode 100644 apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx create mode 100644 apps/portal/src/app/features/profile/components/ProfileCompletionNudge.tsx create mode 100644 apps/portal/src/app/features/profile/components/ProfileRequirementGate.tsx create mode 100644 libs/auth/src/lib/hooks/useCurrentProfile.ts create mode 100644 libs/auth/src/lib/hooks/usePermissions.ts create mode 100644 libs/ui/src/lib/feedback/EmptyState.tsx create mode 100644 libs/ui/src/lib/feedback/ErrorState.tsx create mode 100644 libs/ui/src/lib/layout/AppTopNav.tsx create mode 100644 libs/ui/src/lib/layout/nav-utils.ts diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx new file mode 100644 index 000000000..46b801648 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -0,0 +1,560 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + ActionIcon, + Badge, + Button, + Card, + Code, + Container, + Group, + Modal, + NumberInput, + Paper, + ScrollArea, + Select, + Stack, + Switch, + Text, + TextInput, + Textarea, + Title, + Tooltip, +} from '@mantine/core'; +import { + IconAlertCircle, + IconDeviceFloppy, + IconEye, + IconPlus, + IconRosetteDiscountCheck, + IconTrash, +} from '@tabler/icons-react'; +import { notifications } from '@mantine/notifications'; +import { useTranslation } from 'react-i18next'; +import { + extractErrorMessage, + useArchiveLicenseTemplateMutation, + useCreateLicenseTemplateMutation, + useDeleteLicenseTemplateMutation, + useGetBuiltInTemplateQuery, + useGetLicenseTemplatesQuery, + useGetLicenseTypesQuery, + useGetTemplateVariablesQuery, + usePublishLicenseTemplateMutation, + useUpdateLicenseValidityMutation, + useUpdateLicenseTemplateMutation, + type LicenseTemplate, +} from '@ema-platform/api'; +import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui'; +import { authStorage, usePermissions } from '@ema-platform/auth'; +import { PERMISSIONS } from '../../../layouts/nav-config'; + +/** Same resolution the shared RTK Query baseQuery uses. */ +const API_BASE_URL = + (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? + 'http://localhost:3000/api'; + +const STATUS_COLOR: Record = { + DRAFT: 'gray', + PUBLISHED: 'teal', + ARCHIVED: 'dark', +}; + +/** + * Where the authority designs the certificate its licensees receive. + * + * The layout used to be a Handlebars file inside the deployed image, so any + * change to the authority's own certificate needed a developer and a release. + * Here it is data: staff author a version, preview the real PDF, and publish. + * Publishing archives the incumbent, so exactly one design is live per licence + * type and previously issued certificates keep the design they were made from. + */ +export function CertificateDesignerPage() { + const { t } = useTranslation(); + const { can } = usePermissions(); + + const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]); + const canPublish = can([PERMISSIONS.PUBLISH_TEMPLATE]); + + const { data: licenseTypes } = useGetLicenseTypesQuery(); + const [typeId, setTypeId] = useState(null); + + const { + data: templates = [], + isLoading, + isError, + error, + refetch, + } = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId }); + const { data: variables = [] } = useGetTemplateVariablesQuery(); + const { data: builtIn } = useGetBuiltInTemplateQuery(); + + const [createTemplate, { isLoading: creating }] = useCreateLicenseTemplateMutation(); + const [updateTemplate, { isLoading: saving }] = useUpdateLicenseTemplateMutation(); + const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation(); + const [archiveTemplate] = useArchiveLicenseTemplateMutation(); + const [deleteTemplate] = useDeleteLicenseTemplateMutation(); + const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation(); + + const [selectedId, setSelectedId] = useState(null); + const [source, setSource] = useState(''); + const [name, setName] = useState(''); + const [landscape, setLandscape] = useState(true); + const [newOpen, setNewOpen] = useState(false); + const [newName, setNewName] = useState(''); + const editorRef = useRef(null); + + const selectedType = licenseTypes?.items?.find((type) => type.id === typeId); + const [validityMonths, setValidityMonths] = useState(12); + + // Default to the first licence type so the page is never an empty shell. + useEffect(() => { + if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id); + }, [licenseTypes, typeId]); + + useEffect(() => { + if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12); + }, [selectedType]); + + const selected = useMemo( + () => templates.find((tpl) => tpl.id === selectedId) ?? null, + [templates, selectedId], + ); + + // Pick the live design by default — that is the one staff usually want. + useEffect(() => { + if (!templates.length) { + setSelectedId(null); + return; + } + if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return; + const published = templates.find((tpl) => tpl.status === 'PUBLISHED'); + setSelectedId((published ?? templates[0]).id); + }, [templates, selectedId]); + + useEffect(() => { + if (!selected) return; + setSource(selected.hbsSource); + setName(selected.name); + setLandscape(selected.pageOptions?.landscape ?? true); + }, [selected]); + + const isPublished = selected?.status === 'PUBLISHED'; + const dirty = + Boolean(selected) && + (source !== selected?.hbsSource || + name !== selected?.name || + landscape !== (selected?.pageOptions?.landscape ?? true)); + + async function run(action: () => Promise, success: string) { + try { + await action(); + notifications.show({ color: 'teal', title: success, message: '' }); + } catch (err) { + notifications.show({ + color: 'red', + title: t('designer.actionFailed', 'Action failed'), + message: extractErrorMessage(err), + }); + } + } + + /** Inserts a placeholder where the caret is, rather than at the end. */ + function insertVariable(key: string) { + const el = editorRef.current; + const token = `{{${key}}}`; + if (!el) { + setSource((prev) => prev + token); + return; + } + const start = el.selectionStart ?? source.length; + const end = el.selectionEnd ?? start; + setSource(source.slice(0, start) + token + source.slice(end)); + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(start + token.length, start + token.length); + }); + } + + /** + * Renders the editor's current contents, not the saved row, so unsaved edits + * are what you see. Opened as a blob so it never leaves a file behind. + */ + async function preview() { + 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 + // the bearer token that the shared baseQuery would normally attach. + const token = authStorage.getToken(); + const response = await fetch(`${API_BASE_URL}/license-templates/preview`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + hbsSource: source, + licenseTypeId: typeId, + pageOptions: { format: 'A4', landscape, printBackground: true }, + }), + }); + if (!response.ok) throw new Error(await response.text()); + const url = URL.createObjectURL(await response.blob()); + window.open(url, '_blank', 'noopener'); + // Give the new tab time to read it before revoking. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } catch (err) { + notifications.show({ + color: 'red', + title: t('designer.previewFailed', 'Could not render the preview'), + message: extractErrorMessage(err), + }); + } + } + + return ( + + + + +