Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-19 10:23:33 +03:00
74 changed files with 7969 additions and 3617 deletions

View File

@@ -0,0 +1,108 @@
import { Avatar, Badge, Group, Paper, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { ApplicationApplicant } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
interface ApplicantCardProps {
applicant: ApplicationApplicant;
}
/**
* Who the reviewer is deciding about.
*
* A company licence names itself in the page title (`companyName`); a seafarer
* registration has no company, so the officer's screen led with an application
* number and the human behind it was somewhere in the form answers. This puts
* the identity where it belongs on a person-centric review: name, national ID,
* contact, and — for a seafarer who already holds one — their number and
* standing, which is what says whether this is a first registration or a
* duplicate.
*
* Read-only and sourced from the profile, not the form: this is the record the
* registration will be written onto, so a reviewer comparing the two is exactly
* the intended use.
*/
export function ApplicantCard({ applicant }: ApplicantCardProps) {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const fullName = [applicant.firstName, applicant.middleName, applicant.lastName]
.filter(Boolean)
.join(' ');
const initials = [applicant.firstName, applicant.lastName]
.filter(Boolean)
.map((part) => part?.[0]?.toUpperCase() ?? '')
.join('');
return (
<Paper withBorder p="md">
<Group gap="sm" wrap="nowrap" align="flex-start" mb="sm">
<Avatar radius="xl" color="blue" variant="light">
{initials || '—'}
</Avatar>
<div style={{ minWidth: 0 }}>
<Text fw={600} size="sm" style={{ wordBreak: 'break-word' }}>
{fullName || t('review.nameMissing', 'Name not on profile')}
</Text>
{applicant.seafarerNumber ? (
<Group gap={4} mt={2}>
<Text size="xs" c="dimmed">
{applicant.seafarerNumber}
</Text>
{applicant.seafarerStatus && (
<Badge
size="xs"
variant="light"
color={applicant.seafarerStatus === 'ACTIVE' ? 'teal' : 'orange'}
>
{applicant.seafarerStatus}
</Badge>
)}
</Group>
) : (
<Text size="xs" c="dimmed" mt={2}>
{t('review.notYetRegistered', 'Not yet registered')}
</Text>
)}
</div>
</Group>
<Stack gap={6}>
<Row label={t('review.applicantGender', 'Gender')} value={applicant.gender} />
<Row
label={t('review.applicantDob', 'Date of birth')}
value={applicant.dob ? showDate(applicant.dob) : null}
/>
<Row
label={t('review.applicantNationality', 'Nationality')}
value={applicant.nationality}
/>
<Row
// The id type is the label, so a Fayda number is not read as a passport.
label={applicant.idType ?? t('review.applicantId', 'National ID')}
value={applicant.idNumber}
/>
<Row
label={t('review.applicantPhone', 'Phone')}
value={applicant.primaryPhoneNumber}
/>
<Row label={t('review.applicantEmail', 'Email')} value={applicant.email} />
</Stack>
</Paper>
);
}
/** One label/value line, omitted entirely when there is nothing to show. */
function Row({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<Group justify="space-between" gap="xs" wrap="nowrap" align="flex-start">
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="xs" ta="right" style={{ wordBreak: 'break-word' }}>
{value}
</Text>
</Group>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from "react";
import {
ActionIcon,
Alert,
@@ -13,7 +13,7 @@ import {
Text,
TextInput,
Tooltip,
} from '@mantine/core';
} from "@mantine/core";
import {
IconAlertCircle,
IconCheck,
@@ -22,23 +22,26 @@ import {
IconFileText,
IconRotate,
IconX,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
conditionHolds,
useClearDocumentReviewMutation,
useGetDocumentReviewsQuery,
useLocalized,
useReviewDocumentMutation,
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
import { notifications } from '@mantine/notifications';
} from "@ema-platform/api";
import { notifications } from "@mantine/notifications";
interface DocumentsTabProps {
applicationId: string;
attachments: Attachment[];
/** From the licence type config, so completeness is measured against rules. */
requirements: DocumentRequirement[];
/** Applicant answers used to evaluate conditional document requirements. */
formData: Record<string, Record<string, unknown>>;
/** documentKey -> remark. Owned by the review page. */
flags: Record<string, string>;
onToggleFlag: (documentKey: string) => void;
@@ -58,6 +61,7 @@ export function DocumentsTab({
applicationId,
attachments,
requirements,
formData,
flags,
onToggleFlag,
onFlagRemark,
@@ -80,16 +84,16 @@ export function DocumentsTab({
async function decide(
documentKey: string,
decision: 'ACCEPTED' | 'REJECTED',
decision: "ACCEPTED" | "REJECTED",
attachmentId?: string,
) {
const reason = rejecting[documentKey]?.trim();
if (decision === 'REJECTED' && !reason) {
if (decision === "REJECTED" && !reason) {
// The applicant is shown this verbatim, so refuse to send an empty one.
notifications.show({
color: 'red',
title: t('review.documents.reasonRequired', 'A reason is required'),
message: '',
color: "red",
title: t("review.documents.reasonRequired", "A reason is required"),
message: "",
});
return;
}
@@ -98,7 +102,7 @@ export function DocumentsTab({
id: applicationId,
documentKey,
decision,
reason: decision === 'REJECTED' ? reason : undefined,
reason: decision === "REJECTED" ? reason : undefined,
attachmentId,
}).unwrap();
setRejecting((prev) => {
@@ -108,24 +112,29 @@ export function DocumentsTab({
});
} catch {
notifications.show({
color: 'red',
title: t('review.documents.saveFailed', 'Could not save the verdict'),
message: '',
color: "red",
title: t("review.documents.saveFailed", "Could not save the verdict"),
message: "",
});
}
}
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
const requirementByKey = new Map(requirements.map((r) => [r.key, r]));
const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
const mandatory = requirements.filter(
(r) =>
r.mode === "ALWAYS" ||
(r.mode === "CONDITIONAL" &&
conditionHolds(r.conditionExpression, formData)),
);
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
const completeness = mandatory.length
? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100)
: 100;
const previewFile = preview?.files?.[0];
const isImage = previewFile?.mimeType?.startsWith('image/');
const isPdf = previewFile?.mimeType === 'application/pdf';
const isImage = previewFile?.mimeType?.startsWith("image/");
const isPdf = previewFile?.mimeType === "application/pdf";
return (
<Stack gap="md">
@@ -133,25 +142,30 @@ export function DocumentsTab({
<Paper withBorder p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
{t('review.documents.completeness', 'Required documents')}
{t("review.documents.completeness", "Required documents")}
</Text>
<Text size="sm" c={missing.length ? 'orange' : 'teal'} fw={600}>
<Text size="sm" c={missing.length ? "orange" : "teal"} fw={600}>
{mandatory.length - missing.length}/{mandatory.length}
</Text>
</Group>
<Progress
value={completeness}
color={missing.length ? 'orange' : 'teal'}
aria-label={t('review.documents.completenessLabel', {
color={missing.length ? "orange" : "teal"}
aria-label={t("review.documents.completenessLabel", {
value: completeness,
defaultValue: '{{value}}% of required documents uploaded',
defaultValue: "{{value}}% of required documents uploaded",
})}
/>
{missing.length > 0 && (
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
<Alert
mt="sm"
color="orange"
icon={<IconAlertCircle size={16} />}
variant="light"
>
<Text size="sm">
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
{missing.map((r) => localized(r.name) || r.key).join(', ')}
{t("review.documents.missing", "Not yet uploaded")}:{" "}
{missing.map((r) => localized(r.name) || r.key).join(", ")}
</Text>
</Alert>
)}
@@ -174,45 +188,55 @@ export function DocumentsTab({
opaque badge painted over the bleeding text. Nested here
with its own wrap, the name truncates cleanly instead. */}
<Group gap={6} wrap="wrap" align="center">
<Text size="sm" fw={500} truncate style={{ maxWidth: '100%' }}>
{localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey}
<Text
size="sm"
fw={500}
truncate
style={{ maxWidth: "100%" }}
>
{localized(
requirementByKey.get(attachment.documentKey)?.name,
) || attachment.documentKey}
</Text>
{verdict && (
<Tooltip
label={
verdict.reason ??
t('review.documents.reviewedBy', {
name: verdict.reviewedByName ?? '—',
defaultValue: 'Reviewed by {{name}}',
t("review.documents.reviewedBy", {
name: verdict.reviewedByName ?? "—",
defaultValue: "Reviewed by {{name}}",
})
}
>
<Badge
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
color={
verdict.decision === "ACCEPTED" ? "teal" : "red"
}
variant="light"
size="sm"
leftSection={
verdict.decision === 'ACCEPTED' ? (
verdict.decision === "ACCEPTED" ? (
<IconCheck size={11} />
) : (
<IconX size={11} />
)
}
>
{verdict.decision === 'ACCEPTED'
? t('review.documents.accepted', 'Accepted')
: t('review.documents.rejected', 'Rejected')}
{verdict.decision === "ACCEPTED"
? t("review.documents.accepted", "Accepted")
: t("review.documents.rejected", "Rejected")}
</Badge>
</Tooltip>
)}
{flagged && (
<Badge color="orange" variant="light" size="sm">
{t('review.documents.flagged', 'Correction requested')}
{t("review.documents.flagged", "Correction requested")}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{file?.originalName ?? t('review.documents.noFile', 'No file')}
{file?.originalName ??
t("review.documents.noFile", "No file")}
</Text>
</div>
</Group>
@@ -221,8 +245,11 @@ export function DocumentsTab({
<Tooltip
label={
file?.url
? t('review.documents.preview', 'Preview')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
? t("review.documents.preview", "Preview")
: t(
"review.documents.noFileUploaded",
"Nothing uploaded yet",
)
}
>
<span>
@@ -233,15 +260,18 @@ export function DocumentsTab({
disabled={!file?.url}
onClick={() => setPreview(attachment)}
>
{t('review.documents.view', 'View')}
{t("review.documents.view", "View")}
</Button>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.download', 'Download')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
? t("review.documents.download", "Download")
: t(
"review.documents.noFileUploaded",
"Nothing uploaded yet",
)
}
>
<span>
@@ -253,7 +283,7 @@ export function DocumentsTab({
download={file?.originalName}
target="_blank"
rel="noreferrer"
aria-label={t('review.documents.download', 'Download')}
aria-label={t("review.documents.download", "Download")}
>
<IconDownload size={16} />
</ActionIcon>
@@ -266,19 +296,28 @@ export function DocumentsTab({
<Tooltip
label={
file?.url
? t('review.documents.accept', 'Accept')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
? t("review.documents.accept", "Accept")
: t(
"review.documents.nothingToJudge",
"Nothing uploaded to judge",
)
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'ACCEPTED' ? 'filled' : 'light'}
variant={
verdict?.decision === "ACCEPTED" ? "filled" : "light"
}
color="teal"
loading={saving}
disabled={!file?.url}
aria-label={t('review.documents.accept', 'Accept')}
aria-label={t("review.documents.accept", "Accept")}
onClick={() =>
decide(attachment.documentKey, 'ACCEPTED', attachment.id)
decide(
attachment.documentKey,
"ACCEPTED",
attachment.id,
)
}
>
<IconCheck size={16} />
@@ -288,20 +327,25 @@ export function DocumentsTab({
<Tooltip
label={
file?.url
? t('review.documents.reject', 'Reject')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
? t("review.documents.reject", "Reject")
: t(
"review.documents.nothingToJudge",
"Nothing uploaded to judge",
)
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'REJECTED' ? 'filled' : 'light'}
variant={
verdict?.decision === "REJECTED" ? "filled" : "light"
}
color="red"
disabled={!file?.url}
aria-label={t('review.documents.reject', 'Reject')}
aria-label={t("review.documents.reject", "Reject")}
onClick={() =>
setRejecting((prev) => ({
...prev,
[attachment.documentKey]: verdict?.reason ?? '',
[attachment.documentKey]: verdict?.reason ?? "",
}))
}
>
@@ -310,11 +354,11 @@ export function DocumentsTab({
</span>
</Tooltip>
{verdict && (
<Tooltip label={t('review.documents.clear', 'Clear verdict')}>
<Tooltip label={t("review.documents.clear", "Clear verdict")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t('review.documents.clear', 'Clear verdict')}
aria-label={t("review.documents.clear", "Clear verdict")}
onClick={() =>
clearReview({
id: applicationId,
@@ -330,7 +374,7 @@ export function DocumentsTab({
size="xs"
checked={flagged}
onChange={() => onToggleFlag(attachment.documentKey)}
label={t('review.documents.includeInAdjustment', 'Send back')}
label={t("review.documents.includeInAdjustment", "Send back")}
/>
</Group>
</Group>
@@ -342,8 +386,8 @@ export function DocumentsTab({
size="xs"
autoFocus
placeholder={t(
'review.documents.rejectReason',
'Why must this document be corrected?',
"review.documents.rejectReason",
"Why must this document be corrected?",
)}
value={rejecting[attachment.documentKey]}
onChange={(e) => {
@@ -363,10 +407,10 @@ export function DocumentsTab({
loading={saving}
disabled={!rejecting[attachment.documentKey]?.trim()}
onClick={() =>
decide(attachment.documentKey, 'REJECTED', attachment.id)
decide(attachment.documentKey, "REJECTED", attachment.id)
}
>
{t('review.documents.confirmReject', 'Reject')}
{t("review.documents.confirmReject", "Reject")}
</Button>
</Group>
)}
@@ -376,15 +420,20 @@ export function DocumentsTab({
mt="sm"
size="xs"
placeholder={t(
'review.documents.adjustmentNote',
'What must the applicant correct?',
"review.documents.adjustmentNote",
"What must the applicant correct?",
)}
value={flags[attachment.documentKey]}
onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)}
onChange={(e) =>
onFlagRemark(attachment.documentKey, e.currentTarget.value)
}
error={
flags[attachment.documentKey].trim()
? undefined
: t('review.documents.reasonRequired', 'A reason is required')
: t(
"review.documents.reasonRequired",
"A reason is required",
)
}
/>
)}
@@ -399,8 +448,9 @@ export function DocumentsTab({
size="xl"
title={
preview
? localized(requirementByKey.get(preview.documentKey)?.name) || preview.documentKey
: ''
? localized(requirementByKey.get(preview.documentKey)?.name) ||
preview.documentKey
: ""
}
// Focus is trapped and returned so keyboard users are not dropped at
// the top of the page when the drawer closes.
@@ -411,22 +461,28 @@ export function DocumentsTab({
isPdf ? (
<iframe
src={previewFile.url}
title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ width: '100%', height: '80vh', border: 'none' }}
title={
preview?.documentKey ??
t("review.documents.previewFallback", "document")
}
style={{ width: "100%", height: "80vh", border: "none" }}
/>
) : isImage ? (
<img
src={previewFile.url}
alt={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ maxWidth: '100%' }}
alt={
preview?.documentKey ??
t("review.documents.previewFallback", "document")
}
style={{ maxWidth: "100%" }}
/>
) : (
// Anything the browser will not render inline still gets a way out.
<Stack align="center" gap="sm" py="xl">
<Text size="sm" c="dimmed">
{t(
'review.documents.noInlinePreview',
'This file type cannot be previewed in the browser.',
"review.documents.noInlinePreview",
"This file type cannot be previewed in the browser.",
)}
</Text>
<Button
@@ -436,7 +492,7 @@ export function DocumentsTab({
rel="noreferrer"
leftSection={<IconDownload size={16} />}
>
{t('review.documents.downloadShort', 'Download')}
{t("review.documents.downloadShort", "Download")}
</Button>
</Stack>
)

View File

@@ -0,0 +1,265 @@
import {
Badge,
Card,
Checkbox,
Divider,
Grid,
Group,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
conditionHolds,
displayFieldValue,
useLocalized,
type FormFieldConfig,
type FormSectionConfig,
} from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
/** A section as it will be rendered: config where there is some, key otherwise. */
interface ResolvedSection {
key: string;
title: string;
description?: string;
fields: { field: FormFieldConfig; value: unknown }[];
}
interface FormDetailsTabProps {
/** The application's answers, keyed by section. */
formData: Record<string, Record<string, unknown>>;
/** The licence type's form schema — the order and labels to render by. */
configSections: FormSectionConfig[];
currency?: string;
/** sectionKey -> remark. Owned by the review page. */
flags: Record<string, { remark: string }>;
onToggleFlag: (sectionKey: string) => void;
onFlagRemark: (sectionKey: string, remark: string) => void;
/** Resolves a location id to a readable path, when the tree is loaded. */
resolveLocation?: (locationId: string) => string | undefined;
}
/**
* What the applicant actually filled in, as the reviewing officer reads it.
*
* Replaces a set of bordered key/value tables built by walking `formData`.
* Three things were wrong with that, all of them worse on a person-centric
* registration than on a company licence:
*
* - Values were printed with `String(v)`, so a reviewer deciding on a seafarer
* read `O_POSITIVE`, `DECK` and `true` — database codes, not the answers
* anybody chose. Now resolved through the same field config that rendered
* the input, shared with the applicant's own summary (`displayFieldValue`).
* - Order came from jsonb key order, which is arbitrary: the declaration could
* appear above the emergency contact. Now the schema's `sortOrder` decides,
* which is the order the applicant filled them in.
* - A location answer is a uuid. Shown raw it told the reviewer nothing;
* resolved, it reads "Addis Ababa → Bole → Woreda 03".
*/
export function FormDetailsTab({
formData,
configSections,
currency,
flags,
onToggleFlag,
onFlagRemark,
resolveLocation,
}: FormDetailsTabProps) {
const { t, i18n } = useTranslation();
const localized = useLocalized();
const showDate = useDateDisplayer();
const sections = resolveSections();
/**
* Sections in schema order, each with its fields in schema order.
*
* Anything present in `formData` but absent from the schema is still shown,
* appended after the configured sections — a stale answer from a since-edited
* form is exactly the kind of thing a reviewer needs to see, not something to
* hide because the config moved on.
*/
function resolveSections(): ResolvedSection[] {
const configured = [...configSections]
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((section) => {
const values = formData[section.key] ?? {};
const fields = [...(section.fields ?? [])]
.filter((f) => conditionHolds(f.showWhen, formData))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((field) => ({ field, value: values[field.key] }));
return {
key: section.key,
title: localized(section.title) || section.key,
description: localized(section.description) || undefined,
fields,
};
})
// A section the applicant never reached is noise on a review screen.
.filter((s) => s.fields.some((f) => hasValue(f.value)));
const configuredKeys = new Set(configSections.map((s) => s.key));
const orphans: ResolvedSection[] = Object.entries(formData)
.filter(([key, values]) => !configuredKeys.has(key) && values)
.map(([key, values]) => ({
key,
title: humanise(key),
fields: Object.entries(values).map(([fieldKey, value]) => ({
// No config to render by, so it is treated as free text under a
// humanised key rather than dropped.
field: { key: fieldKey, label: { en: humanise(fieldKey) }, type: 'TEXT' } as FormFieldConfig,
value,
})),
}));
return [...configured, ...orphans];
}
function display(field: FormFieldConfig, value: unknown): string {
// A location is stored as a tree id; the reviewer needs the place.
if (isLocationField(field) && typeof value === 'string' && value) {
return resolveLocation?.(value) ?? value;
}
return displayFieldValue(field, value, {
language: i18n.language,
showDate,
currency,
});
}
return (
<Grid>
{sections.map((section) => {
const flagged = Boolean(flags[section.key]);
const missing = section.fields.filter((f) => !hasValue(f.value)).length;
return (
<Grid.Col span={12} key={section.key}>
<Card withBorder padding="md" radius="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<div style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={600} size="sm">
{section.title}
</Text>
{missing > 0 && (
<Tooltip
label={t(
'review.missingAnswers',
'Left blank by the applicant',
)}
>
<Badge
size="xs"
color="gray"
variant="light"
leftSection={<IconAlertTriangle size={10} />}
>
{missing}
</Badge>
</Tooltip>
)}
</Group>
{section.description && (
<Text size="xs" c="dimmed" mt={2}>
{section.description}
</Text>
)}
</div>
<Checkbox
size="xs"
label={t('review.needsCorrection', 'Needs correction')}
checked={flagged}
onChange={() => onToggleFlag(section.key)}
style={{ flexShrink: 0 }}
/>
</Group>
<Divider my="sm" />
{/* Label above value, two per row — a reviewer scans a definition
list far faster than a bordered table of the same answers. */}
<Grid gutter="sm">
{section.fields.map(({ field, value }) => {
const text = display(field, value);
const answered = hasValue(value) && text !== '';
return (
<Grid.Col
span={{ base: 12, sm: field.type === 'TEXTAREA' ? 12 : 6 }}
key={field.key}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{localized(field.label) || field.key}
</Text>
<Group gap={4} wrap="nowrap" align="center" mt={2}>
{answered && isLocationField(field) && (
<IconMapPin size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
)}
<Text
size="sm"
c={answered ? undefined : 'dimmed'}
fs={answered ? undefined : 'italic'}
style={{ wordBreak: 'break-word' }}
>
{answered
? text
: t('review.notProvided', 'Not provided')}
</Text>
</Group>
</Grid.Col>
);
})}
</Grid>
{flagged && (
<TextInput
mt="sm"
size="xs"
withAsterisk
placeholder={t(
'review.correctionPlaceholder',
'What must the applicant correct?',
)}
// Flagging without saying why is what the applicant would
// receive: "fix this section", and nothing else.
error={
flags[section.key].remark.trim()
? null
: t('review.correctionRequired', 'Say what must be corrected')
}
value={flags[section.key].remark}
onChange={(e) => {
// Read here, not inside the updater: React nulls
// `currentTarget` when the handler returns, and the updater
// runs afterwards during the re-render.
onFlagRemark(section.key, e.currentTarget.value);
}}
/>
)}
</Card>
</Grid.Col>
);
})}
</Grid>
);
}
function hasValue(value: unknown): boolean {
return value !== null && value !== undefined && value !== '';
}
/** English-pinned, like the portal's own location override. */
function isLocationField(field: Pick<FormFieldConfig, 'key' | 'label'>): boolean {
return (
field.key === 'locationId' ||
(field.label?.en ?? '').trim().toLowerCase() === 'location'
);
}
function humanise(key: string): string {
const spaced = key.replace(/([A-Z])/g, ' $1').replace(/[_-]+/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1).trim();
}

View File

@@ -267,6 +267,28 @@ export interface ResolveContext {
allDocumentsAccepted: boolean;
}
/**
* Action ids that are workflow events, so `availableEvents` decides them.
*
* The rest (`schedule-inspection`, `schedule-exam`, the secondary tools) are
* screens and side effects rather than transitions, and the server has no
* opinion on them — those keep using their own `from` list.
*/
const WORKFLOW_EVENT_IDS = new Set<ActionId>([
'claim',
'assign',
'escalate',
'hold',
'resume',
'complete-review',
'approve-documents',
'record-inspection',
'final-approve',
'request-adjustment',
'reject',
'confirm-payment',
]);
/**
* Which actions to render, and for each, whether it can fire and why not.
*
@@ -274,16 +296,35 @@ export interface ResolveContext {
* are merely unavailable right now are kept and disabled with a reason, so the
* officer can see what the next step would be rather than wondering whether
* the screen is broken.
*
* For anything that is a workflow event, `detail.availableEvents` is the
* authority on what fires from here — it comes from the same transition table
* the server validates against, and it is workflow-profile aware. The local
* `from` lists describe the licence course only, so a registration (which skips
* evaluation and inspection, and approves straight out of UNDER_REVIEW) was
* offered Complete Review — rejected server-side with
* `event_not_available_for_service` — while Final Approve, the one action that
* would work, was hidden.
*/
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
const { detail, currentUserId, can, reasons } = ctx;
const app = detail.application;
const serverEvents = detail.availableEvents;
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
(action) => {
// Status-scoped actions vanish outside their stage rather than piling up
// as a column of permanently dead buttons.
if (action.from && !action.from.includes(app.status)) return [];
if (WORKFLOW_EVENT_IDS.has(action.id)) {
// Tolerate an older server that sends no list rather than rendering an
// empty action bar.
if (serverEvents?.length && !serverEvents.includes(action.id)) return [];
if (!serverEvents?.length && action.from && !action.from.includes(app.status)) {
return [];
}
} else if (action.from && !action.from.includes(app.status)) {
return [];
}
// Scheduling and recording are the same slot at the same status; which
// one applies depends on whether an inspection is already booked.

View File

@@ -87,6 +87,23 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
// Person-centric: no company entity, no capital threshold, no staff roles.
detailSections: ['overview', 'documents'],
},
// Opened automatically when a registration is approved, and reviewed like any
// other person-centric service. Listed explicitly because neither key matches
// the certificate prefixes below, so both fell through to the company-shaped
// default and offered an officer Company, Financials and Staff tabs for an
// application about one person.
SEAMAN_BOOK: {
key: 'SEAMAN_BOOK',
icon: IconId,
// Its own TRB inspection is a real stage, unlike the other personal
// services, so the inspection tab stays.
detailSections: ['overview', 'documents', 'inspection'],
},
BTC_BASIC_TRAINING: {
key: 'BTC_BASIC_TRAINING',
icon: IconShieldCheck,
detailSections: ['overview', 'documents'],
},
VESSEL_REGISTRATION: {
key: 'VESSEL_REGISTRATION',
icon: IconAnchor,

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
Badge,
@@ -136,7 +136,9 @@ export function LicenseQueuePage() {
const density = useAppSelector((state) => state.preferences.density);
const [view, setView] = useState<SavedViewId>(
() => (searchParams.get("view") as SavedViewId) || readLastView(),
() =>
(searchParams.get("view") as SavedViewId) ||
(typeCode === "BTC_BASIC_TRAINING" ? "all" : readLastView()),
);
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
@@ -146,6 +148,19 @@ export function LicenseQueuePage() {
const [helpOpen, setHelpOpen] = useState(false);
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
// Auto-created BTC requests start at PAYMENT_PENDING, which is not part of
// the unassigned officer work pool. A dedicated BTC Queue must therefore
// open its all-status view so those requests are visible immediately.
useEffect(() => {
if (
typeCode === "BTC_BASIC_TRAINING" &&
!searchParams.has("view") &&
view !== "all"
) {
setView("all");
}
}, [typeCode, searchParams, view]);
const urlFilter = useMemo(
() => filterFromSearchParams(searchParams),
[searchParams],

File diff suppressed because it is too large Load Diff

View File

@@ -140,7 +140,10 @@ export function LocationForm({
placeholder={t('location.selectType')}
data={allAtLevel.map((lt) => ({
value: lt.id,
label: lt.names[locale],
// Not every locale is filled in on every row, and an option
// with no label is unpickable — fall back to English, then the
// code, which always exists.
label: lt.names[locale] || lt.names.en || lt.code,
}))}
{...form.getInputProps('locationTypeId')}
size="sm"

View File

@@ -178,7 +178,7 @@ export function LocationTree({
if (!search) return tree;
const matches = (loc: Location): boolean => {
const nameMatch = loc.names.en
const nameMatch = (loc.names.en ?? '')
.toLowerCase()
.includes(search.toLowerCase());
const childMatch =

View File

@@ -26,7 +26,10 @@ export function locationTypeColumns(
},
{
header: t('location.name'),
cell: ({ row }) => row.original.names[locale],
// Falls back like the type Select: a row missing this locale shows its
// English name, then its code, rather than an empty cell.
cell: ({ row }) =>
row.original.names[locale] || row.original.names.en || row.original.code,
},
];
}

View File

@@ -18,6 +18,7 @@ import {
useDeleteLocationTypeMutation,
} from '../../api/location-api';
import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import type { LocationType } from '../../types/location';
import { locationTypeColumns } from './columns';
import { locationTypeColumnActions } from './actions';
@@ -68,12 +69,14 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
setShowForm(false);
};
const handleEdit = (type: { id: string; code: string; names: { en: string; am: string }; level: number }) => {
const handleEdit = (type: LocationType) => {
setEditingId(type.id);
form.setValues({
code: type.code,
namesEn: type.names.en,
namesAm: type.names.am,
// The form's inputs are controlled strings; a locale the row never had
// must edit as empty rather than reading back "undefined".
namesEn: type.names.en ?? '',
namesAm: type.names.am ?? '',
level: type.level,
});
setShowForm(true);

View File

@@ -1,37 +1,24 @@
export interface NamePair {
en: string;
am: string;
}
/**
* Re-exported from the shared contract so both apps read one definition.
*
* See the portal's copy of this file: the two apps each maintained their own
* `Location`/`LocationType` and drifted. The payload types below stay here —
* only the backoffice writes locations.
*/
export type {
Location,
LocationType,
ListResponse,
} from '@ema-platform/api';
export interface LocationType {
id: string;
code: string;
names: NamePair;
level: number;
createdAt: string;
updatedAt: string;
}
import type { Bilingual } from '@ema-platform/api';
export interface Location {
id: string;
code: string;
names: NamePair;
locationTypeId: string;
parentId: string | null;
locationType?: LocationType;
children?: Location[];
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
count: number;
items: T[];
}
/** @deprecated Use `Bilingual` from `@ema-platform/api` — it carries om/so too. */
export type NamePair = Bilingual;
export interface CreateLocationTypePayload {
code: string;
names: NamePair;
names: Bilingual;
level: number;
}
@@ -41,7 +28,7 @@ export interface UpdateLocationTypePayload extends CreateLocationTypePayload {
export interface CreateLocationPayload {
code: string;
names: NamePair;
names: Bilingual;
locationTypeId: string;
parentId?: string | null;
}