diff --git a/apps/backoffice/index.html b/apps/backoffice/index.html index 8d1d8072c..851a44f30 100644 --- a/apps/backoffice/index.html +++ b/apps/backoffice/index.html @@ -6,9 +6,122 @@ EMA Backoffice + +
+
+
+
+ + + + + + + + + + + + + + + EMA +
+
ETHIOPIAN MARITIME AUTHORITY
+
የኢትዮጵያ ማሪታይም ባለስልጣን
+
Loading Maritime Backoffice…
+
+
diff --git a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts index 761a89e15..c2714f856 100644 --- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts +++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { notifications } from '@mantine/notifications'; import { useTranslation } from 'react-i18next'; import { extractErrorMessage } from '@ema-platform/api'; @@ -13,12 +13,14 @@ interface PreviewArgs { /** * 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. + * are what you see. Opened as a blob into `PdfPreviewModal` rather than a new + * tab, so the designer never loses their place. */ export function useTemplatePreview() { const { t } = useTranslation(); + const [previewUrl, setPreviewUrl] = useState(null); - return useCallback( + const open = useCallback( async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => { try { // The preview returns a PDF stream, not JSON, so it bypasses RTK Query @@ -38,10 +40,7 @@ export function useTemplatePreview() { }), }); 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); + setPreviewUrl(URL.createObjectURL(await response.blob())); } catch (err) { notifications.show({ color: 'red', @@ -52,4 +51,13 @@ export function useTemplatePreview() { }, [t], ); + + const close = useCallback(() => { + setPreviewUrl((current) => { + if (current) URL.revokeObjectURL(current); + return null; + }); + }, []); + + return { previewUrl, open, close }; } diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx index 7f49af593..d30aa5d2f 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -31,7 +31,7 @@ import { useUpdateLicenseValidityMutation, useUpdateLicenseTemplateMutation, } from '@ema-platform/api'; -import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui'; +import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui'; import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth'; import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel'; import { DesignerToolbar } from '../components/DesignerToolbar'; @@ -86,7 +86,7 @@ export function CertificateDesignerPage() { const draft = useTemplateDraft(templates); const run = useDesignerActions(); - const openPreview = useTemplatePreview(); + const { previewUrl, open: openPreview, close: closePreview } = useTemplatePreview(); const [newOpen, setNewOpen] = useState(false); const [newName, setNewName] = useState(''); @@ -377,6 +377,13 @@ export function CertificateDesignerPage() { }, t('designer.created', 'Draft created')) } /> + + ); } diff --git a/apps/backoffice/src/app/features/configuration/api/configuration-api.ts b/apps/backoffice/src/app/features/configuration/api/configuration-api.ts index 93a728f1e..29f58f9c1 100644 --- a/apps/backoffice/src/app/features/configuration/api/configuration-api.ts +++ b/apps/backoffice/src/app/features/configuration/api/configuration-api.ts @@ -5,6 +5,8 @@ import type { ListResponse, CreateProfessionPayload, UpdateProfessionPayload, + NumberFormatConfig, + NumberFormatPayload, } from "../types/configuration"; const configurationApi = baseApi.injectEndpoints({ @@ -36,6 +38,51 @@ const configurationApi = baseApi.injectEndpoints({ query: (id) => ({ url: `/professions/${id}`, method: "DELETE" }), invalidatesTags: ["Api"], }), + + // Number formats — the shape of generated seafarer, seaman book and BTC + // identifiers. The counter behind each stays server-side; only the + // rendering is configurable here. + getNumberFormats: builder.query({ + query: () => "/number-format-configs", + providesTags: ["Api", "NumberFormatApi"], + }), + createNumberFormat: builder.mutation< + NumberFormatConfig, + NumberFormatPayload + >({ + query: (body) => ({ + url: "/number-format-configs", + method: "POST", + body, + }), + invalidatesTags: ["Api", "NumberFormatApi"], + }), + updateNumberFormat: builder.mutation< + NumberFormatConfig, + { id: string } & Partial + >({ + query: ({ id, ...body }) => ({ + url: `/number-format-configs/${id}`, + method: "PATCH", + body, + }), + invalidatesTags: ["Api", "NumberFormatApi"], + }), + /** + * Server-rendered sample. Asked of the server rather than formatted in the + * browser so the preview cannot drift from what approval will actually + * generate — the two would be the same rule written twice. + */ + previewNumberFormat: builder.mutation< + { sample: string }, + NumberFormatPayload + >({ + query: (body) => ({ + url: "/number-format-configs/preview", + method: "POST", + body, + }), + }), }), overrideExisting: true, }); @@ -46,4 +93,8 @@ export const { useCreateProfessionMutation, useUpdateProfessionMutation, useDeleteProfessionMutation, + useGetNumberFormatsQuery, + useCreateNumberFormatMutation, + useUpdateNumberFormatMutation, + usePreviewNumberFormatMutation, } = configurationApi; diff --git a/apps/backoffice/src/app/features/configuration/components/NumberFormatTab.tsx b/apps/backoffice/src/app/features/configuration/components/NumberFormatTab.tsx new file mode 100644 index 000000000..62ee9460e --- /dev/null +++ b/apps/backoffice/src/app/features/configuration/components/NumberFormatTab.tsx @@ -0,0 +1,288 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + Badge, + Button, + Card, + Center, + Code, + Group, + Loader, + Modal, + NumberInput, + Select, + Stack, + Switch, + Table, + Text, + TextInput, + Title, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useDisclosure } from '@mantine/hooks'; +import { IconInfoCircle, IconPlus } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui'; +import { + useCreateNumberFormatMutation, + useGetNumberFormatsQuery, + usePreviewNumberFormatMutation, + useUpdateNumberFormatMutation, +} from '../api/configuration-api'; +import type { + NumberFormatPayload, + NumberFormatScope, +} from '../types/configuration'; + +const SCOPES: { value: NumberFormatScope; label: string }[] = [ + { value: 'SEAFARER_NUMBER', label: 'Seafarer Number' }, + { value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' }, + { value: 'BTC_NUMBER', label: 'BTC Number' }, +]; + +const scopeLabel = (scope: NumberFormatScope) => + SCOPES.find((s) => s.value === scope)?.label ?? scope; + +/** + * Backoffice control over the shape of generated identifiers. + * + * Authoring a format supersedes the one it replaces rather than editing it: + * numbers already issued were produced by a specific format, and the register + * has to stay explicable. Superseded rows therefore stay listed. + */ +export function NumberFormatTab() { + const { t } = useTranslation(); + const { handleError } = useErrorHandler(); + const [opened, { open, close }] = useDisclosure(false); + const [sample, setSample] = useState(null); + + const { data: formats = [], isLoading } = useGetNumberFormatsQuery(); + const [createFormat, { isLoading: creating }] = useCreateNumberFormatMutation(); + const [updateFormat] = useUpdateNumberFormatMutation(); + const [previewFormat] = usePreviewNumberFormatMutation(); + + const form = useForm({ + initialValues: { + scope: 'SEAFARER_NUMBER', + prefix: 'SEA', + includeYear: true, + separator: '-', + sequenceLength: 6, + startingNumber: 1, + isActive: true, + }, + validate: { + prefix: (value) => (value.trim() ? null : t('numberFormat.prefixRequired', 'Prefix is required')), + sequenceLength: (value) => + value && value >= 1 && value <= 12 + ? null + : t('numberFormat.lengthRange', 'Length must be between 1 and 12'), + startingNumber: (value) => + value && value >= 1 + ? null + : t('numberFormat.startPositive', 'Starting number must be at least 1'), + }, + }); + + // The sample comes from the server so it cannot drift from what an approval + // will actually generate. Debounced because it follows every keystroke. + const { values } = form; + useEffect(() => { + if (!values.prefix?.trim()) { + setSample(null); + return; + } + const timer = setTimeout(() => { + previewFormat(values) + .unwrap() + .then((result) => setSample(result.sample)) + // A preview that fails is not worth interrupting authoring for; the + // create call reports properly if the format is genuinely invalid. + .catch(() => setSample(null)); + }, 300); + return () => clearTimeout(timer); + }, [values, previewFormat]); + + const submit = form.onSubmit(async (payload) => { + try { + await createFormat(payload).unwrap(); + notify.success( + t('numberFormat.created', 'Number format saved. It applies to numbers issued from now on.'), + ); + close(); + form.reset(); + } catch (error) { + handleError(error); + } + }); + + const retire = async (id: string) => { + try { + await updateFormat({ id, isActive: false }).unwrap(); + notify.success(t('numberFormat.retired', 'Format retired.')); + } catch (error) { + handleError(error); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + + +
+ {t('numberFormat.title', 'Number Formats')} + + {t( + 'numberFormat.subtitle', + 'The shape of generated seafarer, seaman book and certificate numbers.', + )} + +
+ +
+ + } color="blue" variant="light"> + {t( + 'numberFormat.notice', + 'Changing a format never alters numbers already issued. A new format applies only to numbers generated after it becomes active.', + )} + + + {formats.length === 0 ? ( + + + {t( + 'numberFormat.empty', + 'No formats configured. Numbers use the built-in default until one is added.', + )} + + + ) : ( + + + + + {t('numberFormat.scope', 'Identifier')} + {t('numberFormat.example', 'Example')} + {t('numberFormat.sequence', 'Sequence')} + {t('numberFormat.status', 'Status')} + + + + + {formats.map((format) => { + const parts = [format.prefix]; + if (format.includeYear) parts.push(String(new Date().getFullYear())); + parts.push(String(format.startingNumber).padStart(format.sequenceLength, '0')); + return ( + + {scopeLabel(format.scope)} + + {parts.join(format.separator)} + + {format.sequenceLength} digits + + + {format.isActive + ? t('numberFormat.active', 'Active') + : t('numberFormat.superseded', 'Superseded')} + + + + {format.isActive && ( + + )} + + + ); + })} + +
+
+ )} + + +
+ + ({ value: type.id, label: localized(type.name, i18n.language) || type.key, }))} value={urlFilter.licenseTypeId ?? null} - onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })} + onChange={(v) => changeType(v ?? undefined)} clearable w={220} /> @@ -562,7 +676,7 @@ export function LicenseQueuePage() { { @@ -639,17 +753,19 @@ export function LicenseQueuePage() { > {t("queue.export", "Export CSV")} - - - + {isLogistics !== false && ( + + + + )} diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index 87b62ca14..41999b1a6 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -1,11 +1,9 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; import { ActionIcon, Alert, Badge, - Card, - Checkbox, Container, Grid, Group, @@ -15,16 +13,14 @@ import { SegmentedControl, Skeleton, Stack, - Table, Tabs, Text, Textarea, - TextInput, ThemeIcon, Timeline, Title, Tooltip, -} from '@mantine/core'; +} from "@mantine/core"; import { IconAlertTriangle, IconCheck, @@ -32,23 +28,27 @@ import { IconLayoutSidebarRightExpand, IconQuestionMark, IconX, -} from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; -import { useTranslation } from 'react-i18next'; +} from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; import { STATUS_COLORS, STATUS_LABELS, + applicantOrCompanyName, extractErrorMessage, useLocalized, useApproveDocumentsMutation, useAssignApplicationMutation, useCompleteReviewMutation, useConfirmPaymentMutation, + useScheduleIssuanceMutation, + useIssueCertificateMutation, useScheduleExamMutation, useEscalateApplicationMutation, useFinalApproveMutation, useGetApplicationForReviewQuery, useGetAttachmentsQuery, + useGetDocumentReviewsQuery, useGetInspectionsQuery, useGetAssignableOfficersQuery, useGetLicenseTypeRequirementsQuery, @@ -59,29 +59,41 @@ import { useResumeApplicationMutation, useScheduleInspectionMutation, type RemarkTargetType, -} from '@ema-platform/api'; + type StaffEvidenceRequirement, +} from "@ema-platform/api"; import { AdvancedTable, AmharicDatePicker, ErrorState, ModalFooter, + PdfPreviewModal, useServerTable, -} from '@ema-platform/ui'; -import { useDateDisplayer } from '@ema-platform/shared'; -import { usePermissions } from '@ema-platform/auth'; -import { useAppSelector } from '../../../../store/hooks'; -import { DecisionBar } from '../../components/DecisionBar'; +} from "@ema-platform/ui"; +import { useDateDisplayer } from "@ema-platform/shared"; +import { usePermissions } from "@ema-platform/auth"; +import { useAppSelector } from "../../../../store/hooks"; +import { DecisionBar } from "../../components/DecisionBar"; import { DecisionConfirmModal, type DecisionSubmission, -} from '../../components/DecisionConfirmModal'; -import { ActivityRail } from '../../components/ActivityRail'; -import { DocumentsTab } from '../../components/DocumentsTab'; -import { ScheduleExamModal } from '../../components/ScheduleExamModal'; -import { computeSla } from '../../sla'; -import { reviewStaffColumns } from './columns'; -import { evaluateEligibility, presentationFor } from '../../config/license-types'; -import { resolveActions, type ActionId, type ResolvedAction } from '../../config/actions'; +} from "../../components/DecisionConfirmModal"; +import { ActivityRail } from "../../components/ActivityRail"; +import { DocumentsTab } from "../../components/DocumentsTab"; +import { FormDetailsTab } from "../../components/FormDetailsTab"; +import { ApplicantCard } from "../../components/ApplicantCard"; +import { useGetLocationsQuery } from "../../../location/api/location-api"; +import { ScheduleExamModal } from "../../components/ScheduleExamModal"; +import { computeSla } from "../../sla"; +import { reviewStaffColumns } from "./columns"; +import { + evaluateEligibility, + presentationFor, +} from "../../config/license-types"; +import { + resolveActions, + type ActionId, + type ResolvedAction, +} from "../../config/actions"; type FlagMap = Record; @@ -91,14 +103,30 @@ type FlagMap = Record; * that fills it in. */ const INSPECTION_CHECKLIST_ITEMS = [ - { key: 'office_premises', labelKey: 'review.checklist.officePremises', fallback: 'Office premises' }, - { key: 'storage_facilities', labelKey: 'review.checklist.storageFacilities', fallback: 'Warehouse / storage facilities' }, - { key: 'vehicles_equipment', labelKey: 'review.checklist.vehiclesEquipment', fallback: 'Vehicles / equipment' }, - { key: 'safety_compliance', labelKey: 'review.checklist.safetyCompliance', fallback: 'Safety & regulatory compliance' }, + { + key: "office_premises", + labelKey: "review.checklist.officePremises", + fallback: "Office premises", + }, + { + key: "storage_facilities", + labelKey: "review.checklist.storageFacilities", + fallback: "Warehouse / storage facilities", + }, + { + key: "vehicles_equipment", + labelKey: "review.checklist.vehiclesEquipment", + fallback: "Vehicles / equipment", + }, + { + key: "safety_compliance", + labelKey: "review.checklist.safetyCompliance", + fallback: "Safety & regulatory compliance", + }, ] as const; function buildChecklist( - outcomes: Record, + outcomes: Record, ) { return INSPECTION_CHECKLIST_ITEMS.map((item) => ({ key: item.key, @@ -107,7 +135,7 @@ function buildChecklist( label: item.fallback, // Untouched rows default to PASS — the segmented control shows exactly // that, so what the officer saw is what gets recorded. - outcome: outcomes[item.key] ?? 'PASS', + outcome: outcomes[item.key] ?? "PASS", })); } @@ -121,28 +149,51 @@ function buildChecklist( * position instead of scrolling back to a column of buttons. */ export function LicenseReviewPage() { + const navigate = useNavigate(); const { t, i18n } = useTranslation(); const showDate = useDateDisplayer(); const localized = useLocalized(); - const { id = '' } = useParams(); + const { id = "" } = useParams(); const { can } = usePermissions(); - const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? ''; + const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? ""; - const { data, isLoading, isError, error, refetch } = useGetApplicationForReviewQuery(id, { - skip: !id, - }); - const { data: inspections = [], refetch: refetchInspections } = useGetInspectionsQuery(id, { - skip: !id, - }); + const { data, isLoading, isError, error, refetch } = + useGetApplicationForReviewQuery(id, { + skip: !id, + }); + const { data: inspections = [], refetch: refetchInspections } = + useGetInspectionsQuery(id, { + skip: !id, + }); const { data: requirements } = useGetLicenseTypeRequirementsQuery( - { idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' }, + { + idOrKey: data?.application.licenseTypeId ?? "", + kind: data?.application.kind ?? "NEW", + }, { skip: !data?.application.licenseTypeId }, ); // Staff role names are already bilingual on the config — the wire data only // carries the role key (e.g. 'CAPTAIN'), so this is what turns it back into // the label an officer reads. const roleNameByKey = useMemo( - () => new Map(requirements?.staffRoleRequirements.map((r) => [r.roleKey, r.name]) ?? []), + () => + new Map( + requirements?.staffRoleRequirements.map((r) => [r.roleKey, r.name]) ?? + [], + ), + [requirements], + ); + // What each role is *required* to produce (CV, work agreement, ERB + // certificate). Without this the tab can only list what was uploaded, so a + // missing CV looks identical to a role that never needed one. + const evidenceByRole = useMemo( + () => + new Map( + requirements?.staffRoleRequirements.map((r) => [ + r.roleKey, + r.requiredEvidence ?? [], + ]) ?? [], + ), [requirements], ); @@ -154,7 +205,10 @@ export function LicenseReviewPage() { const [scheduleInspection] = useScheduleInspectionMutation(); const [recordResult] = useRecordInspectionResultMutation(); const [confirmPayment] = useConfirmPaymentMutation(); - const [scheduleExam, { isLoading: schedulingExam }] = useScheduleExamMutation(); + const [scheduleIssuance] = useScheduleIssuanceMutation(); + const [issueCertificate] = useIssueCertificateMutation(); + const [scheduleExam, { isLoading: schedulingExam }] = + useScheduleExamMutation(); const [holdApplication] = useHoldApplicationMutation(); const [resumeApplication] = useResumeApplicationMutation(); const [escalateApplication] = useEscalateApplicationMutation(); @@ -162,20 +216,27 @@ export function LicenseReviewPage() { // Real officer list, so Assign and Escalate name a person instead of // silently reassigning to whoever already held the application. const { data: officers = [] } = useGetAssignableOfficersQuery(); + // Same cached query the Documents tab reads, so the decision bar reacts the + // moment a verdict is saved. + const { data: documentReviews = [] } = useGetDocumentReviewsQuery(id, { skip: !id }); const staffTable = useServerTable(); const [flags, setFlags] = useState({}); const [capital, setCapital] = useState(); - const [pendingAction, setPendingAction] = useState(null); + const [pendingAction, setPendingAction] = useState( + null, + ); const [busyAction, setBusyAction] = useState(null); const [railOpen, setRailOpen] = useState(true); const [inspectionOpen, setInspectionOpen] = useState(false); - const [inspectionDate, setInspectionDate] = useState(''); + const [inspectionDate, setInspectionDate] = useState(""); + const [issuanceOpen, setIssuanceOpen] = useState(false); + const [issuanceDate, setIssuanceDate] = useState(""); const [resultOpen, setResultOpen] = useState(false); const [scheduleExamOpen, setScheduleExamOpen] = useState(false); - const [findings, setFindings] = useState(''); + const [findings, setFindings] = useState(""); const [checklist, setChecklist] = useState< - Record + Record >({}); // Prefill from whatever is recorded, else the declared figure, so the officer @@ -185,29 +246,50 @@ export function LicenseReviewPage() { const loadedApp = data?.application; useEffect(() => { if (seeded.current || !loadedApp) return; - const existing = loadedApp.capitalAmountVerified ?? loadedApp.capitalAmountDeclared; + const existing = + loadedApp.capitalAmountVerified ?? loadedApp.capitalAmountDeclared; if (existing != null) setCapital(Number(existing)); seeded.current = true; }, [loadedApp]); - const toggleFlag = useCallback((targetType: RemarkTargetType, key: string) => { - setFlags((prev) => { - const next = { ...prev }; - if (next[key]) delete next[key]; - else next[key] = { targetType, remark: '' }; - return next; - }); - }, []); + const toggleFlag = useCallback( + (targetType: RemarkTargetType, key: string) => { + setFlags((prev) => { + const next = { ...prev }; + if (next[key]) delete next[key]; + else next[key] = { targetType, remark: "" }; + return next; + }); + }, + [], + ); const documentFlags = useMemo(() => { const map: Record = {}; for (const [key, flag] of Object.entries(flags)) { - if (flag.targetType === 'DOCUMENT') map[key] = flag.remark; + if (flag.targetType === "DOCUMENT") map[key] = flag.remark; } return map; }, [flags]); const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED'); + + // 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 + // just that the button is dead. + const documentProgress = useMemo(() => { + const attachments = data?.attachments ?? []; + const accepted = new Set( + documentReviews + .filter((review) => review.decision === 'ACCEPTED') + .map((review) => review.documentKey), + ); + const acceptedCount = attachments.filter((a) => accepted.has(a.documentKey)).length; + return { acceptedCount, total: attachments.length }; + }, [data?.attachments, documentReviews]); + const allDocumentsAccepted = + documentProgress.total > 0 && documentProgress.acceptedCount === documentProgress.total; const flagged = Object.entries(flags); /** @@ -221,17 +303,17 @@ export function LicenseReviewPage() { const flaggedItems = useMemo( () => flagged.map(([key, flag]) => { - if (flag.targetType === 'STAFF') { + if (flag.targetType === "STAFF") { const member = data?.staff.find((s) => s.id === key); return { key, label: member ? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey} — ${member.fullName}` - : t('review.staffMember', 'Staff member'), + : t("review.staffMember", "Staff member"), }; } - if (flag.targetType === 'FORM_SECTION') { - return { key, label: key.replace(/([A-Z])/g, ' $1').trim() }; + if (flag.targetType === "FORM_SECTION") { + return { key, label: key.replace(/([A-Z])/g, " $1").trim() }; } return { key, label: key }; }), @@ -247,6 +329,7 @@ export function LicenseReviewPage() { can, flaggedCount: flagged.length, hasPendingInspection: Boolean(pendingInspection), + allDocumentsAccepted, reasons: { wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'), notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'), @@ -254,10 +337,61 @@ export function LicenseReviewPage() { needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'), needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'), needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'), + needsDocumentReviews: + documentProgress.total === 0 + ? t( + 'review.disabled.needsDocumentsUploaded', + 'No documents uploaded to review yet', + ) + : t('review.disabled.needsDocumentReviews', { + accepted: documentProgress.acceptedCount, + total: documentProgress.total, + defaultValue: + 'Accept all documents first — {{accepted}} of {{total}} accepted. Open the Documents tab and accept the rest.', + }), }, }); }, [data, currentUserId, can, flagged.length, pendingInspection, t]); + // Location answers are tree ids. The picker the applicant used resolves them + // client-side from the same list, so the reviewer reads the place rather than + // the uuid. Fetched only when the form actually has a location field. + // + // Above the early returns because it is a hook: React requires the same hook + // order on every render, and the loading and error branches return before the + // application is known. + const needsLocations = ( + requirements?.licenseType.formSchema.sections ?? [] + ).some((section) => + (section.fields ?? []).some( + (f) => + f.key === "locationId" || + (f.label?.en ?? "").trim().toLowerCase() === "location", + ), + ); + const { data: locationsRes } = useGetLocationsQuery( + { take: 10000 }, + { skip: !needsLocations }, + ); + const resolveLocation = useMemo(() => { + const all = locationsRes?.items ?? []; + if (all.length === 0) return undefined; + const byId = new Map(all.map((loc) => [loc.id, loc])); + return (locationId: string) => { + // Walks to the root so the answer reads as a place, not a leaf name: + // "Woreda 03" alone does not say which sub-city it belongs to. Bounded by + // the map size so a cyclic tree cannot spin the render. + const path: string[] = []; + let current = byId.get(locationId); + let hops = 0; + while (current && hops++ <= byId.size) { + path.unshift(localized(current.names) || current.code); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + return path.length ? path.join(" → ") : undefined; + }; + }, [locationsRes, localized]); + if (isLoading) { // Skeleton mirrors the real three-zone layout so nothing jumps on load. return ( @@ -282,7 +416,7 @@ export function LicenseReviewPage() { return ( refetch()} /> @@ -295,30 +429,45 @@ export function LicenseReviewPage() { const staffPaged = staffTable.paginate(data.staff); const presentation = presentationFor(app.licenseType?.key); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature - const sla = computeSla(app, undefined, i18n.language, (key, options) => t(key, options as any) as string); + const sla = computeSla( + app, + undefined, + i18n.language, + (key, options) => t(key, options as any) as string, + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature - const eligibility = evaluateEligibility(app, app.licenseType, i18n.language, (key, options) => t(key, options as any) as string); + const eligibility = evaluateEligibility( + app, + app.licenseType, + i18n.language, + (key, options) => t(key, options as any) as string, + ); const rawThreshold = app.licenseType?.capitalThreshold; const threshold = - rawThreshold === null || rawThreshold === undefined ? undefined : Number(rawThreshold); + rawThreshold === null || rawThreshold === undefined + ? undefined + : Number(rawThreshold); /** Stages where the officer can still record the verified capital. */ const needsCapital = Boolean(threshold) && - ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_PENDING', 'INSPECTION_COMPLETED'].includes( - status, - ); + [ + "UNDER_REVIEW", + "UNDER_EVALUATION", + "INSPECTION_PENDING", + "INSPECTION_COMPLETED", + ].includes(status); async function run(action: () => Promise, success: string) { try { await action(); - notifications.show({ color: 'teal', title: success, message: '' }); + notifications.show({ color: "teal", title: success, message: "" }); refetch(); refetchInspections(); } catch (err) { notifications.show({ - color: 'red', - title: t('review.actionFailed', 'Action failed'), + color: "red", + title: t("review.actionFailed", "Action failed"), message: extractErrorMessage(err), }); } @@ -327,35 +476,38 @@ export function LicenseReviewPage() { /** Actions with their own dedicated form open that; the rest confirm. */ function handleAction(action: ResolvedAction) { switch (action.id) { - case 'schedule-inspection': + case "schedule-inspection": setInspectionOpen(true); return; - case 'record-inspection': + case "schedule-issuance": + setIssuanceOpen(true); + return; + case "record-inspection": setResultOpen(true); return; // Needs a session picked before anything is sent, so it opens its own // modal rather than going through the generic confirm step. - case 'schedule-exam': + case "schedule-exam": setScheduleExamOpen(true); return; - case 'copy-link': + case "copy-link": navigator.clipboard.writeText(window.location.href); notifications.show({ - color: 'teal', - title: t('review.linkCopied', 'Link copied'), - message: '', + color: "teal", + title: t("review.linkCopied", "Link copied"), + message: "", }); return; - case 'print': + case "print": window.print(); return; - case 'audit-trail': + case "audit-trail": setRailOpen(true); return; - case 'download-documents': + case "download-documents": for (const attachment of data?.attachments ?? []) { const url = attachment.files?.[0]?.url; - if (url) window.open(url, '_blank', 'noopener'); + if (url) window.open(url, "_blank", "noopener"); } return; default: @@ -369,29 +521,30 @@ export function LicenseReviewPage() { setBusyAction(action.id); try { switch (action.id) { - case 'claim': + case "claim": // Claim is fired from the queue in practice; kept here for the case // where an officer opens an unclaimed application directly. break; - case 'complete-review': + case "complete-review": await run( - () => completeReview({ id, capitalAmountVerified: capital }).unwrap(), - t('review.done.completeReview', 'Review completed'), + () => + completeReview({ id, capitalAmountVerified: capital }).unwrap(), + t("review.done.completeReview", "Review completed"), ); break; - case 'approve-documents': + case "approve-documents": await run( () => approveDocuments({ id }).unwrap(), - t('review.done.approveDocuments', 'Documents approved'), + t("review.done.approveDocuments", "Documents approved"), ); break; - case 'final-approve': + case "final-approve": await run( () => finalApprove({ id, capitalAmountVerified: capital }).unwrap(), - t('review.done.finalApprove', 'Approved'), + t("review.done.finalApprove", "Approved"), ); break; - case 'request-adjustment': { + case "request-adjustment": { const items = flagged // Only the ticked deficiencies are sent, so the applicant can // edit exactly the list they were shown. @@ -414,44 +567,47 @@ export function LicenseReviewPage() { const unexplained = items.filter((item) => !item.remark); if (items.length === 0 || unexplained.length > 0) { notifications.show({ - color: 'orange', + color: "orange", title: items.length === 0 ? t( - 'review.adjustment.nothingFlagged', - 'Nothing has been flagged', + "review.adjustment.nothingFlagged", + "Nothing has been flagged", ) : t( - 'review.adjustment.reasonsMissing', - 'Every flagged item needs a reason', + "review.adjustment.reasonsMissing", + "Every flagged item needs a reason", ), message: items.length === 0 ? t( - 'review.adjustment.nothingFlaggedHint', - 'Tick what the applicant must correct before requesting an adjustment.', + "review.adjustment.nothingFlaggedHint", + "Tick what the applicant must correct before requesting an adjustment.", ) : `${t( - 'review.adjustment.reasonsMissingHint', - 'Say what must be corrected for:', - )} ${unexplained.map((item) => item.targetKey).join(', ')}`, + "review.adjustment.reasonsMissingHint", + "Say what must be corrected for:", + )} ${unexplained.map((item) => item.targetKey).join(", ")}`, }); // `finally` closes the modal and clears the busy flag. return; } - await run(async () => { - await requestAdjustment({ - id, - generalRemark: submission.reason, - notificationBody: submission.notificationBody, - items, - }).unwrap(); - setFlags({}); - }, t('review.done.requestAdjustment', 'Adjustment requested')); + await run( + async () => { + await requestAdjustment({ + id, + generalRemark: submission.reason, + notificationBody: submission.notificationBody, + items, + }).unwrap(); + setFlags({}); + }, + t("review.done.requestAdjustment", "Adjustment requested"), + ); break; } - case 'reject': + case "reject": await run( () => rejectApplication({ @@ -460,28 +616,34 @@ export function LicenseReviewPage() { // The preview the officer edited is what actually gets sent. notificationBody: submission.notificationBody, }).unwrap(), - t('review.done.reject', 'Application rejected'), + t("review.done.reject", "Application rejected"), ); break; - case 'confirm-payment': + case "confirm-payment": await run( () => confirmPayment(id).unwrap(), - t('review.done.confirmPayment', 'Payment confirmed'), + t("review.done.confirmPayment", "Payment confirmed"), ); break; - case 'hold': + case "issue-certificate": + await run( + () => issueCertificate(id).unwrap(), + t("review.done.issueCertificate", "Certificate issued"), + ); + break; + case "hold": await run( () => holdApplication({ id, reason: submission.reason }).unwrap(), - t('review.done.hold', 'Application placed on hold'), + t("review.done.hold", "Application placed on hold"), ); break; - case 'resume': + case "resume": await run( () => resumeApplication({ id, remark: submission.reason }).unwrap(), - t('review.done.resume', 'Application resumed'), + t("review.done.resume", "Application resumed"), ); break; - case 'escalate': + case "escalate": if (!submission.officerId) return; await run( () => @@ -490,10 +652,10 @@ export function LicenseReviewPage() { supervisorId: submission.officerId as string, reason: submission.reason, }).unwrap(), - t('review.done.escalate', 'Escalated'), + t("review.done.escalate", "Escalated"), ); break; - case 'assign': + case "assign": if (!submission.officerId) return; await run( () => @@ -502,7 +664,7 @@ export function LicenseReviewPage() { officerId: submission.officerId as string, remark: submission.reason, }).unwrap(), - t('review.done.assign', 'Reassigned'), + t("review.done.assign", "Reassigned"), ); break; default: @@ -515,19 +677,45 @@ export function LicenseReviewPage() { } const sections = presentation.detailSections; - const formSections = Object.entries(app.formData ?? {}); + const hasFormAnswers = Object.keys(app.formData ?? {}).length > 0; // The bilingual section/field labels the applicant's wizard renders — this // page already fetches them (`requirements` above) but used to fall back to // the raw formData keys, so an officer saw `vesselId` instead of a label in // either language. const configSections = requirements?.licenseType.formSchema.sections ?? []; - const sectionsByKey = new Map(configSections.map((s) => [s.key, s])); + + // A person-centric service has no company, so the company-shaped facts are + // not merely empty — they are the wrong question. TIN is hidden rather than + // shown blank, and the applicant's own identity card takes its place. + const isPersonal = !app.companyName; + const applicant = data.applicant; + const applicantFullName = [ + applicant?.firstName, + applicant?.middleName, + applicant?.lastName, + ] + .filter(Boolean) + .join(" "); + // The profile is the reliable name for a personal service — `formData.account` + // holds one only for applications filed after that field was added, and the + // application number identifies the paperwork rather than the person. + const headerName = + app.companyName || + applicantFullName || + applicantOrCompanyName(app) || + app.applicationNumber; + const linkedBookServices = (data.relatedApplications ?? []).filter( + (related) => + ["SEAMAN_BOOK", "BTC_BASIC_TRAINING"].includes( + related.licenseType?.key ?? "", + ), + ); return (
- {app.companyName ?? app.applicationNumber} + {headerName} {app.applicationNumber} @@ -537,20 +725,46 @@ export function LicenseReviewPage() { {app.adjustmentRound > 0 && ( - {t('review.round', { count: app.adjustmentRound, defaultValue: 'round {{count}}' })} + {t("review.round", { + count: app.adjustmentRound, + defaultValue: "round {{count}}", + })} )} + {linkedBookServices.length > 1 && ( + + {linkedBookServices.map((related) => ( + navigate(`/licence-review/${related.id}`)} + > + {related.licenseType?.key === "SEAMAN_BOOK" + ? "Seaman Book" + : "BTC"}{" "} + · {related.applicationNumber} ·{" "} + {STATUS_LABELS[related.status]} + + ))} + + )}
- setRailOpen((o) => !o)}> + setRailOpen((o) => !o)} + > {railOpen ? ( ) : ( @@ -564,20 +778,40 @@ export function LicenseReviewPage() { {/* Zone 1 — sticky summary rail. */} - + + {/* Who, before what: a person-centric review is about the applicant, + and the licence facts below are the context. */} + {isPersonal && applicant && } + - {t('review.summary', 'Summary')} + {t("review.summary", "Summary")} - - - + {/* A person has no TIN; showing the row blank invited the + reviewer to wonder what was missing. */} + {!isPersonal && ( + + )} + + - + @@ -585,26 +819,31 @@ export function LicenseReviewPage() { {eligibility.length > 0 && ( - {t('review.eligibility', 'Eligibility')} + {t("review.eligibility", "Eligibility")} {eligibility.map((rule) => ( - + - {rule.status === 'pass' ? ( + {rule.status === "pass" ? ( - ) : rule.status === 'fail' ? ( + ) : rule.status === "fail" ? ( ) : ( @@ -626,15 +865,22 @@ export function LicenseReviewPage() { - {t('review.statusTimeline', 'Progress')} + {t("review.statusTimeline", "Progress")} - + {data.history.slice(-5).map((entry) => ( - {t(`queue.statusValues.${entry.toStatus}`, STATUS_LABELS[entry.toStatus] ?? entry.toStatus)} + {t( + `queue.statusValues.${entry.toStatus}`, + STATUS_LABELS[entry.toStatus] ?? entry.toStatus, + )} } > @@ -653,105 +899,52 @@ export function LicenseReviewPage() { {/* Tabs with nothing behind them are not rendered at all. */} - {sections.includes('overview') && formSections.length > 0 && ( - {t('review.tabs.overview', 'Overview')} + {sections.includes("overview") && hasFormAnswers && ( + + {t("review.tabs.overview", "Overview")} + )} - {sections.includes('financials') && ( - {t('review.tabs.financials', 'Financials')} + {sections.includes("financials") && ( + + {t("review.tabs.financials", "Financials")} + )} - {sections.includes('documents') && ( + {sections.includes("documents") && ( - {t('review.tabs.documents', 'Documents')} ({data.attachments.length}) + {t("review.tabs.documents", "Documents")} ( + {data.attachments.length}) )} - {sections.includes('staff') && data.staff.length > 0 && ( + {sections.includes("staff") && data.staff.length > 0 && ( - {t('review.tabs.staff', 'Staff')} ({data.staff.length}) + {t("review.tabs.staff", "Staff")} ({data.staff.length}) )} - {sections.includes('inspection') && app.licenseType?.inspectionRequired && ( - {t('review.tabs.inspection', 'Inspection')} - )} + {sections.includes("inspection") && + app.licenseType?.inspectionRequired && ( + + {t("review.tabs.inspection", "Inspection")} + + )} - - {formSections.map(([sectionKey, values]) => { - const sectionConfig = sectionsByKey.get(sectionKey); - const fieldsByKey = new Map( - (sectionConfig?.fields ?? []).map((f) => [f.key, f]), - ); - return ( - - - - {sectionConfig - ? localized(sectionConfig.title) - : sectionKey.replace(/([A-Z])/g, ' $1')} - - toggleFlag('FORM_SECTION', sectionKey)} - /> - - - - {Object.entries(values ?? {}).map(([k, v]) => { - const fieldConfig = fieldsByKey.get(k); - return ( - - - - {fieldConfig ? localized(fieldConfig.label) : k} - - - - {v === null ? '—' : String(v)} - - - ); - })} - -
- {flags[sectionKey] && ( - { - // Read here, not inside the updater: React nulls - // `currentTarget` when the handler returns, and the - // updater runs afterwards during the re-render — - // which crashed the page on the first keystroke. - const remark = e.currentTarget.value; - setFlags((p) => ({ - ...p, - [sectionKey]: { ...p[sectionKey], remark }, - })); - }} - /> - )} -
- ); - })} -
+ + toggleFlag("FORM_SECTION", sectionKey) + } + onFlagRemark={(sectionKey, remark) => + setFlags((p) => ({ + ...p, + [sectionKey]: { ...p[sectionKey], remark }, + })) + } + resolveLocation={resolveLocation} + />
@@ -759,38 +952,51 @@ export function LicenseReviewPage() { {needsCapital ? ( <> setCapital(Number(v) || undefined)} thousandSeparator="," error={ - capital !== undefined && threshold && capital < threshold - ? t('review.belowMinimum', { + capital !== undefined && + threshold && + capital < threshold + ? t("review.belowMinimum", { min: threshold.toLocaleString(i18n.language), - defaultValue: 'Below the {{min}} minimum', + defaultValue: "Below the {{min}} minimum", }) : undefined } /> - {t('review.declared', 'Applicant declared')}{' '} + {t("review.declared", "Applicant declared")}{" "} {app.capitalAmountDeclared - ? Number(app.capitalAmountDeclared).toLocaleString(i18n.language) - : '—'} + ? Number(app.capitalAmountDeclared).toLocaleString( + i18n.language, + ) + : "—"} ) : ( - {t('review.capitalLocked', 'Capital can no longer be edited at this stage.')} + {t( + "review.capitalLocked", + "Capital can no longer be edited at this stage.", + )} )}
@@ -801,8 +1007,9 @@ export function LicenseReviewPage() { applicationId={id} attachments={data.attachments} requirements={requirements?.documentRequirements ?? []} + formData={app.formData ?? {}} flags={documentFlags} - onToggleFlag={(key) => toggleFlag('DOCUMENT', key)} + onToggleFlag={(key) => toggleFlag("DOCUMENT", key)} onFlagRemark={(key, remark) => setFlags((p) => ({ ...p, [key]: { ...p[key], remark } })) } @@ -811,15 +1018,20 @@ export function LicenseReviewPage() { - localized(roleNameByKey.get(member.roleKey)) || member.roleKey, + localized(roleNameByKey.get(member.roleKey)) || + member.roleKey, renderEvidence: (member) => ( - + ), - onToggleFlag: (member) => toggleFlag('STAFF', member.id), + onToggleFlag: (member) => toggleFlag("STAFF", member.id), onRemarkChange: (member, remark) => setFlags((p) => ({ ...p, @@ -839,7 +1051,10 @@ export function LicenseReviewPage() { {inspections.length === 0 ? ( - {t('review.noInspections', 'No inspection has been scheduled yet.')} + {t( + "review.noInspections", + "No inspection has been scheduled yet.", + )} ) : ( @@ -849,7 +1064,7 @@ export function LicenseReviewPage() { {inspection.scheduledDate ? showDate(inspection.scheduledDate) - : t('review.unscheduled', 'Not scheduled')} + : t("review.unscheduled", "Not scheduled")} {inspection.findings && ( @@ -859,13 +1074,18 @@ export function LicenseReviewPage() { - {inspection.result === 'PASSED' - ? t('review.passed', 'Passed') - : inspection.result === 'FAILED' - ? t('review.failed', 'Failed') - : t(`review.inspectionStatus.${inspection.status}`, inspection.status)} + {inspection.result === "PASSED" + ? t("review.passed", "Passed") + : inspection.result === "FAILED" + ? t("review.failed", "Failed") + : t( + `review.inspectionStatus.${inspection.status}`, + inspection.status, + )}
))} @@ -875,12 +1095,17 @@ export function LicenseReviewPage() { - {status === 'PAYMENT_PENDING' && ( - }> - {t('review.awaitingPayment', { + {status === "PAYMENT_PENDING" && ( + } + > + {t("review.awaitingPayment", { amount: app.feeAmount, currency: app.feeCurrency, - defaultValue: 'Waiting for the applicant to pay {{amount}} {{currency}}.', + defaultValue: + "Waiting for the applicant to pay {{amount}} {{currency}}.", })} )} @@ -896,7 +1121,9 @@ export function LicenseReviewPage() { setScheduleExamOpen(false)} onConfirm={async (payload) => { try { await scheduleExam({ id, ...payload }).unwrap(); notifications.show({ - color: 'teal', - title: t('review.done.scheduleExam', 'Exam scheduled'), - message: '', + color: "teal", + title: t("review.done.scheduleExam", "Exam scheduled"), + message: "", }); setScheduleExamOpen(false); } catch (err) { notifications.show({ - color: 'red', - title: t('review.actionFailed', 'Action failed'), + color: "red", + title: t("review.actionFailed", "Action failed"), message: extractErrorMessage(err), }); } @@ -941,41 +1176,84 @@ export function LicenseReviewPage() { setInspectionOpen(false)} - title={t('review.actions.scheduleInspection', 'Schedule inspection')} + title={t("review.actions.scheduleInspection", "Schedule inspection")} > -