Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
@@ -22,23 +22,26 @@ import {
|
|||||||
IconFileText,
|
IconFileText,
|
||||||
IconRotate,
|
IconRotate,
|
||||||
IconX,
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
conditionHolds,
|
||||||
useClearDocumentReviewMutation,
|
useClearDocumentReviewMutation,
|
||||||
useGetDocumentReviewsQuery,
|
useGetDocumentReviewsQuery,
|
||||||
useLocalized,
|
useLocalized,
|
||||||
useReviewDocumentMutation,
|
useReviewDocumentMutation,
|
||||||
type Attachment,
|
type Attachment,
|
||||||
type DocumentRequirement,
|
type DocumentRequirement,
|
||||||
} from '@ema-platform/api';
|
} from "@ema-platform/api";
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
interface DocumentsTabProps {
|
interface DocumentsTabProps {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
/** From the licence type config, so completeness is measured against rules. */
|
/** From the licence type config, so completeness is measured against rules. */
|
||||||
requirements: DocumentRequirement[];
|
requirements: DocumentRequirement[];
|
||||||
|
/** Applicant answers used to evaluate conditional document requirements. */
|
||||||
|
formData: Record<string, Record<string, unknown>>;
|
||||||
/** documentKey -> remark. Owned by the review page. */
|
/** documentKey -> remark. Owned by the review page. */
|
||||||
flags: Record<string, string>;
|
flags: Record<string, string>;
|
||||||
onToggleFlag: (documentKey: string) => void;
|
onToggleFlag: (documentKey: string) => void;
|
||||||
@@ -58,6 +61,7 @@ export function DocumentsTab({
|
|||||||
applicationId,
|
applicationId,
|
||||||
attachments,
|
attachments,
|
||||||
requirements,
|
requirements,
|
||||||
|
formData,
|
||||||
flags,
|
flags,
|
||||||
onToggleFlag,
|
onToggleFlag,
|
||||||
onFlagRemark,
|
onFlagRemark,
|
||||||
@@ -80,16 +84,16 @@ export function DocumentsTab({
|
|||||||
|
|
||||||
async function decide(
|
async function decide(
|
||||||
documentKey: string,
|
documentKey: string,
|
||||||
decision: 'ACCEPTED' | 'REJECTED',
|
decision: "ACCEPTED" | "REJECTED",
|
||||||
attachmentId?: string,
|
attachmentId?: string,
|
||||||
) {
|
) {
|
||||||
const reason = rejecting[documentKey]?.trim();
|
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.
|
// The applicant is shown this verbatim, so refuse to send an empty one.
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'red',
|
color: "red",
|
||||||
title: t('review.documents.reasonRequired', 'A reason is required'),
|
title: t("review.documents.reasonRequired", "A reason is required"),
|
||||||
message: '',
|
message: "",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -98,7 +102,7 @@ export function DocumentsTab({
|
|||||||
id: applicationId,
|
id: applicationId,
|
||||||
documentKey,
|
documentKey,
|
||||||
decision,
|
decision,
|
||||||
reason: decision === 'REJECTED' ? reason : undefined,
|
reason: decision === "REJECTED" ? reason : undefined,
|
||||||
attachmentId,
|
attachmentId,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
setRejecting((prev) => {
|
setRejecting((prev) => {
|
||||||
@@ -108,24 +112,29 @@ export function DocumentsTab({
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'red',
|
color: "red",
|
||||||
title: t('review.documents.saveFailed', 'Could not save the verdict'),
|
title: t("review.documents.saveFailed", "Could not save the verdict"),
|
||||||
message: '',
|
message: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
|
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
|
||||||
const requirementByKey = new Map(requirements.map((r) => [r.key, r]));
|
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 missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
|
||||||
const completeness = mandatory.length
|
const completeness = mandatory.length
|
||||||
? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100)
|
? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100)
|
||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
const previewFile = preview?.files?.[0];
|
const previewFile = preview?.files?.[0];
|
||||||
const isImage = previewFile?.mimeType?.startsWith('image/');
|
const isImage = previewFile?.mimeType?.startsWith("image/");
|
||||||
const isPdf = previewFile?.mimeType === 'application/pdf';
|
const isPdf = previewFile?.mimeType === "application/pdf";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -133,25 +142,30 @@ export function DocumentsTab({
|
|||||||
<Paper withBorder p="md">
|
<Paper withBorder p="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">
|
||||||
{t('review.documents.completeness', 'Required documents')}
|
{t("review.documents.completeness", "Required documents")}
|
||||||
</Text>
|
</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}
|
{mandatory.length - missing.length}/{mandatory.length}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Progress
|
<Progress
|
||||||
value={completeness}
|
value={completeness}
|
||||||
color={missing.length ? 'orange' : 'teal'}
|
color={missing.length ? "orange" : "teal"}
|
||||||
aria-label={t('review.documents.completenessLabel', {
|
aria-label={t("review.documents.completenessLabel", {
|
||||||
value: completeness,
|
value: completeness,
|
||||||
defaultValue: '{{value}}% of required documents uploaded',
|
defaultValue: "{{value}}% of required documents uploaded",
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{missing.length > 0 && (
|
{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">
|
<Text size="sm">
|
||||||
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
|
{t("review.documents.missing", "Not yet uploaded")}:{" "}
|
||||||
{missing.map((r) => localized(r.name) || r.key).join(', ')}
|
{missing.map((r) => localized(r.name) || r.key).join(", ")}
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -174,45 +188,55 @@ export function DocumentsTab({
|
|||||||
opaque badge painted over the bleeding text. Nested here
|
opaque badge painted over the bleeding text. Nested here
|
||||||
with its own wrap, the name truncates cleanly instead. */}
|
with its own wrap, the name truncates cleanly instead. */}
|
||||||
<Group gap={6} wrap="wrap" align="center">
|
<Group gap={6} wrap="wrap" align="center">
|
||||||
<Text size="sm" fw={500} truncate style={{ maxWidth: '100%' }}>
|
<Text
|
||||||
{localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey}
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
truncate
|
||||||
|
style={{ maxWidth: "100%" }}
|
||||||
|
>
|
||||||
|
{localized(
|
||||||
|
requirementByKey.get(attachment.documentKey)?.name,
|
||||||
|
) || attachment.documentKey}
|
||||||
</Text>
|
</Text>
|
||||||
{verdict && (
|
{verdict && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
verdict.reason ??
|
verdict.reason ??
|
||||||
t('review.documents.reviewedBy', {
|
t("review.documents.reviewedBy", {
|
||||||
name: verdict.reviewedByName ?? '—',
|
name: verdict.reviewedByName ?? "—",
|
||||||
defaultValue: 'Reviewed by {{name}}',
|
defaultValue: "Reviewed by {{name}}",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
|
color={
|
||||||
|
verdict.decision === "ACCEPTED" ? "teal" : "red"
|
||||||
|
}
|
||||||
variant="light"
|
variant="light"
|
||||||
size="sm"
|
size="sm"
|
||||||
leftSection={
|
leftSection={
|
||||||
verdict.decision === 'ACCEPTED' ? (
|
verdict.decision === "ACCEPTED" ? (
|
||||||
<IconCheck size={11} />
|
<IconCheck size={11} />
|
||||||
) : (
|
) : (
|
||||||
<IconX size={11} />
|
<IconX size={11} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{verdict.decision === 'ACCEPTED'
|
{verdict.decision === "ACCEPTED"
|
||||||
? t('review.documents.accepted', 'Accepted')
|
? t("review.documents.accepted", "Accepted")
|
||||||
: t('review.documents.rejected', 'Rejected')}
|
: t("review.documents.rejected", "Rejected")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{flagged && (
|
{flagged && (
|
||||||
<Badge color="orange" variant="light" size="sm">
|
<Badge color="orange" variant="light" size="sm">
|
||||||
{t('review.documents.flagged', 'Correction requested')}
|
{t("review.documents.flagged", "Correction requested")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" c="dimmed" truncate>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{file?.originalName ?? t('review.documents.noFile', 'No file')}
|
{file?.originalName ??
|
||||||
|
t("review.documents.noFile", "No file")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -221,8 +245,11 @@ export function DocumentsTab({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
file?.url
|
file?.url
|
||||||
? t('review.documents.preview', 'Preview')
|
? t("review.documents.preview", "Preview")
|
||||||
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
|
: t(
|
||||||
|
"review.documents.noFileUploaded",
|
||||||
|
"Nothing uploaded yet",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
@@ -233,15 +260,18 @@ export function DocumentsTab({
|
|||||||
disabled={!file?.url}
|
disabled={!file?.url}
|
||||||
onClick={() => setPreview(attachment)}
|
onClick={() => setPreview(attachment)}
|
||||||
>
|
>
|
||||||
{t('review.documents.view', 'View')}
|
{t("review.documents.view", "View")}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
file?.url
|
file?.url
|
||||||
? t('review.documents.download', 'Download')
|
? t("review.documents.download", "Download")
|
||||||
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
|
: t(
|
||||||
|
"review.documents.noFileUploaded",
|
||||||
|
"Nothing uploaded yet",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
@@ -253,7 +283,7 @@ export function DocumentsTab({
|
|||||||
download={file?.originalName}
|
download={file?.originalName}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
aria-label={t('review.documents.download', 'Download')}
|
aria-label={t("review.documents.download", "Download")}
|
||||||
>
|
>
|
||||||
<IconDownload size={16} />
|
<IconDownload size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -266,19 +296,28 @@ export function DocumentsTab({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
file?.url
|
file?.url
|
||||||
? t('review.documents.accept', 'Accept')
|
? t("review.documents.accept", "Accept")
|
||||||
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
|
: t(
|
||||||
|
"review.documents.nothingToJudge",
|
||||||
|
"Nothing uploaded to judge",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={verdict?.decision === 'ACCEPTED' ? 'filled' : 'light'}
|
variant={
|
||||||
|
verdict?.decision === "ACCEPTED" ? "filled" : "light"
|
||||||
|
}
|
||||||
color="teal"
|
color="teal"
|
||||||
loading={saving}
|
loading={saving}
|
||||||
disabled={!file?.url}
|
disabled={!file?.url}
|
||||||
aria-label={t('review.documents.accept', 'Accept')}
|
aria-label={t("review.documents.accept", "Accept")}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
decide(attachment.documentKey, 'ACCEPTED', attachment.id)
|
decide(
|
||||||
|
attachment.documentKey,
|
||||||
|
"ACCEPTED",
|
||||||
|
attachment.id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconCheck size={16} />
|
<IconCheck size={16} />
|
||||||
@@ -288,20 +327,25 @@ export function DocumentsTab({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
file?.url
|
file?.url
|
||||||
? t('review.documents.reject', 'Reject')
|
? t("review.documents.reject", "Reject")
|
||||||
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
|
: t(
|
||||||
|
"review.documents.nothingToJudge",
|
||||||
|
"Nothing uploaded to judge",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={verdict?.decision === 'REJECTED' ? 'filled' : 'light'}
|
variant={
|
||||||
|
verdict?.decision === "REJECTED" ? "filled" : "light"
|
||||||
|
}
|
||||||
color="red"
|
color="red"
|
||||||
disabled={!file?.url}
|
disabled={!file?.url}
|
||||||
aria-label={t('review.documents.reject', 'Reject')}
|
aria-label={t("review.documents.reject", "Reject")}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setRejecting((prev) => ({
|
setRejecting((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[attachment.documentKey]: verdict?.reason ?? '',
|
[attachment.documentKey]: verdict?.reason ?? "",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -310,11 +354,11 @@ export function DocumentsTab({
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{verdict && (
|
{verdict && (
|
||||||
<Tooltip label={t('review.documents.clear', 'Clear verdict')}>
|
<Tooltip label={t("review.documents.clear", "Clear verdict")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
aria-label={t('review.documents.clear', 'Clear verdict')}
|
aria-label={t("review.documents.clear", "Clear verdict")}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
clearReview({
|
clearReview({
|
||||||
id: applicationId,
|
id: applicationId,
|
||||||
@@ -330,7 +374,7 @@ export function DocumentsTab({
|
|||||||
size="xs"
|
size="xs"
|
||||||
checked={flagged}
|
checked={flagged}
|
||||||
onChange={() => onToggleFlag(attachment.documentKey)}
|
onChange={() => onToggleFlag(attachment.documentKey)}
|
||||||
label={t('review.documents.includeInAdjustment', 'Send back')}
|
label={t("review.documents.includeInAdjustment", "Send back")}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -342,8 +386,8 @@ export function DocumentsTab({
|
|||||||
size="xs"
|
size="xs"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
'review.documents.rejectReason',
|
"review.documents.rejectReason",
|
||||||
'Why must this document be corrected?',
|
"Why must this document be corrected?",
|
||||||
)}
|
)}
|
||||||
value={rejecting[attachment.documentKey]}
|
value={rejecting[attachment.documentKey]}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -363,10 +407,10 @@ export function DocumentsTab({
|
|||||||
loading={saving}
|
loading={saving}
|
||||||
disabled={!rejecting[attachment.documentKey]?.trim()}
|
disabled={!rejecting[attachment.documentKey]?.trim()}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
decide(attachment.documentKey, 'REJECTED', attachment.id)
|
decide(attachment.documentKey, "REJECTED", attachment.id)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('review.documents.confirmReject', 'Reject')}
|
{t("review.documents.confirmReject", "Reject")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
@@ -376,15 +420,20 @@ export function DocumentsTab({
|
|||||||
mt="sm"
|
mt="sm"
|
||||||
size="xs"
|
size="xs"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
'review.documents.adjustmentNote',
|
"review.documents.adjustmentNote",
|
||||||
'What must the applicant correct?',
|
"What must the applicant correct?",
|
||||||
)}
|
)}
|
||||||
value={flags[attachment.documentKey]}
|
value={flags[attachment.documentKey]}
|
||||||
onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)}
|
onChange={(e) =>
|
||||||
|
onFlagRemark(attachment.documentKey, e.currentTarget.value)
|
||||||
|
}
|
||||||
error={
|
error={
|
||||||
flags[attachment.documentKey].trim()
|
flags[attachment.documentKey].trim()
|
||||||
? undefined
|
? 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"
|
size="xl"
|
||||||
title={
|
title={
|
||||||
preview
|
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
|
// Focus is trapped and returned so keyboard users are not dropped at
|
||||||
// the top of the page when the drawer closes.
|
// the top of the page when the drawer closes.
|
||||||
@@ -411,22 +461,28 @@ export function DocumentsTab({
|
|||||||
isPdf ? (
|
isPdf ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={previewFile.url}
|
src={previewFile.url}
|
||||||
title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
|
title={
|
||||||
style={{ width: '100%', height: '80vh', border: 'none' }}
|
preview?.documentKey ??
|
||||||
|
t("review.documents.previewFallback", "document")
|
||||||
|
}
|
||||||
|
style={{ width: "100%", height: "80vh", border: "none" }}
|
||||||
/>
|
/>
|
||||||
) : isImage ? (
|
) : isImage ? (
|
||||||
<img
|
<img
|
||||||
src={previewFile.url}
|
src={previewFile.url}
|
||||||
alt={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
|
alt={
|
||||||
style={{ maxWidth: '100%' }}
|
preview?.documentKey ??
|
||||||
|
t("review.documents.previewFallback", "document")
|
||||||
|
}
|
||||||
|
style={{ maxWidth: "100%" }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Anything the browser will not render inline still gets a way out.
|
// Anything the browser will not render inline still gets a way out.
|
||||||
<Stack align="center" gap="sm" py="xl">
|
<Stack align="center" gap="sm" py="xl">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t(
|
{t(
|
||||||
'review.documents.noInlinePreview',
|
"review.documents.noInlinePreview",
|
||||||
'This file type cannot be previewed in the browser.',
|
"This file type cannot be previewed in the browser.",
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
@@ -436,7 +492,7 @@ export function DocumentsTab({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
leftSection={<IconDownload size={16} />}
|
leftSection={<IconDownload size={16} />}
|
||||||
>
|
>
|
||||||
{t('review.documents.downloadShort', 'Download')}
|
{t("review.documents.downloadShort", "Download")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -267,6 +267,28 @@ export interface ResolveContext {
|
|||||||
allDocumentsAccepted: boolean;
|
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.
|
* 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
|
* 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
|
* officer can see what the next step would be rather than wondering whether
|
||||||
* the screen is broken.
|
* 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[] {
|
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||||
const { detail, currentUserId, can, reasons } = ctx;
|
const { detail, currentUserId, can, reasons } = ctx;
|
||||||
const app = detail.application;
|
const app = detail.application;
|
||||||
|
const serverEvents = detail.availableEvents;
|
||||||
|
|
||||||
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
|
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
|
||||||
(action) => {
|
(action) => {
|
||||||
// Status-scoped actions vanish outside their stage rather than piling up
|
// Status-scoped actions vanish outside their stage rather than piling up
|
||||||
// as a column of permanently dead buttons.
|
// 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
|
// Scheduling and recording are the same slot at the same status; which
|
||||||
// one applies depends on whether an inspection is already booked.
|
// one applies depends on whether an inspection is already booked.
|
||||||
|
|||||||
@@ -87,6 +87,23 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
|||||||
// Person-centric: no company entity, no capital threshold, no staff roles.
|
// Person-centric: no company entity, no capital threshold, no staff roles.
|
||||||
detailSections: ['overview', 'documents'],
|
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: {
|
VESSEL_REGISTRATION: {
|
||||||
key: 'VESSEL_REGISTRATION',
|
key: 'VESSEL_REGISTRATION',
|
||||||
icon: IconAnchor,
|
icon: IconAnchor,
|
||||||
|
|||||||
@@ -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 { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -136,7 +136,9 @@ export function LicenseQueuePage() {
|
|||||||
const density = useAppSelector((state) => state.preferences.density);
|
const density = useAppSelector((state) => state.preferences.density);
|
||||||
|
|
||||||
const [view, setView] = useState<SavedViewId>(
|
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 [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||||
@@ -146,6 +148,19 @@ export function LicenseQueuePage() {
|
|||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
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(
|
const urlFilter = useMemo(
|
||||||
() => filterFromSearchParams(searchParams),
|
() => filterFromSearchParams(searchParams),
|
||||||
[searchParams],
|
[searchParams],
|
||||||
|
|||||||
@@ -140,7 +140,10 @@ export function LocationForm({
|
|||||||
placeholder={t('location.selectType')}
|
placeholder={t('location.selectType')}
|
||||||
data={allAtLevel.map((lt) => ({
|
data={allAtLevel.map((lt) => ({
|
||||||
value: lt.id,
|
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')}
|
{...form.getInputProps('locationTypeId')}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export function LocationTree({
|
|||||||
if (!search) return tree;
|
if (!search) return tree;
|
||||||
|
|
||||||
const matches = (loc: Location): boolean => {
|
const matches = (loc: Location): boolean => {
|
||||||
const nameMatch = loc.names.en
|
const nameMatch = (loc.names.en ?? '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(search.toLowerCase());
|
.includes(search.toLowerCase());
|
||||||
const childMatch =
|
const childMatch =
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ export function locationTypeColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t('location.name'),
|
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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useDeleteLocationTypeMutation,
|
useDeleteLocationTypeMutation,
|
||||||
} from '../../api/location-api';
|
} from '../../api/location-api';
|
||||||
import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||||
|
import type { LocationType } from '../../types/location';
|
||||||
import { locationTypeColumns } from './columns';
|
import { locationTypeColumns } from './columns';
|
||||||
import { locationTypeColumnActions } from './actions';
|
import { locationTypeColumnActions } from './actions';
|
||||||
|
|
||||||
@@ -68,12 +69,14 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
|||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (type: { id: string; code: string; names: { en: string; am: string }; level: number }) => {
|
const handleEdit = (type: LocationType) => {
|
||||||
setEditingId(type.id);
|
setEditingId(type.id);
|
||||||
form.setValues({
|
form.setValues({
|
||||||
code: type.code,
|
code: type.code,
|
||||||
namesEn: type.names.en,
|
// The form's inputs are controlled strings; a locale the row never had
|
||||||
namesAm: type.names.am,
|
// must edit as empty rather than reading back "undefined".
|
||||||
|
namesEn: type.names.en ?? '',
|
||||||
|
namesAm: type.names.am ?? '',
|
||||||
level: type.level,
|
level: type.level,
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
|
|||||||
@@ -1,37 +1,24 @@
|
|||||||
export interface NamePair {
|
/**
|
||||||
en: string;
|
* Re-exported from the shared contract so both apps read one definition.
|
||||||
am: string;
|
*
|
||||||
}
|
* 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 {
|
import type { Bilingual } from '@ema-platform/api';
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
names: NamePair;
|
|
||||||
level: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Location {
|
/** @deprecated Use `Bilingual` from `@ema-platform/api` — it carries om/so too. */
|
||||||
id: string;
|
export type NamePair = Bilingual;
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateLocationTypePayload {
|
export interface CreateLocationTypePayload {
|
||||||
code: string;
|
code: string;
|
||||||
names: NamePair;
|
names: Bilingual;
|
||||||
level: number;
|
level: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +28,7 @@ export interface UpdateLocationTypePayload extends CreateLocationTypePayload {
|
|||||||
|
|
||||||
export interface CreateLocationPayload {
|
export interface CreateLocationPayload {
|
||||||
code: string;
|
code: string;
|
||||||
names: NamePair;
|
names: Bilingual;
|
||||||
locationTypeId: string;
|
locationTypeId: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
verifyOtpIfPrompted,
|
verifyOtpIfPrompted,
|
||||||
} from './support/applicant';
|
} from './support/applicant';
|
||||||
import { deleteApplicant, sql, sqlValue } from './support/db';
|
import { deleteApplicant, sql, sqlValue } from './support/db';
|
||||||
import { approveRegistration, runWorkflow } from './support/workflow';
|
import {
|
||||||
|
approveRegistration,
|
||||||
|
resolveOpenRemarks,
|
||||||
|
runWorkflow,
|
||||||
|
} from './support/workflow';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seafarer registration, applicant through to approval.
|
* Seafarer registration, applicant through to approval.
|
||||||
@@ -23,20 +27,32 @@ import { approveRegistration, runWorkflow } from './support/workflow';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fills the fields `RequireSeafarerProfile` refuses to open the wizard without.
|
* Fills the profile the seafarer wizard prefills its Identity Details step
|
||||||
|
* from.
|
||||||
|
*
|
||||||
|
* No longer a precondition for reaching the wizard — that redirect is gone and
|
||||||
|
* the step collects these itself — but a populated profile is the returning
|
||||||
|
* applicant's case, and it is the prefill that keeps them from retyping.
|
||||||
*
|
*
|
||||||
* They are split across two tabs, and every tab's panel is in the DOM whether
|
* They are split across two tabs, and every tab's panel is in the DOM whether
|
||||||
* or not it is showing — so each one has to be selected before its inputs can
|
* or not it is showing — so each one has to be selected before its inputs can
|
||||||
* be filled, and `PROFILE_FIELD_SECTION` in the auth lib is the map of which
|
* be filled, and `PROFILE_FIELD_SECTION` in the auth lib is the map of which
|
||||||
* field lives where.
|
* field lives where.
|
||||||
*/
|
*/
|
||||||
async function completeProfile(page: Page): Promise<void> {
|
async function completeProfile(
|
||||||
|
page: Page,
|
||||||
|
applicant: Applicant,
|
||||||
|
): Promise<void> {
|
||||||
await page.goto('/profile');
|
await page.goto('/profile');
|
||||||
|
|
||||||
await openTab(page, 'Profile');
|
await openTab(page, 'Profile');
|
||||||
await page.getByLabel('First Name').fill('Dawit');
|
// The account's own name parts, not invented ones: the Maritime tab refuses
|
||||||
await page.getByLabel('Middle Name').fill('Bekele');
|
// to save when they do not join to the name on the Personal tab, and it
|
||||||
await page.getByLabel('Last Name').fill('Tesfaye');
|
// refuses by returning early — no request, no field error, so the failure
|
||||||
|
// surfaced only as "save produced no request".
|
||||||
|
await page.getByLabel('First Name').fill(applicant.firstName);
|
||||||
|
await page.getByLabel('Middle Name').fill(applicant.middleName);
|
||||||
|
await page.getByLabel('Last Name').fill(applicant.lastName);
|
||||||
await pick(page, 'Gender', /male/i);
|
await pick(page, 'Gender', /male/i);
|
||||||
await pickDate(page, 'Date of Birth', '1995-04-12');
|
await pickDate(page, 'Date of Birth', '1995-04-12');
|
||||||
await pick(page, 'Marital Status', /single/i);
|
await pick(page, 'Marital Status', /single/i);
|
||||||
@@ -44,15 +60,16 @@ async function completeProfile(page: Page): Promise<void> {
|
|||||||
await save(page);
|
await save(page);
|
||||||
|
|
||||||
await openTab(page, 'Address');
|
await openTab(page, 'Address');
|
||||||
await pick(page, 'ID Type', /^NID$/i);
|
// Matched on the option's label, not its stored value: the select shows
|
||||||
|
// "National Id" and submits `NID`, so `/^NID$/` matched no option at all.
|
||||||
|
await pick(page, 'ID Type', /^national id$/i);
|
||||||
await page.getByLabel('ID Number').fill('FYD1234567890');
|
await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||||
// A country select, not a free-text field.
|
// A country select, not a free-text field.
|
||||||
await pick(page, 'Nationality', /ethiopia/i);
|
await pick(page, 'Nationality', /ethiopia/i);
|
||||||
// `addressSchema` requires this in Ethiopian format; without it the form
|
// Primary Phone is deliberately not filled: it is `readOnly` here and already
|
||||||
// never submits and no request is made for `save` to wait on.
|
// carries the account's number ("From your account, edit it in the Personal
|
||||||
await page
|
// tab"), so `addressSchema`'s Ethiopian-format rule is already satisfied and a
|
||||||
.getByRole('textbox', { name: 'Primary Phone' })
|
// fill would only fail against a read-only input.
|
||||||
.fill('+251911234567');
|
|
||||||
await save(page);
|
await save(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,19 +157,35 @@ async function save(page: Page): Promise<void> {
|
|||||||
// A zod-blocked submit fires no request at all, so the bare timeout says
|
// A zod-blocked submit fires no request at all, so the bare timeout says
|
||||||
// only "no response" — which reads as a backend fault rather than a form
|
// only "no response" — which reads as a backend fault rather than a form
|
||||||
// that refused to submit. Surface the field errors instead.
|
// that refused to submit. Surface the field errors instead.
|
||||||
|
// Field errors only. `[role="alert"]` also matches Mantine's `<Alert>`, and
|
||||||
|
// the profile page renders an informational seafarer banner as one — which
|
||||||
|
// got reported as "validation errors: Seafarer registration asks for these
|
||||||
|
// details…", pointing at a form that was in fact filled in correctly.
|
||||||
const messages = await page
|
const messages = await page
|
||||||
.locator('.mantine-InputWrapper-error, [role="alert"]')
|
.locator('.mantine-InputWrapper-error')
|
||||||
.allTextContents();
|
.allTextContents();
|
||||||
throw new Error(
|
throw new Error(
|
||||||
messages.length
|
messages.length
|
||||||
? `Save did not submit — validation errors: ${messages.join('; ')}`
|
? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||||
: 'Save produced no request and reported no validation error.',
|
: // No field error either, so the form was valid and something else
|
||||||
|
// refused: `onSaveProfile` early-returns when the profile name does
|
||||||
|
// not match the account name, and notifies rather than marking a
|
||||||
|
// field.
|
||||||
|
'Save produced no request and reported no field error — check for a rejected notification (e.g. the profile/account name match).',
|
||||||
{ cause },
|
{ cause },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Signs up, declares seafarer operations, and fills the gating profile. */
|
/**
|
||||||
|
* Signs up, declares seafarer operations, and fills the profile.
|
||||||
|
*
|
||||||
|
* Declaring seafarer now lands on the registration wizard, not `/profile` — the
|
||||||
|
* wizard collects the identity itself. The profile is still filled here because
|
||||||
|
* these tests are about the registration workflow, and a profile with a name and
|
||||||
|
* an address is what the approval's completion effect writes onto; `/profile` is
|
||||||
|
* navigated to directly rather than waited for as a redirect.
|
||||||
|
*/
|
||||||
async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||||
const offset = await signUp(page, applicant);
|
const offset = await signUp(page, applicant);
|
||||||
await verifyOtpIfPrompted(page, offset);
|
await verifyOtpIfPrompted(page, offset);
|
||||||
@@ -162,10 +195,12 @@ async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
|||||||
.first()
|
.first()
|
||||||
.check();
|
.check();
|
||||||
await page.getByRole('button', { name: /save operations/i }).click();
|
await page.getByRole('button', { name: /save operations/i }).click();
|
||||||
// A seafarer is taken to `/profile`, not the dashboard: registration is
|
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
|
||||||
// built from the profile, and a fresh signup holds none of it yet.
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.goto('/profile');
|
||||||
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||||
await completeProfile(page);
|
await completeProfile(page, applicant);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('seafarer registration', () => {
|
test.describe('seafarer registration', () => {
|
||||||
@@ -179,9 +214,7 @@ test.describe('seafarer registration', () => {
|
|||||||
deleteApplicant(applicant.email);
|
deleteApplicant(applicant.email);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the wizard refuses to open until the profile it is built from is complete', async ({
|
test('selecting seafarer opens the registration wizard', async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const offset = await signUp(page, applicant);
|
const offset = await signUp(page, applicant);
|
||||||
await verifyOtpIfPrompted(page, offset);
|
await verifyOtpIfPrompted(page, offset);
|
||||||
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||||
@@ -190,17 +223,19 @@ test.describe('seafarer registration', () => {
|
|||||||
.first()
|
.first()
|
||||||
.check();
|
.check();
|
||||||
await page.getByRole('button', { name: /save operations/i }).click();
|
await page.getByRole('button', { name: /save operations/i }).click();
|
||||||
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
|
|
||||||
// A new account holds none of the identity the registration is filled in
|
// Straight to the form they came for. The wizard collects the identity
|
||||||
// from, so the gate collects it rather than opening an uncompletable form.
|
// itself (Identity Details), so a brand-new account with an empty profile
|
||||||
|
// is a thing it fills rather than a reason to be sent to /profile first.
|
||||||
|
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The short link lands in the same place.
|
||||||
await page.goto('/seafarer-registration');
|
await page.goto('/seafarer-registration');
|
||||||
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
|
||||||
|
timeout: 30_000,
|
||||||
// The shared wizard route is gated identically — otherwise the gate is
|
});
|
||||||
// decoration a deep link walks straight past.
|
|
||||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('opening the wizard creates the draft up front', async ({ page }) => {
|
test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||||
@@ -224,7 +259,7 @@ test.describe('seafarer registration', () => {
|
|||||||
const number = await waitForApplication(applicant.email);
|
const number = await waitForApplication(applicant.email);
|
||||||
const id = idOf(number);
|
const id = idOf(number);
|
||||||
|
|
||||||
await submit(id);
|
await submit(id, applicant);
|
||||||
await runWorkflow(id, [{ path: 'claim' }]);
|
await runWorkflow(id, [{ path: 'claim' }]);
|
||||||
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||||
|
|
||||||
@@ -248,19 +283,36 @@ test.describe('seafarer registration', () => {
|
|||||||
const number = await waitForApplication(applicant.email);
|
const number = await waitForApplication(applicant.email);
|
||||||
const id = idOf(number);
|
const id = idOf(number);
|
||||||
|
|
||||||
await submit(id);
|
await submit(id, applicant);
|
||||||
await runWorkflow(id, [
|
await runWorkflow(id, [
|
||||||
{ path: 'claim' },
|
{ path: 'claim' },
|
||||||
{
|
{
|
||||||
path: 'request-adjustment',
|
path: 'request-adjustment',
|
||||||
data: { remarks: [{ message: 'Medical certificate is illegible.' }] },
|
// `RequestAdjustmentDto` takes `items`, each naming what to fix and
|
||||||
|
// where — a bare `remarks: [{ message }]` is refused with "items should
|
||||||
|
// not be empty", which reads as an empty request rather than a wrongly
|
||||||
|
// shaped one.
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
targetType: 'FORM_SECTION',
|
||||||
|
targetKey: 'medicalCertificate',
|
||||||
|
remark: 'Medical certificate is illegible.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(statusOf(number)).toBe('RESUBMIT_REQUIRED');
|
expect(statusOf(number)).toBe('RESUBMIT_REQUIRED');
|
||||||
|
|
||||||
|
// Every flagged item has to be ticked off first: `resubmit` refuses while
|
||||||
|
// any remark is open (`unresolved_remarks`), which is what stops an
|
||||||
|
// applicant returning the same form untouched.
|
||||||
|
await resolveOpenRemarks(id, openRemarkIds(number), applicant);
|
||||||
|
|
||||||
// A resubmission returns to review directly — a registration has no
|
// A resubmission returns to review directly — a registration has no
|
||||||
// earlier stage to fall back to.
|
// earlier stage to fall back to.
|
||||||
await runWorkflow(id, [{ path: 'resubmit' }]);
|
await runWorkflow(id, [{ path: 'resubmit' }], applicant);
|
||||||
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,7 +322,7 @@ test.describe('seafarer registration', () => {
|
|||||||
const number = await waitForApplication(applicant.email);
|
const number = await waitForApplication(applicant.email);
|
||||||
const id = idOf(number);
|
const id = idOf(number);
|
||||||
|
|
||||||
await submit(id);
|
await submit(id, applicant);
|
||||||
await runWorkflow(id, [
|
await runWorkflow(id, [
|
||||||
{ path: 'claim' },
|
{ path: 'claim' },
|
||||||
{ path: 'hold', data: { reason: 'Awaiting confirmation from the clinic.' } },
|
{ path: 'hold', data: { reason: 'Awaiting confirmation from the clinic.' } },
|
||||||
@@ -288,16 +340,17 @@ test.describe('seafarer registration', () => {
|
|||||||
const number = await waitForApplication(applicant.email);
|
const number = await waitForApplication(applicant.email);
|
||||||
const id = idOf(number);
|
const id = idOf(number);
|
||||||
|
|
||||||
await submit(id);
|
await submit(id, applicant);
|
||||||
await runWorkflow(id, [
|
await runWorkflow(id, [
|
||||||
{ path: 'claim' },
|
{ path: 'claim' },
|
||||||
{ path: 'reject', data: { reason: 'Basic training evidence incomplete.' } },
|
{ path: 'reject', data: { reason: 'Basic training evidence incomplete.' } },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(statusOf(number)).toBe('REJECTED');
|
expect(statusOf(number)).toBe('REJECTED');
|
||||||
// A rejection is terminal: nothing is numbered and no children open.
|
// A rejection is terminal: nothing is numbered, and the children submit
|
||||||
|
// opened stay drafts — never filed, never billed, nothing an officer sees.
|
||||||
expect(seafarerNumberOf(applicant.email)).toBeNull();
|
expect(seafarerNumberOf(applicant.email)).toBeNull();
|
||||||
expect(childrenOf(number)).toHaveLength(0);
|
expect(childrenOf(number).every((r) => r[1] === 'DRAFT')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('approval numbers the profile and opens both child applications', async ({
|
test('approval numbers the profile and opens both child applications', async ({
|
||||||
@@ -308,7 +361,7 @@ test.describe('seafarer registration', () => {
|
|||||||
const number = await waitForApplication(applicant.email);
|
const number = await waitForApplication(applicant.email);
|
||||||
const id = idOf(number);
|
const id = idOf(number);
|
||||||
|
|
||||||
await submit(id);
|
await submit(id, applicant);
|
||||||
await approveRegistration(id);
|
await approveRegistration(id);
|
||||||
|
|
||||||
expect(statusOf(number)).toBe('COMPLETED');
|
expect(statusOf(number)).toBe('COMPLETED');
|
||||||
@@ -323,13 +376,15 @@ test.describe('seafarer registration', () => {
|
|||||||
expect(profile[0][1]).toBe('ACTIVE');
|
expect(profile[0][1]).toBe('ACTIVE');
|
||||||
|
|
||||||
// The applicant is not made to apply twice more for the documents that
|
// The applicant is not made to apply twice more for the documents that
|
||||||
// prove what they have just been told.
|
// prove what they have just been told. Both were opened as drafts when the
|
||||||
|
// registration was submitted; approval is what puts them in flight — the
|
||||||
|
// BTC straight to payment, the Seaman Book into the queue for the TRB
|
||||||
|
// inspection it still owes.
|
||||||
const children = childrenOf(number);
|
const children = childrenOf(number);
|
||||||
expect(children.map((r) => r[0])).toEqual([
|
expect(children.map((r) => [r[0], r[1]])).toEqual([
|
||||||
'BTC_BASIC_TRAINING',
|
['BTC_BASIC_TRAINING', 'PAYMENT_PENDING'],
|
||||||
'SEAMAN_BOOK',
|
['SEAMAN_BOOK', 'SUBMITTED'],
|
||||||
]);
|
]);
|
||||||
expect(children.every((r) => r[1] === 'SUBMITTED')).toBe(true);
|
|
||||||
expect(children.every((r) => r[2] === 'AUTO_SEAFARER_APPROVAL')).toBe(true);
|
expect(children.every((r) => r[2] === 'AUTO_SEAFARER_APPROVAL')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,7 +396,7 @@ test.describe('seafarer registration', () => {
|
|||||||
const number = await waitForApplication(applicant.email);
|
const number = await waitForApplication(applicant.email);
|
||||||
const id = idOf(number);
|
const id = idOf(number);
|
||||||
|
|
||||||
await submit(id);
|
await submit(id, applicant);
|
||||||
await approveRegistration(id);
|
await approveRegistration(id);
|
||||||
const first = seafarerNumberOf(applicant.email);
|
const first = seafarerNumberOf(applicant.email);
|
||||||
|
|
||||||
@@ -359,7 +414,7 @@ test.describe('seafarer registration', () => {
|
|||||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||||
const number = await waitForApplication(applicant.email);
|
const number = await waitForApplication(applicant.email);
|
||||||
|
|
||||||
await submit(idOf(number));
|
await submit(idOf(number), applicant);
|
||||||
await approveRegistration(idOf(number));
|
await approveRegistration(idOf(number));
|
||||||
|
|
||||||
// The number is permanent and the service is not renewable, so the portal
|
// The number is permanent and the service is not renewable, so the portal
|
||||||
@@ -430,6 +485,17 @@ function seafarerNumberOf(email: string): string | null {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ids of the remarks still open on the current adjustment round. */
|
||||||
|
function openRemarkIds(applicationNumber: string): string[] {
|
||||||
|
return sql(`
|
||||||
|
SELECT r.id FROM application_remarks r
|
||||||
|
JOIN license_applications a ON a.id = r.application_id
|
||||||
|
WHERE a.application_number = '${applicationNumber}'
|
||||||
|
AND r.is_resolved = false
|
||||||
|
AND r.round_number = a.adjustment_round
|
||||||
|
`).map((row) => row[0]);
|
||||||
|
}
|
||||||
|
|
||||||
function childrenOf(applicationNumber: string): string[][] {
|
function childrenOf(applicationNumber: string): string[][] {
|
||||||
return sql(`
|
return sql(`
|
||||||
SELECT lt.key, a.status, a.origin
|
SELECT lt.key, a.status, a.origin
|
||||||
@@ -444,12 +510,98 @@ function childrenOf(applicationNumber: string): string[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submits the draft.
|
* Fills the draft's answers and evidence directly, so it can be submitted.
|
||||||
*
|
*
|
||||||
* The wizard's own sections are not filled in: what these tests are about is
|
* These tests are about the workflow and its approval effects, not the wizard's
|
||||||
* the workflow and its approval effects, and a form-validation failure would
|
* fields — but `submit` validates the whole form and every required document, so
|
||||||
* fail them for the wrong reason. Field-level rules belong in their own spec.
|
* an unfilled draft cannot reach the workflow at all. Driving six wizard steps
|
||||||
|
* and four uploads in each test would make them slow tests of the form instead.
|
||||||
|
*
|
||||||
|
* So the answers go in as one `form_data` write and the evidence as attachment
|
||||||
|
* rows. Deliberately not through MinIO: `getSuppliedDocumentKeys` joins
|
||||||
|
* attachments to their files and counts document keys, and nothing at submission
|
||||||
|
* reads a file's bytes — a row with a storage key is exactly as complete as an
|
||||||
|
* upload, without requiring object storage to be reachable.
|
||||||
|
*
|
||||||
|
* Values mirror the seeded schema (`seafarer-registration.seed-data.ts`); a
|
||||||
|
* required field added there fails these with `application_incomplete`, naming
|
||||||
|
* the field.
|
||||||
*/
|
*/
|
||||||
async function submit(applicationId: string): Promise<void> {
|
function fillForSubmission(applicationId: string): void {
|
||||||
await runWorkflow(applicationId, [{ path: 'submit' }]);
|
const locationId = sqlValue(`
|
||||||
|
SELECT l.id FROM iam.locations l
|
||||||
|
JOIN iam.location_types lt ON lt.id = l.location_type_id
|
||||||
|
WHERE lt.code = 'SUBCITY' LIMIT 1
|
||||||
|
`);
|
||||||
|
if (!locationId) {
|
||||||
|
throw new Error('No SUBCITY location seeded — run the location seed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = JSON.stringify({
|
||||||
|
profileSummary: {
|
||||||
|
firstName: 'Dawit',
|
||||||
|
middleName: 'Bekele',
|
||||||
|
lastName: 'Tesfaye',
|
||||||
|
gender: 'MALE',
|
||||||
|
dateOfBirth: '1995-04-12',
|
||||||
|
maritalStatus: 'SINGLE',
|
||||||
|
nationality: 'Ethiopian',
|
||||||
|
nationalIdNumber: 'FYD1234567890',
|
||||||
|
},
|
||||||
|
identity: { placeOfBirth: 'Addis Ababa', department: 'DECK' },
|
||||||
|
address: { locationId, permanentAddress: 'Bole, Addis Ababa' },
|
||||||
|
emergencyContact: {
|
||||||
|
name: 'Almaz Tesfaye',
|
||||||
|
relationship: 'Sister',
|
||||||
|
phoneNumber: '+251911222333',
|
||||||
|
},
|
||||||
|
physicalCharacteristics: {
|
||||||
|
hairColor: 'BLACK',
|
||||||
|
eyeColor: 'BROWN',
|
||||||
|
heightCm: 172,
|
||||||
|
weightKg: 68,
|
||||||
|
bloodType: 'O_POSITIVE',
|
||||||
|
},
|
||||||
|
medicalCertificate: {
|
||||||
|
certificateNumber: 'MED-2026-001',
|
||||||
|
issuerName: 'Addis Marine Clinic',
|
||||||
|
issueDate: '2026-01-15',
|
||||||
|
},
|
||||||
|
declaration: { accepted: true },
|
||||||
|
}).replace(/'/g, "''");
|
||||||
|
|
||||||
|
const documentKeys = [
|
||||||
|
'photo',
|
||||||
|
'nationalId',
|
||||||
|
'medical_certificate',
|
||||||
|
'basic_training_evidence',
|
||||||
|
];
|
||||||
|
|
||||||
|
sql(`
|
||||||
|
UPDATE license_applications
|
||||||
|
SET form_data = '${formData}'::jsonb
|
||||||
|
WHERE id = '${applicationId}';
|
||||||
|
|
||||||
|
WITH inserted AS (
|
||||||
|
INSERT INTO attachments (owner_type, owner_id, document_key, valid_from, valid_to)
|
||||||
|
SELECT 'APPLICATION', '${applicationId}', key, CURRENT_DATE, CURRENT_DATE + 365
|
||||||
|
FROM unnest(ARRAY[${documentKeys.map((d) => `'${d}'`).join(',')}]) AS key
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO attachment_files
|
||||||
|
(attachment_id, original_name, mime_type, size_bytes, storage_key)
|
||||||
|
SELECT id, 'evidence.pdf', 'application/pdf', 1024, 'e2e/' || id || '.pdf'
|
||||||
|
FROM inserted;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills what submission requires, then submits as the applicant. */
|
||||||
|
async function submit(
|
||||||
|
applicationId: string,
|
||||||
|
applicant: Applicant,
|
||||||
|
): Promise<void> {
|
||||||
|
fillForSubmission(applicationId);
|
||||||
|
// As the applicant: `submit` is ownership-guarded, so the officer's token —
|
||||||
|
// which every other step here uses — is refused with `not_application_owner`.
|
||||||
|
await runWorkflow(applicationId, [{ path: 'submit' }], applicant);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import { E2E } from '../../playwright.config';
|
|||||||
|
|
||||||
const OTP_PATTERN = /is (\d{4,8})\./g;
|
const OTP_PATTERN = /is (\d{4,8})\./g;
|
||||||
|
|
||||||
/** Byte offset to read from later. Zero when the log does not exist yet. */
|
/**
|
||||||
|
* Byte offset to read from later. Zero when the log does not exist yet.
|
||||||
|
*
|
||||||
|
* Bytes, and read back as bytes — see `otpSince`.
|
||||||
|
*/
|
||||||
export function logOffset(): number {
|
export function logOffset(): number {
|
||||||
try {
|
try {
|
||||||
return statSync(E2E.apiLog).size;
|
return statSync(E2E.apiLog).size;
|
||||||
@@ -48,7 +52,14 @@ export async function waitForOtp(
|
|||||||
function otpSince(offset: number): string | null {
|
function otpSince(offset: number): string | null {
|
||||||
let text: string;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
text = readFileSync(E2E.apiLog, 'utf8').slice(offset);
|
// Sliced as a Buffer, then decoded — not `readFileSync(…, 'utf8').slice()`.
|
||||||
|
// `logOffset()` is a byte count from `statSync`, while slicing a string
|
||||||
|
// counts UTF-16 code units, and the API logs Amharic notification bodies:
|
||||||
|
// every multi-byte character made the offset overshoot, so a code written
|
||||||
|
// just after it was skipped and the wait timed out. The drift grows with
|
||||||
|
// the log, which is why this failed intermittently and more often later in
|
||||||
|
// a run.
|
||||||
|
text = readFileSync(E2E.apiLog).subarray(offset).toString('utf8');
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,18 +14,35 @@ export interface Applicant {
|
|||||||
username: string;
|
username: string;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
/** The account name, as typed at signup. Always `${firstName} ${middleName} ${lastName}`. */
|
||||||
name: string;
|
name: string;
|
||||||
|
firstName: string;
|
||||||
|
middleName: string;
|
||||||
|
lastName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newApplicant(label: string): Applicant {
|
export function newApplicant(label: string): Applicant {
|
||||||
const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||||
|
// The profile's Maritime tab refuses to save unless first/middle/last join to
|
||||||
|
// exactly the account name (`ProfilePage.onSaveProfile`) — and that refusal is
|
||||||
|
// a silent early return, no request. So the parts are the source of truth here
|
||||||
|
// and the account name is composed from them, rather than the two being
|
||||||
|
// written independently and hoped to agree.
|
||||||
|
//
|
||||||
|
// Each part is at least three characters, which `profileSchema` requires.
|
||||||
|
const firstName = 'Dawit';
|
||||||
|
const middleName = 'Bekele';
|
||||||
|
const lastName = `Tesfaye${stamp.slice(-4)}`;
|
||||||
return {
|
return {
|
||||||
email: `e2e.${label}.${stamp}@example.test`,
|
email: `e2e.${label}.${stamp}@example.test`,
|
||||||
username: `e2e${label}${stamp}`.slice(0, 28),
|
username: `e2e${label}${stamp}`.slice(0, 28),
|
||||||
// Ethiopian mobile format; the last digits vary so two runs never collide.
|
// Ethiopian mobile format; the last digits vary so two runs never collide.
|
||||||
phoneNumber: `+2519${stamp.slice(-8)}`,
|
phoneNumber: `+2519${stamp.slice(-8)}`,
|
||||||
password: 'E2ePassw0rd!',
|
password: 'E2ePassw0rd!',
|
||||||
name: `E2E ${label} ${stamp.slice(-4)}`,
|
name: `${firstName} ${middleName} ${lastName}`,
|
||||||
|
firstName,
|
||||||
|
middleName,
|
||||||
|
lastName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,47 @@ import { OFFICER } from './officer';
|
|||||||
/** Routes served by the applicant-facing controller rather than the review one. */
|
/** Routes served by the applicant-facing controller rather than the review one. */
|
||||||
const APPLICANT_STEPS = new Set(['submit', 'resubmit']);
|
const APPLICANT_STEPS = new Set(['submit', 'resubmit']);
|
||||||
|
|
||||||
async function officerContext(): Promise<APIRequestContext> {
|
/**
|
||||||
const context = await request.newContext({ baseURL: E2E.apiUrl });
|
* Resolves every open remark on an application, as the applicant.
|
||||||
const response = await context.post('/auth/login', {
|
*
|
||||||
data: { email: OFFICER.email, password: OFFICER.password },
|
* `resubmit` refuses while any remain (`unresolved_remarks`) — the applicant is
|
||||||
|
* expected to tick off each correction as they make it, which the portal does
|
||||||
|
* per section. A test that only wants the round-trip still has to do it.
|
||||||
|
*/
|
||||||
|
export async function resolveOpenRemarks(
|
||||||
|
applicationId: string,
|
||||||
|
remarkIds: string[],
|
||||||
|
applicant: { email: string; password: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await runWorkflow(
|
||||||
|
applicationId,
|
||||||
|
remarkIds.map((remarkId) => ({
|
||||||
|
path: `remarks/${remarkId}/resolve`,
|
||||||
|
method: 'patch' as const,
|
||||||
|
})),
|
||||||
|
applicant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An authenticated API context for one account.
|
||||||
|
*
|
||||||
|
* Paths built against it are relative on purpose. `E2E.apiUrl` carries the
|
||||||
|
* `/api` prefix, and a leading slash resolves against the *origin* —
|
||||||
|
* `/auth/login` against `http://host/api` requests `http://host/auth/login`,
|
||||||
|
* which 404s. Every path in this file is therefore written without one.
|
||||||
|
*/
|
||||||
|
async function contextFor(
|
||||||
|
who: string,
|
||||||
|
credentials: { email: string; password: string },
|
||||||
|
): Promise<APIRequestContext> {
|
||||||
|
const context = await request.newContext({ baseURL: `${E2E.apiUrl}/` });
|
||||||
|
const response = await context.post('auth/login', {
|
||||||
|
data: { email: credentials.email, password: credentials.password },
|
||||||
});
|
});
|
||||||
if (!response.ok()) {
|
if (!response.ok()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Officer login failed (${response.status()}): ${await response.text()}`,
|
`${who} login failed (${response.status()}): ${await response.text()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
@@ -36,17 +69,23 @@ async function officerContext(): Promise<APIRequestContext> {
|
|||||||
|
|
||||||
await context.dispose();
|
await context.dispose();
|
||||||
return request.newContext({
|
return request.newContext({
|
||||||
baseURL: E2E.apiUrl,
|
baseURL: `${E2E.apiUrl}/`,
|
||||||
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function officerContext(): Promise<APIRequestContext> {
|
||||||
|
return contextFor('Officer', OFFICER);
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkflowStep {
|
export interface WorkflowStep {
|
||||||
/** Route under the review controller, e.g. `claim`, `final-approve`. */
|
/** Route under the review controller, e.g. `claim`, `final-approve`. */
|
||||||
path: string;
|
path: string;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
/** Set when a step is expected to be refused — the refusal is the assertion. */
|
/** Set when a step is expected to be refused — the refusal is the assertion. */
|
||||||
expectFailure?: boolean;
|
expectFailure?: boolean;
|
||||||
|
/** POST unless stated; the applicant's remark-resolve route is a PATCH. */
|
||||||
|
method?: 'post' | 'patch';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,8 +98,29 @@ export interface WorkflowStep {
|
|||||||
export async function runWorkflow(
|
export async function runWorkflow(
|
||||||
applicationId: string,
|
applicationId: string,
|
||||||
steps: WorkflowStep[],
|
steps: WorkflowStep[],
|
||||||
|
/**
|
||||||
|
* The owner, required only when a step is applicant-side. `submit` and
|
||||||
|
* `resubmit` are guarded by ownership, not permission — the officer holds
|
||||||
|
* every permission but is not the applicant, so running them on the officer's
|
||||||
|
* token is refused with `not_application_owner`.
|
||||||
|
*/
|
||||||
|
applicant?: { email: string; password: string },
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
const api = await officerContext();
|
const officer = await officerContext();
|
||||||
|
const needsApplicant = steps.some(
|
||||||
|
(step) => APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/'),
|
||||||
|
);
|
||||||
|
if (needsApplicant && !applicant) {
|
||||||
|
throw new Error(
|
||||||
|
`Steps [${steps
|
||||||
|
.filter((s) => APPLICANT_STEPS.has(s.path) || s.path.startsWith('remarks/'))
|
||||||
|
.map((s) => s.path)
|
||||||
|
.join(', ')}] act as the applicant — pass their credentials to runWorkflow.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const owner = needsApplicant && applicant
|
||||||
|
? await contextFor('Applicant', applicant)
|
||||||
|
: null;
|
||||||
const codes: number[] = [];
|
const codes: number[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -68,13 +128,17 @@ export async function runWorkflow(
|
|||||||
// Applicant-side actions (`submit`, `resubmit`) live on the
|
// Applicant-side actions (`submit`, `resubmit`) live on the
|
||||||
// applications controller; everything an officer does is on the review
|
// applications controller; everything an officer does is on the review
|
||||||
// controller. Routing by step keeps callers from having to know.
|
// controller. Routing by step keeps callers from having to know.
|
||||||
const base = APPLICANT_STEPS.has(step.path)
|
const isApplicantStep =
|
||||||
|
APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/');
|
||||||
|
const base = isApplicantStep
|
||||||
? 'license-applications'
|
? 'license-applications'
|
||||||
: 'license-application-review';
|
: 'license-application-review';
|
||||||
const response = await api.post(
|
const api = isApplicantStep && owner ? owner : officer;
|
||||||
`/${base}/${applicationId}/${step.path}`,
|
const url = `${base}/${applicationId}/${step.path}`;
|
||||||
{ data: step.data ?? {} },
|
const response =
|
||||||
);
|
step.method === 'patch'
|
||||||
|
? await api.patch(url, { data: step.data ?? {} })
|
||||||
|
: await api.post(url, { data: step.data ?? {} });
|
||||||
codes.push(response.status());
|
codes.push(response.status());
|
||||||
|
|
||||||
if (!step.expectFailure && !response.ok()) {
|
if (!step.expectFailure && !response.ok()) {
|
||||||
@@ -84,7 +148,8 @@ export async function runWorkflow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await api.dispose();
|
await officer.dispose();
|
||||||
|
await owner?.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
return codes;
|
return codes;
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { Divider, Paper, Stack, Table, Text, Title } from "@mantine/core";
|
import {
|
||||||
|
Badge,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
conditionHolds,
|
conditionHolds,
|
||||||
|
displayFieldValue,
|
||||||
type Attachment,
|
type Attachment,
|
||||||
|
type FormFieldConfig,
|
||||||
type FormSectionConfig,
|
type FormSectionConfig,
|
||||||
type LicenseTypeRequirements,
|
type LicenseTypeRequirements,
|
||||||
} from "@ema-platform/api";
|
} from "@ema-platform/api";
|
||||||
|
import { useDateDisplayer } from "@ema-platform/shared";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { DocumentSlots } from "./DocumentSlots";
|
import { DocumentSlots } from "./DocumentSlots";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,42 +46,83 @@ export function ApplicationSummary({
|
|||||||
attachments,
|
attachments,
|
||||||
applicationId,
|
applicationId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
const showDate = useDateDisplayer();
|
||||||
<Paper withBorder p="lg" radius="md">
|
const { i18n } = useTranslation();
|
||||||
<Stack gap="lg">
|
|
||||||
{sections.map((section) => (
|
|
||||||
<div key={section.key}>
|
|
||||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
|
||||||
{localized(section.title)}
|
|
||||||
</Text>
|
|
||||||
<Table withTableBorder withColumnBorders>
|
|
||||||
<Table.Tbody>
|
|
||||||
{(section.fields ?? [])
|
|
||||||
.filter((f) => conditionHolds(f.showWhen, formData))
|
|
||||||
.map((field) => (
|
|
||||||
<Table.Tr key={field.key}>
|
|
||||||
<Table.Td w="45%">
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{localized(field.label)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm">
|
|
||||||
{String(formData[section.key]?.[field.key] ?? "—")}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div>
|
// Shared with the officer's review screen, so the applicant and the reviewer
|
||||||
<Divider mb="md" />
|
// never read the same answer two different ways.
|
||||||
|
const display = (field: FormFieldConfig, raw: unknown) =>
|
||||||
|
displayFieldValue(field, raw, {
|
||||||
|
language: i18n.language,
|
||||||
|
showDate,
|
||||||
|
currency: config.feeCurrency,
|
||||||
|
}) || "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{sections.map((section) => {
|
||||||
|
const fields = (section.fields ?? []).filter((f) =>
|
||||||
|
conditionHolds(f.showWhen, formData),
|
||||||
|
);
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="lg" radius="md" key={section.key}>
|
||||||
|
<Group justify="space-between" align="center" mb="xs">
|
||||||
|
<Title order={5}>{localized(section.title)}</Title>
|
||||||
|
<Badge variant="light" color="gray" size="sm">
|
||||||
|
{fields.length} {fields.length === 1 ? "detail" : "details"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
{localized(section.description) && (
|
||||||
|
<Text fz="xs" c="dimmed" mb="sm">
|
||||||
|
{localized(section.description)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Divider mb="md" />
|
||||||
|
|
||||||
|
{/* Label above value in two columns — a definition list reads far
|
||||||
|
better than a bordered grid when most answers are short. */}
|
||||||
|
<Grid gutter="md">
|
||||||
|
{fields.map((field) => {
|
||||||
|
const value = display(
|
||||||
|
field,
|
||||||
|
formData[section.key]?.[field.key],
|
||||||
|
);
|
||||||
|
const answered = value !== "—";
|
||||||
|
return (
|
||||||
|
<Grid.Col
|
||||||
|
span={{
|
||||||
|
base: 12,
|
||||||
|
sm: field.type === "TEXTAREA" ? 12 : 6,
|
||||||
|
}}
|
||||||
|
key={field.key}
|
||||||
|
>
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||||
|
{localized(field.label)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fz="sm"
|
||||||
|
mt={2}
|
||||||
|
c={answered ? undefined : "dimmed"}
|
||||||
|
fs={answered ? undefined : "italic"}
|
||||||
|
style={{ wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{answered ? value : "Not provided"}
|
||||||
|
</Text>
|
||||||
|
</Grid.Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Paper withBorder p="lg" radius="md">
|
||||||
<Title order={5} mb="sm">
|
<Title order={5} mb="sm">
|
||||||
Documents
|
Documents
|
||||||
</Title>
|
</Title>
|
||||||
|
<Divider mb="md" />
|
||||||
<DocumentSlots
|
<DocumentSlots
|
||||||
requirements={config.documentRequirements}
|
requirements={config.documentRequirements}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
@@ -81,8 +135,7 @@ export function ApplicationSummary({
|
|||||||
// requires the callback.
|
// requires the callback.
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Grid,
|
Grid,
|
||||||
|
Input,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Select,
|
Select,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
section: FormSectionConfig;
|
section: FormSectionConfig;
|
||||||
@@ -129,10 +131,32 @@ export function ConfigDrivenSection({
|
|||||||
// own vessel register, so this overrides whatever type the backend
|
// own vessel register, so this overrides whatever type the backend
|
||||||
// configured, the same way nationality overrides SELECT above.
|
// configured, the same way nationality overrides SELECT above.
|
||||||
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(labelEn.trim());
|
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(labelEn.trim());
|
||||||
|
// Stores a location-tree uuid, so it needs the cascading picker the
|
||||||
|
// profile's Address tab uses — configured as TEXT because the field
|
||||||
|
// types have no LOCATION member, which left a required field asking
|
||||||
|
// the applicant to type a uuid by hand.
|
||||||
|
const isLocation = field.key === 'locationId' || labelEn.trim() === 'location';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
|
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
|
||||||
{isNationality ? (
|
{isLocation ? (
|
||||||
|
// LocationPicker renders its own cascade of Selects and takes no
|
||||||
|
// label/error props, so the wrapper supplies them.
|
||||||
|
<Input.Wrapper
|
||||||
|
label={label}
|
||||||
|
description={localized(field.helpText) || undefined}
|
||||||
|
withAsterisk={field.required}
|
||||||
|
error={error}
|
||||||
|
>
|
||||||
|
<LocationPicker
|
||||||
|
value={(value as string) ?? undefined}
|
||||||
|
onChange={(id) => onChange(field.key, id)}
|
||||||
|
required={field.required}
|
||||||
|
maxDepth={3}
|
||||||
|
disabled={common.disabled}
|
||||||
|
/>
|
||||||
|
</Input.Wrapper>
|
||||||
|
) : isNationality ? (
|
||||||
<CountrySelect
|
<CountrySelect
|
||||||
{...common}
|
{...common}
|
||||||
demonym
|
demonym
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
|
IconPencil,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
@@ -53,7 +54,12 @@ import {
|
|||||||
type ValidationIssue,
|
type ValidationIssue,
|
||||||
type Vessel,
|
type Vessel,
|
||||||
} from "@ema-platform/api";
|
} from "@ema-platform/api";
|
||||||
import { getCountryCode, getCountryName, ModalFooter } from "@ema-platform/ui";
|
import {
|
||||||
|
getCountryCode,
|
||||||
|
getCountryName,
|
||||||
|
ModalFooter,
|
||||||
|
splitPersonName,
|
||||||
|
} from "@ema-platform/ui";
|
||||||
import {
|
import {
|
||||||
LICENSE_PERMISSIONS,
|
LICENSE_PERMISSIONS,
|
||||||
PORTAL_PERMISSIONS,
|
PORTAL_PERMISSIONS,
|
||||||
@@ -67,9 +73,13 @@ import {
|
|||||||
} from "../components/ConfigDrivenSection";
|
} from "../components/ConfigDrivenSection";
|
||||||
import { DocumentSlots } from "../components/DocumentSlots";
|
import { DocumentSlots } from "../components/DocumentSlots";
|
||||||
import { StaffEvidence } from "../components/StaffEvidence";
|
import { StaffEvidence } from "../components/StaffEvidence";
|
||||||
|
import { useAppSelector } from "../../../store/hooks";
|
||||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||||
/** Resolves a dot path (e.g. "profile.address.nationality") against a plain object. */
|
/** Resolves a dot path (e.g. "profile.address.nationality") against a plain object. */
|
||||||
function readSourcePath(context: Record<string, unknown>, path: string): unknown {
|
function readSourcePath(
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
path: string,
|
||||||
|
): unknown {
|
||||||
return path
|
return path
|
||||||
.split(".")
|
.split(".")
|
||||||
.reduce<unknown>(
|
.reduce<unknown>(
|
||||||
@@ -81,6 +91,15 @@ function readSourcePath(context: Record<string, unknown>, path: string): unknown
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Name fields predate the generic `source` metadata in some persisted form
|
||||||
|
// schemas. Keep their profile mapping here so existing applications/configs
|
||||||
|
// receive the same prefill as newly seeded schemas.
|
||||||
|
const LEGACY_PROFILE_SOURCES: Record<string, string> = {
|
||||||
|
firstName: "profile.firstName",
|
||||||
|
middleName: "profile.middleName",
|
||||||
|
lastName: "profile.lastName",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The applicant wizard, rendered entirely from the license type's
|
* The applicant wizard, rendered entirely from the license type's
|
||||||
* configuration. The same page serves every license type — the route's
|
* configuration. The same page serves every license type — the route's
|
||||||
@@ -91,6 +110,7 @@ export function LicenseApplicationPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
|
const accountUser = useAppSelector((state) => state.auth.user);
|
||||||
|
|
||||||
const { data: config, isLoading: loadingConfig } =
|
const { data: config, isLoading: loadingConfig } =
|
||||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||||
@@ -103,6 +123,9 @@ export function LicenseApplicationPage() {
|
|||||||
|
|
||||||
// Create (or resume) the draft up front, so uploads have a real owner to
|
// Create (or resume) the draft up front, so uploads have a real owner to
|
||||||
// attach to and nothing is lost if the browser is closed mid-wizard.
|
// attach to and nothing is lost if the browser is closed mid-wizard.
|
||||||
|
// For a one-shot registration (e.g. seafarer) already submitted or further
|
||||||
|
// along, the API returns that existing application instead of a new draft —
|
||||||
|
// "Apply" reopens it rather than erroring, the same way it reopens a DRAFT.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appId || !config) return;
|
if (appId || !config) return;
|
||||||
createApplication({ licenseType: typeCode })
|
createApplication({ licenseType: typeCode })
|
||||||
@@ -225,32 +248,65 @@ export function LicenseApplicationPage() {
|
|||||||
detail?.application?.formData,
|
detail?.application?.formData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Generic fill for every field the config marks `readOnly` with a
|
// Generic fill for every field the config gives a `source` — the profile
|
||||||
// `source` — e.g. seafarer registration's read-only Identity Details step,
|
// value the applicant would otherwise retype. Seafarer registration's
|
||||||
// which shows what's already on the profile instead of asking again.
|
// Identity Details step is the case that drives this: it collects name,
|
||||||
// `readOnly` fields are never sent by the applicant and the server skips
|
// gender, DOB and national ID *in the wizard* rather than sending the
|
||||||
// them at validation, so this is display-only; the profile itself is what
|
// applicant to `/profile` first, so those fields are editable and this is a
|
||||||
// an edit has to go through.
|
// prefill, not a display.
|
||||||
|
//
|
||||||
|
// Editable sourced fields are filled only while still blank. Re-running
|
||||||
|
// this effect (a refetched profile, a saved draft) must not overwrite what
|
||||||
|
// the applicant has since typed — for a `readOnly` field the profile stays
|
||||||
|
// authoritative, so those keep tracking it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!profile || !config) return;
|
if (!profile || !config) return;
|
||||||
const context = { user: profile.user, profile };
|
// `profile.firstName/middleName/lastName` stay blank until the applicant
|
||||||
|
// saves the Maritime Profile tab once — a fresh signup arrives here
|
||||||
|
// without ever having done that. Fall back to splitting the account's
|
||||||
|
// `name.en` (the same name signup collected) so this step still
|
||||||
|
// prefills instead of opening blank.
|
||||||
|
const accountName = accountUser?.name ?? profile.user?.name;
|
||||||
|
const nameFallback = accountName?.en
|
||||||
|
? splitPersonName(accountName.en)
|
||||||
|
: null;
|
||||||
|
const context = {
|
||||||
|
user: accountUser ?? profile.user,
|
||||||
|
profile: {
|
||||||
|
...profile,
|
||||||
|
firstName: profile.firstName || nameFallback?.firstName || "",
|
||||||
|
middleName: profile.middleName || nameFallback?.middleName || "",
|
||||||
|
lastName: profile.lastName || nameFallback?.lastName || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const section of config.licenseType.formSchema.sections) {
|
for (const section of config.licenseType.formSchema.sections) {
|
||||||
for (const field of section.fields) {
|
for (const field of section.fields) {
|
||||||
if (!field.readOnly || !field.source) continue;
|
const source = field.source ?? LEGACY_PROFILE_SOURCES[field.key];
|
||||||
const value = readSourcePath(context, field.source);
|
if (!source) continue;
|
||||||
|
const current = next[section.key]?.[field.key];
|
||||||
|
const untouched =
|
||||||
|
current === undefined || current === null || current === "";
|
||||||
|
if (!field.readOnly && !untouched) continue;
|
||||||
|
const value = readSourcePath(context, source);
|
||||||
if (value === undefined || value === null || value === "") continue;
|
if (value === undefined || value === null || value === "") continue;
|
||||||
if (next[section.key]?.[field.key] === value) continue;
|
if (current === value) continue;
|
||||||
next[section.key] = { ...next[section.key], [field.key]: value };
|
next[section.key] = { ...next[section.key], [field.key]: value };
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
}, [profile, config, detail?.application?.id, detail?.application?.formData]);
|
}, [
|
||||||
|
profile,
|
||||||
|
accountUser,
|
||||||
|
config,
|
||||||
|
detail?.application?.id,
|
||||||
|
detail?.application?.formData,
|
||||||
|
]);
|
||||||
|
|
||||||
const application = detail?.application;
|
const application = detail?.application;
|
||||||
const isAdjusting = application?.status === "RESUBMIT_REQUIRED";
|
const isAdjusting = application?.status === "RESUBMIT_REQUIRED";
|
||||||
@@ -294,7 +350,16 @@ export function LicenseApplicationPage() {
|
|||||||
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
|
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const readOnly = !["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status);
|
// A submitted application stays editable until an officer takes it, which
|
||||||
|
// mirrors the server's own rule (`assertEditable`): an applicant who spots
|
||||||
|
// their own mistake can fix it instead of waiting to be sent back for it.
|
||||||
|
// Once claimed it locks — the officer reading it must not have the form move
|
||||||
|
// underneath them.
|
||||||
|
const editableWhileSubmitted =
|
||||||
|
application.status === "SUBMITTED" && !application.assignedOfficerId;
|
||||||
|
const readOnly =
|
||||||
|
!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
|
||||||
|
!editableWhileSubmitted;
|
||||||
// A DRAFT has nothing worth summarising yet, so it always opens straight
|
// A DRAFT has nothing worth summarising yet, so it always opens straight
|
||||||
// into the wizard; every later status (including RESUBMIT_REQUIRED) opens
|
// into the wizard; every later status (including RESUBMIT_REQUIRED) opens
|
||||||
// to the summary first.
|
// to the summary first.
|
||||||
@@ -382,6 +447,7 @@ export function LicenseApplicationPage() {
|
|||||||
title: "Resubmitted",
|
title: "Resubmitted",
|
||||||
message: "Your corrections were sent back to the reviewing officer.",
|
message: "Your corrections were sent back to the reviewing officer.",
|
||||||
});
|
});
|
||||||
|
navigate("/licensing/applications");
|
||||||
} else {
|
} else {
|
||||||
await submitApplication(appId as string).unwrap();
|
await submitApplication(appId as string).unwrap();
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -389,8 +455,12 @@ export function LicenseApplicationPage() {
|
|||||||
title: "Application submitted",
|
title: "Application submitted",
|
||||||
message: "You will be notified as it progresses.",
|
message: "You will be notified as it progresses.",
|
||||||
});
|
});
|
||||||
|
// Stays on the application rather than dropping the applicant into a
|
||||||
|
// list: they have just filled a long form and the useful next screen is
|
||||||
|
// what they submitted, with its status and — while it is still
|
||||||
|
// unclaimed — the means to correct it.
|
||||||
|
setViewingSummary(true);
|
||||||
}
|
}
|
||||||
navigate("/licensing/applications");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const found = extractValidationIssues(err);
|
const found = extractValidationIssues(err);
|
||||||
setIssues(found);
|
setIssues(found);
|
||||||
@@ -568,10 +638,11 @@ export function LicenseApplicationPage() {
|
|||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Fee: {config.fee ?? "—"} {config.feeCurrency}
|
Fee: {config.fee ?? "—"} {config.feeCurrency}
|
||||||
</Text>
|
</Text>
|
||||||
{showSummary && isAdjusting && (
|
{showSummary && !readOnly && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
leftSection={<IconPencil size={14} />}
|
||||||
onClick={() => setViewingSummary(false)}
|
onClick={() => setViewingSummary(false)}
|
||||||
>
|
>
|
||||||
Edit details
|
Edit details
|
||||||
@@ -600,6 +671,19 @@ export function LicenseApplicationPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showSummary && editableWhileSubmitted && (
|
||||||
|
<Alert
|
||||||
|
color="blue"
|
||||||
|
icon={<IconInfoCircle size={16} />}
|
||||||
|
title="Submitted — still correctable"
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
Your application is in the queue. You can still change any detail
|
||||||
|
until a reviewing officer picks it up; after that, corrections happen
|
||||||
|
only if they ask for them.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{issues.length > 0 && (
|
{issues.length > 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
color="red"
|
color="red"
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ interface LocationPickerProps {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
/** Caps how many cascading levels render (e.g. 3 = City / Sub-city / Woreda only). */
|
/** Caps how many cascading levels render (e.g. 3 = City / Sub-city / Woreda only). */
|
||||||
maxDepth?: number;
|
maxDepth?: number;
|
||||||
|
/** Locks every level — a submitted application, or a section under review. */
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth }: LocationPickerProps) {
|
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth, disabled }: LocationPickerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
const { data: typesRes, isLoading: typesLoading, isError: typesError, refetch: refetchTypes } = useGetLocationTypesQuery();
|
const { data: typesRes, isLoading: typesLoading, isError: typesError, refetch: refetchTypes } = useGetLocationTypesQuery();
|
||||||
@@ -204,7 +206,9 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
|
|||||||
{levels.map((levelIdx) => {
|
{levels.map((levelIdx) => {
|
||||||
const options = buildOptions(levelIdx);
|
const options = buildOptions(levelIdx);
|
||||||
const currentValue = selectedChain[levelIdx]?.id ?? null;
|
const currentValue = selectedChain[levelIdx]?.id ?? null;
|
||||||
const isDisabled = levelIdx > 0 && !selectedChain[levelIdx - 1];
|
// Either the whole picker is locked, or this level has no parent
|
||||||
|
// choice yet to narrow it.
|
||||||
|
const isDisabled = disabled || (levelIdx > 0 && !selectedChain[levelIdx - 1]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
export interface NamePair {
|
/**
|
||||||
en: string;
|
* Re-exported from the shared contract so both apps read one definition.
|
||||||
am: string;
|
*
|
||||||
}
|
* The portal and backoffice each kept their own copy of this model and drifted:
|
||||||
|
* the two `Location` shapes disagreed on `locationType`/`children`/timestamps,
|
||||||
|
* and `NamePair` dropped the `om`/`so` names the backend stores. Importers keep
|
||||||
|
* this path; the model itself now lives in `@ema-platform/api`.
|
||||||
|
*/
|
||||||
|
export type {
|
||||||
|
Location,
|
||||||
|
LocationType,
|
||||||
|
ListResponse,
|
||||||
|
} from '@ema-platform/api';
|
||||||
|
|
||||||
export interface LocationType {
|
/** @deprecated Use `Bilingual` from `@ema-platform/api` — it carries om/so too. */
|
||||||
id: string;
|
export type { Bilingual as NamePair } from '@ema-platform/api';
|
||||||
code: string;
|
|
||||||
names: NamePair;
|
|
||||||
level: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Location {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
names: NamePair;
|
|
||||||
locationTypeId: string;
|
|
||||||
parentId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListResponse<T> {
|
|
||||||
count: number;
|
|
||||||
items: T[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { OperationsFormContent } from '../../profile/components/OperationsFormCo
|
|||||||
* where the catalogue offers it.
|
* where the catalogue offers it.
|
||||||
*/
|
*/
|
||||||
const NEXT_STEP: Record<string, string> = {
|
const NEXT_STEP: Record<string, string> = {
|
||||||
SEAFARER_REGISTRATION: '/profile',
|
SEAFARER_REGISTRATION: '/licensing/SEAFARER_REGISTRATION/apply',
|
||||||
VESSEL_REGISTRATION: '/vessel-registration',
|
VESSEL_REGISTRATION: '/vessel-registration',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,9 @@ export function AddressFormContent({
|
|||||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||||
{t('profileAddress.addressSection')}
|
{t('profileAddress.addressSection')}
|
||||||
</Text>
|
</Text>
|
||||||
{/* City / Sub-city / Woreda only — no Kebele level, kebeleId mirrors woredaId. */}
|
{/* City / Sub-city / Woreda — the picker's depth. Kebele is a seeded
|
||||||
|
level but nothing collects it, so `kebeleId` stays unset rather than
|
||||||
|
borrowing the woreda's id. */}
|
||||||
<LocationPicker value={leafId} onChainChange={handleChainChange} maxDepth={3} />
|
<LocationPicker value={leafId} onChainChange={handleChainChange} maxDepth={3} />
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -1,38 +1,30 @@
|
|||||||
import { useEffect } from 'react';
|
import { Center, Loader } from "@mantine/core";
|
||||||
import { Navigate, useLocation, useParams } from 'react-router-dom';
|
import { Navigate, useParams } from "react-router-dom";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useCurrentProfile, type ProfileRequirement } from "@ema-platform/auth";
|
||||||
import { PageLoader, notify } from '@ema-platform/ui';
|
import { useGetMyApplicationsQuery } from "@ema-platform/api";
|
||||||
import {
|
|
||||||
PROFILE_FIELD_SECTION,
|
|
||||||
useCurrentProfile,
|
|
||||||
type ProfileRequirement,
|
|
||||||
} from '@ema-platform/auth';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seafarer registration is filled in from the profile (nationality, ID,
|
* The identity the seafarer wizard needs before it can produce a registration.
|
||||||
* names, contact details) — the server refuses an application missing them,
|
|
||||||
* so they're asked for up front instead of at submit time.
|
|
||||||
*
|
*
|
||||||
* Only the fields the Personal, Maritime Profile and Address tabs actually
|
* No longer a gate on opening the wizard: the Identity Details step collects
|
||||||
* mark required — matches `profileSchema` / `addressSchema`, so the gate is
|
* these itself, so an applicant with an empty profile starts in registration
|
||||||
* always satisfiable by finishing those tabs and never blocks on an optional
|
* rather than being sent to `/profile` to prepare for it. Kept because
|
||||||
* field (place of birth, region/city/woreda, emergency contact) the forms
|
* `ProfilePage` still reads it to show what a seafarer registration will need.
|
||||||
* don't star.
|
|
||||||
*/
|
*/
|
||||||
export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
|
export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
|
||||||
fields: [
|
fields: [
|
||||||
'firstName',
|
"firstName",
|
||||||
'middleName',
|
"middleName",
|
||||||
'lastName',
|
"lastName",
|
||||||
'gender',
|
"gender",
|
||||||
'dob',
|
"dob",
|
||||||
'maritalStatus',
|
"maritalStatus",
|
||||||
'professionId',
|
"professionId",
|
||||||
'idType',
|
"idType",
|
||||||
'idNumber',
|
"idNumber",
|
||||||
'nationality',
|
"nationality",
|
||||||
'primaryPhoneNumber',
|
"primaryPhoneNumber",
|
||||||
'email',
|
"email",
|
||||||
],
|
],
|
||||||
// Translation key, not literal text — `ProfileRequirementGate` runs it
|
// Translation key, not literal text — `ProfileRequirementGate` runs it
|
||||||
// through `t()` at render time (it can't be translated here: this object
|
// through `t()` at render time (it can't be translated here: this object
|
||||||
@@ -42,38 +34,38 @@ export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
|
|||||||
reason: 'profileGate.seafarerReason',
|
reason: 'profileGate.seafarerReason',
|
||||||
};
|
};
|
||||||
|
|
||||||
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
|
const REGISTRATION_TYPE_KEY = "SEAFARER_REGISTRATION";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends an applicant with an incomplete profile to `/profile` before they can
|
* Opens the existing registration summary when the applicant already holds a
|
||||||
* reach seafarer registration. Wraps `/seafarer-registration` directly and
|
* seafarer number, avoiding an attempt to create a duplicate registration.
|
||||||
* `/licensing/:typeCode/apply` when `typeCode` is the seafarer type — the
|
|
||||||
* latter is the shared wizard route every licence type renders through, so
|
|
||||||
* without it the gate is a decoration a deep link skips.
|
|
||||||
*
|
*
|
||||||
* Fires before the wizard starts, not mid-application, so nothing is lost —
|
* It deliberately does *not* gate on profile completeness any more. Selecting
|
||||||
* unlike the case `ProfileRequirementGate`'s doc comment warns against
|
* Seafarer Registration now opens the wizard, and the Identity Details step
|
||||||
* (mid-flow redirects on the old, deleted setup wizard).
|
* collects name, gender, DOB, marital status, nationality and national ID
|
||||||
|
* itself — an empty profile is a thing the wizard fills, not a reason to be
|
||||||
|
* sent away from it. Those answers reach the profile when a reviewer approves
|
||||||
|
* the registration (`CompletionEffectService.registerSeafarer`).
|
||||||
|
*
|
||||||
|
* Wraps `/seafarer-registration` directly and `/licensing/:typeCode/apply`
|
||||||
|
* when `typeCode` is the seafarer type — the latter is the shared wizard route
|
||||||
|
* every licence type renders through, so without it a deep link skips this.
|
||||||
*/
|
*/
|
||||||
export function RequireSeafarerProfile({ children }: { children: React.ReactNode }) {
|
export function RequireSeafarerProfile({
|
||||||
const { t } = useTranslation();
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
const { typeCode } = useParams();
|
const { typeCode } = useParams();
|
||||||
const { pathname } = useLocation();
|
const { isLoading, error, profile } = useCurrentProfile();
|
||||||
const { isLoading, isFetching, error, gapsFor, profile } = useCurrentProfile();
|
|
||||||
|
|
||||||
// Shared wizard route — only the seafarer type is gated here.
|
// Shared wizard route — only the seafarer type is checked here.
|
||||||
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
|
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
|
||||||
const gaps = gated ? gapsFor(SEAFARER_PROFILE_REQUIREMENT) : [];
|
const registered = Boolean(profile?.seafarerNumber);
|
||||||
const redirecting = gated && !isLoading && !error && !isFetching && gaps.length > 0;
|
const { data: applications, isLoading: loadingApplications } =
|
||||||
|
useGetMyApplicationsQuery(undefined, {
|
||||||
useEffect(() => {
|
skip: !gated || !registered,
|
||||||
if (!redirecting) return;
|
});
|
||||||
const fields = gaps.map((field) => t(`profileFields.${field}`, field)).join(', ');
|
|
||||||
notify.info(t('profileGate.seafarerRedirect', { fields }));
|
|
||||||
// Fire once per redirect, not on every render while gaps/gapsFor are
|
|
||||||
// recreated — the toast content is captured at the moment it fires.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [redirecting, pathname]);
|
|
||||||
|
|
||||||
if (!gated) return <>{children}</>;
|
if (!gated) return <>{children}</>;
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
|
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode, splitPersonName, joinPersonName } from '@ema-platform/ui';
|
||||||
import { useApiMutation, useLocalized } from '@ema-platform/api';
|
import { useApiMutation, useLocalized } from '@ema-platform/api';
|
||||||
import { PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
import { PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
||||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||||
@@ -89,15 +89,6 @@ function getInitials(name: string, fallback: string) {
|
|||||||
return letters.toUpperCase();
|
return letters.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitProfileName(fullName: string) {
|
|
||||||
const [firstName = '', middleName = '', ...lastName] = fullName.trim().split(/\s+/);
|
|
||||||
return { firstName, middleName, lastName: lastName.join(' ') };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatProfileName({ firstName, middleName, lastName }: Pick<ProfileValues, 'firstName' | 'middleName' | 'lastName'>) {
|
|
||||||
return [firstName, middleName, lastName].filter(Boolean).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeName(name: string) {
|
function normalizeName(name: string) {
|
||||||
return name.trim().replace(/\s+/g, ' ');
|
return name.trim().replace(/\s+/g, ' ');
|
||||||
}
|
}
|
||||||
@@ -205,7 +196,7 @@ export function ProfilePage() {
|
|||||||
// already holds so the form does not flash empty on a refetch.
|
// already holds so the form does not flash empty on a refetch.
|
||||||
const currentProfile = resolvedProfile ?? storedProfile;
|
const currentProfile = resolvedProfile ?? storedProfile;
|
||||||
if (currentProfile) {
|
if (currentProfile) {
|
||||||
const accountName = user?.name?.en ? splitProfileName(user.name.en) : null;
|
const accountName = user?.name?.en ? splitPersonName(user.name.en) : null;
|
||||||
setLoadedProfile({
|
setLoadedProfile({
|
||||||
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
|
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
|
||||||
firstName: accountName?.firstName || currentProfile.firstName || '',
|
firstName: accountName?.firstName || currentProfile.firstName || '',
|
||||||
@@ -286,7 +277,7 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
setIsSavingProfile(true);
|
setIsSavingProfile(true);
|
||||||
try {
|
try {
|
||||||
const profileName = splitProfileName(values.nameEn);
|
const profileName = splitPersonName(values.nameEn);
|
||||||
const saves: Promise<unknown>[] = [
|
const saves: Promise<unknown>[] = [
|
||||||
updateTrigger({
|
updateTrigger({
|
||||||
url: '/auth/update-profile',
|
url: '/auth/update-profile',
|
||||||
@@ -363,7 +354,7 @@ export function ProfilePage() {
|
|||||||
const onSaveProfile = async (values: ProfileValues) => {
|
const onSaveProfile = async (values: ProfileValues) => {
|
||||||
if (!profileId) return;
|
if (!profileId) return;
|
||||||
|
|
||||||
const fullName = formatProfileName(values);
|
const fullName = joinPersonName(values);
|
||||||
if (user && normalizeName(fullName) !== normalizeName(user.name.en)) {
|
if (user && normalizeName(fullName) !== normalizeName(user.name.en)) {
|
||||||
notify.error(t('profile.nameMismatch'));
|
notify.error(t('profile.nameMismatch'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ export interface AddressPayload {
|
|||||||
regionId?: string;
|
regionId?: string;
|
||||||
cityId?: string;
|
cityId?: string;
|
||||||
subCityId?: string;
|
subCityId?: string;
|
||||||
/** Legacy spelling still accepted by the address upsert endpoint. */
|
|
||||||
subcityId?: string;
|
|
||||||
woredaId?: string;
|
woredaId?: string;
|
||||||
kebeleId?: string;
|
kebeleId?: string;
|
||||||
streetAddress?: string;
|
streetAddress?: string;
|
||||||
@@ -28,15 +26,15 @@ export interface AddressPayload {
|
|||||||
emergencyContactRelation?: string;
|
emergencyContactRelation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Blank optional strings drop out; `kebeleId` always mirrors `woredaId`. */
|
/** Blank optional strings drop out. */
|
||||||
export function toAddressPayload(values: AddressValues): AddressPayload {
|
export function toAddressPayload(values: AddressValues): AddressPayload {
|
||||||
const clean = (v?: string) => (v && v.trim() ? v.trim() : undefined);
|
const clean = (v?: string) => (v && v.trim() ? v.trim() : undefined);
|
||||||
const regionId = clean(values.regionId);
|
const regionId = clean(values.regionId);
|
||||||
// The location service uses the selected City for the profile's region.
|
// The seeded location tree tops out at CITY — Addis Ababa is a city-state,
|
||||||
// Send that same id as cityId too, because profile completeness requires
|
// so a selected city stands in for the region and both ids are the same
|
||||||
// both fields even when the location tree has no separate region node.
|
// node. Profile completeness requires both, and the picker only ever yields
|
||||||
|
// one of them.
|
||||||
const cityId = clean(values.cityId) ?? regionId;
|
const cityId = clean(values.cityId) ?? regionId;
|
||||||
const subCityId = clean(values.subCityId);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
idType: values.idType.trim(),
|
idType: values.idType.trim(),
|
||||||
@@ -45,10 +43,12 @@ export function toAddressPayload(values: AddressValues): AddressPayload {
|
|||||||
nationality: getCountryName(values.nationality),
|
nationality: getCountryName(values.nationality),
|
||||||
regionId,
|
regionId,
|
||||||
cityId,
|
cityId,
|
||||||
subCityId,
|
subCityId: clean(values.subCityId),
|
||||||
subcityId: subCityId, // legacy spelling, same value
|
|
||||||
woredaId: clean(values.woredaId),
|
woredaId: clean(values.woredaId),
|
||||||
kebeleId: clean(values.woredaId), // no separate Kebele field — mirrors woredaId
|
// Left unset rather than mirroring woredaId: the picker stops at woreda,
|
||||||
|
// and copying that id here filed a WOREDA-typed node in kebele_id, so the
|
||||||
|
// column could not be trusted to mean what it says.
|
||||||
|
kebeleId: clean(values.kebeleId),
|
||||||
streetAddress: clean(values.streetAddress),
|
streetAddress: clean(values.streetAddress),
|
||||||
primaryPhoneNumber: values.primaryPhoneNumber,
|
primaryPhoneNumber: values.primaryPhoneNumber,
|
||||||
secondaryPhoneNumber: clean(values.secondaryPhoneNumber),
|
secondaryPhoneNumber: clean(values.secondaryPhoneNumber),
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ function SeaServiceTab() {
|
|||||||
const [form, setForm] = useState(EMPTY_SEA_SERVICE);
|
const [form, setForm] = useState(EMPTY_SEA_SERVICE);
|
||||||
const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
|
const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
|
||||||
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
|
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
|
||||||
|
const [uploadingEvidence, setUploadingEvidence] = useState(false);
|
||||||
|
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
@@ -217,27 +219,27 @@ function SeaServiceTab() {
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
await updateRecord({ id: editing.id, body }).unwrap();
|
await updateRecord({ id: editing.id, body }).unwrap();
|
||||||
} else {
|
} else {
|
||||||
recordId = (await createRecord(body).unwrap()).id;
|
const created = await createRecord(body).unwrap();
|
||||||
|
recordId = created.id;
|
||||||
|
notify.success('Sea-service record added');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evidenceFile && recordId) {
|
if (evidenceFile && recordId) {
|
||||||
setUploading(true);
|
setUploadingEvidence(true);
|
||||||
const result = await uploadDocument({
|
const result = await uploadDocument({
|
||||||
ownerType: 'SEA_SERVICE_RECORD',
|
ownerType: 'SEA_SERVICE_RECORD',
|
||||||
ownerId: recordId,
|
ownerId: recordId,
|
||||||
documentKey: 'evidence',
|
documentKey: 'evidence',
|
||||||
file: evidenceFile,
|
file: evidenceFile,
|
||||||
});
|
});
|
||||||
setUploading(false);
|
setUploadingEvidence(false);
|
||||||
if (!result.ok) {
|
if (result.ok) {
|
||||||
|
notify.success('Evidence uploaded');
|
||||||
|
} else {
|
||||||
notify.error(result.error);
|
notify.error(result.error);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notify.success(
|
|
||||||
editing
|
|
||||||
? t('seaRecords.seaService.updated')
|
|
||||||
: t('seaRecords.seaService.added'),
|
|
||||||
);
|
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed')));
|
notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed')));
|
||||||
@@ -387,7 +389,17 @@ function SeaServiceTab() {
|
|||||||
setForm({ ...form, dutiesDescription: e.target.value })
|
setForm({ ...form, dutiesDescription: e.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<EvidenceField file={evidenceFile} onChange={setEvidenceFile} />
|
<FileButton onChange={setEvidenceFile} accept="image/*,application/pdf">
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconFileUpload size={16} />}
|
||||||
|
>
|
||||||
|
{evidenceFile ? evidenceFile.name : 'Attach evidence (optional)'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
@@ -395,7 +407,7 @@ function SeaServiceTab() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!valid}
|
disabled={!valid}
|
||||||
loading={creating || updating || uploading}
|
loading={creating || updating || uploadingEvidence}
|
||||||
>
|
>
|
||||||
{editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
|
{editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -439,7 +451,7 @@ function MedicalTab() {
|
|||||||
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
|
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState(EMPTY_MEDICAL);
|
const [form, setForm] = useState(EMPTY_MEDICAL);
|
||||||
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
|
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploadingEvidence, setUploadingEvidence] = useState(false);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -478,27 +490,27 @@ function MedicalTab() {
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
await updateCertificate({ id: editing.id, body }).unwrap();
|
await updateCertificate({ id: editing.id, body }).unwrap();
|
||||||
} else {
|
} else {
|
||||||
certificateId = (await createCertificate(body).unwrap()).id;
|
const created = await createCertificate(body).unwrap();
|
||||||
|
certificateId = created.id;
|
||||||
|
notify.success('Medical certificate added');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evidenceFile && certificateId) {
|
if (evidenceFile && certificateId) {
|
||||||
setUploading(true);
|
setUploadingEvidence(true);
|
||||||
const result = await uploadDocument({
|
const result = await uploadDocument({
|
||||||
ownerType: 'MEDICAL_CERTIFICATE',
|
ownerType: 'MEDICAL_CERTIFICATE',
|
||||||
ownerId: certificateId,
|
ownerId: certificateId,
|
||||||
documentKey: 'evidence',
|
documentKey: 'evidence',
|
||||||
file: evidenceFile,
|
file: evidenceFile,
|
||||||
});
|
});
|
||||||
setUploading(false);
|
setUploadingEvidence(false);
|
||||||
if (!result.ok) {
|
if (result.ok) {
|
||||||
|
notify.success('Evidence uploaded');
|
||||||
|
} else {
|
||||||
notify.error(result.error);
|
notify.error(result.error);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notify.success(
|
|
||||||
editing
|
|
||||||
? t('seaRecords.medical.updated')
|
|
||||||
: t('seaRecords.medical.added'),
|
|
||||||
);
|
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed')));
|
notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed')));
|
||||||
@@ -622,7 +634,17 @@ function MedicalTab() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<EvidenceField file={evidenceFile} onChange={setEvidenceFile} />
|
<FileButton onChange={setEvidenceFile} accept="image/*,application/pdf">
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconFileUpload size={16} />}
|
||||||
|
>
|
||||||
|
{evidenceFile ? evidenceFile.name : 'Attach evidence (optional)'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
@@ -630,7 +652,7 @@ function MedicalTab() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!valid}
|
disabled={!valid}
|
||||||
loading={creating || updating || uploading}
|
loading={creating || updating || uploadingEvidence}
|
||||||
>
|
>
|
||||||
{editing ? t('common.save') : t('seaRecords.medical.add')}
|
{editing ? t('common.save') : t('seaRecords.medical.add')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -30,14 +30,22 @@ import {
|
|||||||
IconX,
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
interface ApplicationSummary {
|
||||||
|
id: string;
|
||||||
|
applicationId: string;
|
||||||
|
status: string;
|
||||||
|
submittedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
|
/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
|
||||||
interface SeamanBookOverview {
|
interface SeamanBookOverview {
|
||||||
application: {
|
application: ApplicationSummary | null;
|
||||||
id: string;
|
/**
|
||||||
applicationId: string;
|
* The Basic Training Certificate opened alongside the book by an approved
|
||||||
status: string;
|
* seafarer registration — a separate application, separately numbered and
|
||||||
submittedAt: string;
|
* separately billed, so it is shown as its own card rather than merged in.
|
||||||
} | null;
|
*/
|
||||||
|
btcApplication: ApplicationSummary | null;
|
||||||
book: {
|
book: {
|
||||||
id: string;
|
id: string;
|
||||||
issuedDate: string;
|
issuedDate: string;
|
||||||
@@ -122,6 +130,76 @@ function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One in-flight application: its number, where it stands, and the stages left.
|
||||||
|
*
|
||||||
|
* Shared by the Seaman Book and the BTC because an approved registration opens
|
||||||
|
* both and they move independently — the book waits on a TRB inspection while
|
||||||
|
* the BTC goes straight to payment, so a single merged card would have to lie
|
||||||
|
* about one of them.
|
||||||
|
*/
|
||||||
|
function ApplicationCard({
|
||||||
|
title,
|
||||||
|
application,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
application: ApplicationSummary;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const activeStep = stageIndexFor(application.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||||
|
<IconBook2 size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>
|
||||||
|
{title} — {application.id}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{/* An approved seafarer registration opens this application as a
|
||||||
|
draft, so it can be here before anyone has filed it. Calling
|
||||||
|
that "Submitted" would misreport where it stands. */}
|
||||||
|
{application.status === 'DRAFT' ? 'Opened' : 'Submitted'}{' '}
|
||||||
|
{formatDate(application.submittedAt)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Badge
|
||||||
|
color={STATUS_COLOR[application.status] ?? 'gray'}
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{application.status.replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stepper active={activeStep} size="sm" color="teal">
|
||||||
|
{STAGES.map((stage, i) => (
|
||||||
|
<Stepper.Step
|
||||||
|
key={stage.label}
|
||||||
|
label={stage.label}
|
||||||
|
description={i <= activeStep ? 'Done' : 'Pending'}
|
||||||
|
icon={
|
||||||
|
i <= activeStep ? (
|
||||||
|
<IconCircleCheck size={16} />
|
||||||
|
) : (
|
||||||
|
<IconClock size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Component
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -134,6 +212,7 @@ export function SeamanBookPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const application = data?.application ?? null;
|
const application = data?.application ?? null;
|
||||||
|
const btcApplication = data?.btcApplication ?? null;
|
||||||
const eligibility = data?.eligibility;
|
const eligibility = data?.eligibility;
|
||||||
const bstItems = eligibility?.bstModules ?? [];
|
const bstItems = eligibility?.bstModules ?? [];
|
||||||
const bstDone = bstItems.filter((b) => b.done).length;
|
const bstDone = bstItems.filter((b) => b.done).length;
|
||||||
@@ -141,9 +220,10 @@ export function SeamanBookPage() {
|
|||||||
// The server decides: the same checklist gates the submission, so a screen
|
// The server decides: the same checklist gates the submission, so a screen
|
||||||
// that judged eligibility for itself could offer a button the API refuses.
|
// that judged eligibility for itself could offer a button the API refuses.
|
||||||
const isEligible = data?.eligible ?? false;
|
const isEligible = data?.eligible ?? false;
|
||||||
const submitted = Boolean(application);
|
// Either service already being in flight means there is nothing to apply for
|
||||||
|
// here — an approved registration opens both, so offering "Apply" alongside
|
||||||
const activeStep = stageIndexFor(application?.status);
|
// them would invite a duplicate the server refuses anyway.
|
||||||
|
const submitted = Boolean(application || btcApplication);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -156,55 +236,22 @@ export function SeamanBookPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active application status */}
|
{/* Active application status — one card per service in flight. */}
|
||||||
{application && (
|
{application && (
|
||||||
<Paper withBorder radius="lg" p="lg">
|
<ApplicationCard title="Seaman Book" application={application}>
|
||||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
|
||||||
<Group gap="xs">
|
|
||||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
|
||||||
<IconBook2 size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text fw={700}>Application {application.id}</Text>
|
|
||||||
<Text fz="xs" c="dimmed">
|
|
||||||
Submitted {formatDate(application.submittedAt)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Badge
|
|
||||||
color={STATUS_COLOR[application.status] ?? 'gray'}
|
|
||||||
variant="light"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{application.status.replaceAll('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* Progress stepper */}
|
|
||||||
<Stepper active={activeStep} size="sm" color="teal">
|
|
||||||
{STAGES.map((stage, i) => (
|
|
||||||
<Stepper.Step
|
|
||||||
key={stage.label}
|
|
||||||
label={stage.label}
|
|
||||||
description={i <= activeStep ? 'Done' : 'Pending'}
|
|
||||||
icon={
|
|
||||||
i <= activeStep ? (
|
|
||||||
<IconCircleCheck size={16} />
|
|
||||||
) : (
|
|
||||||
<IconClock size={16} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stepper>
|
|
||||||
|
|
||||||
{data?.book && (
|
{data?.book && (
|
||||||
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
|
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
|
||||||
Your Seaman Book <strong>{data.book.id}</strong> has been issued.
|
Your Seaman Book <strong>{data.book.id}</strong> has been issued.
|
||||||
Please visit the EMA office to collect it, bringing your National ID.
|
Please visit the EMA office to collect it, bringing your National ID.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</ApplicationCard>
|
||||||
|
)}
|
||||||
|
{btcApplication && (
|
||||||
|
<ApplicationCard
|
||||||
|
title="Basic Training Certificate"
|
||||||
|
application={btcApplication}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No active application — eligibility + apply */}
|
{/* No active application — eligibility + apply */}
|
||||||
|
|||||||
@@ -286,9 +286,8 @@ export const am: Translations = {
|
|||||||
addDetails: 'እነዚህን መረጃዎች ጨምር',
|
addDetails: 'እነዚህን መረጃዎች ጨምር',
|
||||||
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
|
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
|
||||||
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
|
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
|
||||||
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
|
seafarerBanner:
|
||||||
seafarerBanner: 'ለባህረኛ ምዝገባ የመገለጫ መረጃ ያስፈልጋል።',
|
'የባህረኛ ምዝገባ እነዚህን መረጃዎች ይጠይቃል፤ ከጸደቀ በኋላም መገለጫዎን ያዘምናል። እዚህ አስቀድመው ቢሞሏቸው እዚያ እንደገና መተየብ አይኖርብዎትም።',
|
||||||
checkingProfile: 'የባህረኛ መገለጫ በመፈተሽ ላይ…',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
profileSections: {
|
profileSections: {
|
||||||
|
|||||||
@@ -286,8 +286,8 @@ export const en = {
|
|||||||
viewProfile: 'View full profile',
|
viewProfile: 'View full profile',
|
||||||
seafarerReason:
|
seafarerReason:
|
||||||
'Seafarer registration is built from your profile — these details fill it in for you.',
|
'Seafarer registration is built from your profile — these details fill it in for you.',
|
||||||
seafarerRedirect: 'Finish your profile to register as a seafarer. Still needed: {{fields}}',
|
seafarerBanner:
|
||||||
seafarerBanner: 'Profile details are needed for seafarer registration.',
|
'Seafarer registration asks for these details and updates your profile once approved. Filling them in here first saves you typing them there.',
|
||||||
checkingProfile: 'Checking seafarer profile…',
|
checkingProfile: 'Checking seafarer profile…',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { notify, AppHeader, AppSidebar, filterByPermissions } from "@ema-platform/ui";
|
import {
|
||||||
|
notify,
|
||||||
|
AppHeader,
|
||||||
|
AppSidebar,
|
||||||
|
filterByPermissions,
|
||||||
|
} from "@ema-platform/ui";
|
||||||
import type { NavItem } from "@ema-platform/ui";
|
import type { NavItem } from "@ema-platform/ui";
|
||||||
import {
|
import {
|
||||||
BrandMark,
|
BrandMark,
|
||||||
logout,
|
logout,
|
||||||
useCurrentProfile,
|
|
||||||
usePermissions,
|
usePermissions,
|
||||||
LICENSE_PERMISSIONS,
|
LICENSE_PERMISSIONS,
|
||||||
PORTAL_PERMISSIONS,
|
PORTAL_PERMISSIONS,
|
||||||
@@ -68,27 +72,93 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
|||||||
{
|
{
|
||||||
label: "nav.groupLicensing",
|
label: "nav.groupLicensing",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck, permissions: [L.VIEW_OWN_APPLICATIONS] },
|
{
|
||||||
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff, permissions: [P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER] },
|
to: "/licensing/applications",
|
||||||
|
label: "My Applications",
|
||||||
|
i18nKey: "nav.myApplications",
|
||||||
|
icon: IconTruck,
|
||||||
|
permissions: [L.VIEW_OWN_APPLICATIONS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/waiver",
|
||||||
|
label: "Waiver",
|
||||||
|
i18nKey: "nav.waiver",
|
||||||
|
icon: IconShieldOff,
|
||||||
|
permissions: [P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "nav.groupSeafarer",
|
label: "nav.groupSeafarer",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList, permissions: [P.APPLY_SEAFARER_REGISTRATION] },
|
{
|
||||||
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
|
to: "/seafarer-registration",
|
||||||
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.seamanBook', icon: IconBook2, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
|
label: "Seafarer Registration",
|
||||||
{ to: '/licensing/BTC_BASIC_TRAINING/apply', label: 'Basic Training Certificate', i18nKey: 'nav.btc', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
|
i18nKey: "nav.seafarerRegistration",
|
||||||
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
|
icon: IconList,
|
||||||
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList, permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM] },
|
permissions: [P.APPLY_SEAFARER_REGISTRATION],
|
||||||
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, permissions: [P.VIEW_OWN_CERTIFICATES] },
|
},
|
||||||
|
{
|
||||||
|
to: "/seafarer/records",
|
||||||
|
label: "My Sea Records",
|
||||||
|
i18nKey: "nav.seaRecords",
|
||||||
|
icon: IconList,
|
||||||
|
permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/seaman-book",
|
||||||
|
label: "Seaman Book",
|
||||||
|
i18nKey: "nav.seamanBook",
|
||||||
|
icon: IconBook2,
|
||||||
|
permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/licensing/BTC_BASIC_TRAINING/apply",
|
||||||
|
label: "Basic Training Certificate",
|
||||||
|
i18nKey: "nav.btc",
|
||||||
|
icon: IconShieldCheck,
|
||||||
|
permissions: [P.VIEW_OWN_CERTIFICATES],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/certificates",
|
||||||
|
label: "Certificates",
|
||||||
|
i18nKey: "nav.certificates",
|
||||||
|
icon: IconShieldCheck,
|
||||||
|
permissions: [P.VIEW_OWN_CERTIFICATES],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/exams",
|
||||||
|
label: "Examinations",
|
||||||
|
i18nKey: "nav.exams",
|
||||||
|
icon: IconList,
|
||||||
|
permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/endorsements",
|
||||||
|
label: "Endorsements",
|
||||||
|
i18nKey: "nav.endorsements",
|
||||||
|
icon: IconRubberStamp,
|
||||||
|
permissions: [P.VIEW_OWN_CERTIFICATES],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "nav.groupVessels",
|
label: "nav.groupVessels",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip, permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS] },
|
{
|
||||||
{ to: '/vessel-ownership-transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange, permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS] },
|
to: "/vessel-registration",
|
||||||
|
label: "Vessel Registration",
|
||||||
|
i18nKey: "nav.vesselRegistration",
|
||||||
|
icon: IconShip,
|
||||||
|
permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/vessel-ownership-transfer",
|
||||||
|
label: "Ownership Transfer",
|
||||||
|
i18nKey: "nav.ownershipTransfer",
|
||||||
|
icon: IconArrowsExchange,
|
||||||
|
permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,22 +187,24 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const PAGE_META: Record<string, { i18nKey: string }> = {
|
const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||||
'/dashboard': { i18nKey: 'nav.dashboard' },
|
"/dashboard": { i18nKey: "nav.dashboard" },
|
||||||
'/vessel-registration-dashboard': { i18nKey: 'nav.vesselRegistrationDashboard' },
|
"/vessel-registration-dashboard": {
|
||||||
'/vessel-registration': { i18nKey: 'nav.vesselRegistration' },
|
i18nKey: "nav.vesselRegistrationDashboard",
|
||||||
'/vessel-ownership-transfer': { i18nKey: 'nav.ownershipTransfer' },
|
},
|
||||||
'/licensing/applications': { i18nKey: 'nav.myApplications' },
|
"/vessel-registration": { i18nKey: "nav.vesselRegistration" },
|
||||||
'/waiver': { i18nKey: 'nav.waiver' },
|
"/vessel-ownership-transfer": { i18nKey: "nav.ownershipTransfer" },
|
||||||
'/seafarer-registration': { i18nKey: 'nav.seafarerRegistration' },
|
"/licensing/applications": { i18nKey: "nav.myApplications" },
|
||||||
'/seafarer/records': { i18nKey: 'nav.seaRecords' },
|
"/waiver": { i18nKey: "nav.waiver" },
|
||||||
'/seaman-book': { i18nKey: 'nav.myApplication' },
|
"/seafarer-registration": { i18nKey: "nav.seafarerRegistration" },
|
||||||
'/certificates': { i18nKey: 'nav.certificates' },
|
"/seafarer/records": { i18nKey: "nav.seaRecords" },
|
||||||
'/exams': { i18nKey: 'nav.exams' },
|
"/seaman-book": { i18nKey: "nav.myApplication" },
|
||||||
'/endorsements': { i18nKey: 'nav.endorsements' },
|
"/certificates": { i18nKey: "nav.certificates" },
|
||||||
'/documents': { i18nKey: 'nav.documents' },
|
"/exams": { i18nKey: "nav.exams" },
|
||||||
'/notifications':{ i18nKey: 'nav.notifications' },
|
"/endorsements": { i18nKey: "nav.endorsements" },
|
||||||
'/profile': { i18nKey: 'nav.profile' },
|
"/documents": { i18nKey: "nav.documents" },
|
||||||
'/support': { i18nKey: 'nav.support' },
|
"/notifications": { i18nKey: "nav.notifications" },
|
||||||
|
"/profile": { i18nKey: "nav.profile" },
|
||||||
|
"/support": { i18nKey: "nav.support" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PortalLayout() {
|
export function PortalLayout() {
|
||||||
@@ -148,30 +220,23 @@ export function PortalLayout() {
|
|||||||
refetchOnMountOrArgChange: false,
|
refetchOnMountOrArgChange: false,
|
||||||
});
|
});
|
||||||
const { permissions: granted, known } = usePermissions();
|
const { permissions: granted, known } = usePermissions();
|
||||||
// A seafarer registers once; the number is permanent. Once it exists the
|
|
||||||
// registration item is dropped rather than left to bounce off
|
|
||||||
// RequireSeafarerProfile's redirect.
|
|
||||||
const { profile } = useCurrentProfile();
|
|
||||||
const registered = Boolean(profile?.seafarerNumber);
|
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
const sections = useMemo(() => {
|
||||||
const translated = NAV_SECTIONS.map((section) => ({
|
const translated = NAV_SECTIONS.map((section) => ({
|
||||||
label: section.label,
|
label: section.label,
|
||||||
items: section.items
|
items: section.items.map(({ i18nKey, ...rest }) => ({
|
||||||
.filter((item) => !(registered && item.to === "/seafarer-registration"))
|
...rest,
|
||||||
.map(({ i18nKey, ...rest }) => ({
|
label: t(i18nKey),
|
||||||
...rest,
|
badge:
|
||||||
label: t(i18nKey),
|
rest.to === "/notifications" && unseen?.count
|
||||||
badge:
|
? unseen.count
|
||||||
rest.to === "/notifications" && unseen?.count
|
: undefined,
|
||||||
? unseen.count
|
})),
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
// Unfiltered until the grant list has loaded — same fail-open rule as
|
// Unfiltered until the grant list has loaded — same fail-open rule as
|
||||||
// RequirePermission: a moment of extra nav beats a flash of empty nav.
|
// RequirePermission: a moment of extra nav beats a flash of empty nav.
|
||||||
return known ? filterByPermissions(translated, granted) : translated;
|
return known ? filterByPermissions(translated, granted) : translated;
|
||||||
}, [t, unseen?.count, granted, known, registered]);
|
}, [t, unseen?.count, granted, known]);
|
||||||
|
|
||||||
// Breadcrumb trail
|
// Breadcrumb trail
|
||||||
const segments = location.pathname.split("/").filter(Boolean);
|
const segments = location.pathname.split("/").filter(Boolean);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from './lib/base-api';
|
|||||||
export * from './lib/query-and-mutation';
|
export * from './lib/query-and-mutation';
|
||||||
export * from './lib/session';
|
export * from './lib/session';
|
||||||
export * from './lib/features/licensing';
|
export * from './lib/features/licensing';
|
||||||
|
export * from './lib/features/location';
|
||||||
export * from './lib/features/seafarer';
|
export * from './lib/features/seafarer';
|
||||||
export * from './lib/features/vessel';
|
export * from './lib/features/vessel';
|
||||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { resolveTokenFromStorage } from '../../session';
|
import { resolveTokenFromStorage } from '../../session';
|
||||||
import type {
|
import type {
|
||||||
Bilingual,
|
Bilingual,
|
||||||
|
FormFieldConfig,
|
||||||
FormSectionConfig,
|
FormSectionConfig,
|
||||||
LicenseApplication,
|
LicenseApplication,
|
||||||
LicenseStatus,
|
LicenseStatus,
|
||||||
@@ -178,12 +179,64 @@ export function applicantOrCompanyName(app: LicenseApplication): string | undefi
|
|||||||
return typeof applicantName === 'string' && applicantName ? applicantName : undefined;
|
return typeof applicantName === 'string' && applicantName ? applicantName : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One form answer as a person should read it back.
|
||||||
|
*
|
||||||
|
* The stored value is not it: a SELECT holds the option's `value`, so an
|
||||||
|
* unformatted view shows reviewers and applicants `AB_POSITIVE` and `DECK` —
|
||||||
|
* the codes the database wants, not the words that were chosen. Shared by the
|
||||||
|
* applicant's summary and the officer's review so the two never describe the
|
||||||
|
* same application differently.
|
||||||
|
*
|
||||||
|
* `showDate` is passed in rather than imported: date display is a hook
|
||||||
|
* (`useDateDisplayer`, Ethiopian-calendar aware) and this is a plain function.
|
||||||
|
*/
|
||||||
|
export function displayFieldValue(
|
||||||
|
field: Pick<FormFieldConfig, 'type' | 'options'>,
|
||||||
|
raw: unknown,
|
||||||
|
opts: {
|
||||||
|
language?: string;
|
||||||
|
showDate?: (value: string) => string;
|
||||||
|
currency?: string;
|
||||||
|
} = {},
|
||||||
|
): string {
|
||||||
|
if (raw === null || raw === undefined || raw === '') return '';
|
||||||
|
const { language = 'en', showDate, currency } = opts;
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case 'BOOLEAN':
|
||||||
|
return raw ? 'Yes' : 'No';
|
||||||
|
case 'DATE':
|
||||||
|
return showDate?.(String(raw)) || String(raw);
|
||||||
|
case 'SELECT': {
|
||||||
|
const option = field.options?.find((o) => o.value === raw);
|
||||||
|
// Falls back to the stored value rather than blanking: an option removed
|
||||||
|
// from the config since this was filed still has to show what was chosen.
|
||||||
|
return option ? localized(option.label, language) : String(raw);
|
||||||
|
}
|
||||||
|
case 'MONEY': {
|
||||||
|
const amount = Number(raw);
|
||||||
|
return Number.isFinite(amount)
|
||||||
|
? `${amount.toLocaleString()} ${currency ?? ''}`.trim()
|
||||||
|
: String(raw);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return String(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Reads a bilingual value for the active language, falling back to English. */
|
/** Reads a bilingual value for the active language, falling back to English. */
|
||||||
export function localized(value: Bilingual | undefined, language = 'en'): string {
|
export function localized(value: Bilingual | undefined, language = 'en'): string {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
// `||` not `??`: an empty Amharic string is "not translated", not a value —
|
// `||` not `??`: an empty Amharic string is "not translated", not a value —
|
||||||
// editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
|
// editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
|
||||||
return (language === 'am' ? value.am : value.en) || value.en || value.am || '';
|
//
|
||||||
|
// Keyed by the active language rather than an en/am ternary, so a locale the
|
||||||
|
// backend stores but the UI does not yet offer a switcher for (`om`, `so` on
|
||||||
|
// location names) still resolves once it does. English then Amharic remain
|
||||||
|
// the fallbacks, in that order.
|
||||||
|
const active = value[language as keyof Bilingual];
|
||||||
|
return active || value.en || value.am || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,18 @@
|
|||||||
// seafarer domain, and two copies would drift.
|
// seafarer domain, and two copies would drift.
|
||||||
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
|
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
|
||||||
|
|
||||||
export type Bilingual = { en?: string; am?: string };
|
/**
|
||||||
|
* A backend `LocaleValidationDto`. Named for the two locales the UI offers, but
|
||||||
|
* carries every locale the column stores — location names are seeded with `om`
|
||||||
|
* (Finfinnee) and `so` too, and a type that declared only en/am made those
|
||||||
|
* unreachable through the typed client.
|
||||||
|
*/
|
||||||
|
export type Bilingual = {
|
||||||
|
en?: string;
|
||||||
|
am?: string;
|
||||||
|
om?: string;
|
||||||
|
so?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The application status vocabulary. Single source of truth for both apps —
|
* The application status vocabulary. Single source of truth for both apps —
|
||||||
@@ -329,8 +340,37 @@ export interface ApplicationRemark {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who filed the application, from their profile.
|
||||||
|
*
|
||||||
|
* Served alongside the form because a person-centric service (seafarer
|
||||||
|
* registration, a certificate) has no `companyName` to identify itself by — a
|
||||||
|
* reviewer opening one otherwise sees only an application number and has to
|
||||||
|
* infer the human from the answers.
|
||||||
|
*/
|
||||||
|
export interface ApplicationApplicant {
|
||||||
|
profileId: string;
|
||||||
|
firstName: string | null;
|
||||||
|
middleName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
gender: string | null;
|
||||||
|
dob: string | null;
|
||||||
|
pob: string | null;
|
||||||
|
maritalStatus: string | null;
|
||||||
|
seafarerNumber: string | null;
|
||||||
|
seafarerStatus: string | null;
|
||||||
|
seafarerDepartment: string | null;
|
||||||
|
nationality: string | null;
|
||||||
|
idType: string | null;
|
||||||
|
idNumber: string | null;
|
||||||
|
primaryPhoneNumber: string | null;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApplicationDetail {
|
export interface ApplicationDetail {
|
||||||
application: LicenseApplication;
|
application: LicenseApplication;
|
||||||
|
/** Null when the applicant has no profile row (never expected in practice). */
|
||||||
|
applicant: ApplicationApplicant | null;
|
||||||
staff: ApplicationStaff[];
|
staff: ApplicationStaff[];
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
history: StatusHistoryEntry[];
|
history: StatusHistoryEntry[];
|
||||||
|
|||||||
1
libs/api/src/lib/features/location/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './location.types';
|
||||||
47
libs/api/src/lib/features/location/location.types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/** Shared location contract — mirrors the `iam.locations` tree in emaapi. */
|
||||||
|
|
||||||
|
import type { Bilingual } from '../licensing/licensing.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One node of the location tree.
|
||||||
|
*
|
||||||
|
* Both apps read the same `/locations` route, so the model lives here rather
|
||||||
|
* than in each app's own feature folder — the two hand-maintained copies had
|
||||||
|
* already drifted (the portal's lacked `locationType`, `children` and the
|
||||||
|
* timestamps, and its query accepted no `parentId` filter even though the
|
||||||
|
* backend supports one).
|
||||||
|
*
|
||||||
|
* `names` is `Bilingual`, the backend's `LocaleValidationDto`: seeded location
|
||||||
|
* names carry `om` and `so` alongside `en`/`am`, which the previous
|
||||||
|
* `{ en, am }` pair made unreachable.
|
||||||
|
*/
|
||||||
|
export interface Location {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
names: Bilingual;
|
||||||
|
locationTypeId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
locationType?: LocationType;
|
||||||
|
children?: Location[];
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A level in the tree. `level` orders them (City 1 → Sub-city 2 → Woreda 3 →
|
||||||
|
* Kebele 4); `code` is what both sides key behaviour off, so it is the field to
|
||||||
|
* match on rather than the display name.
|
||||||
|
*/
|
||||||
|
export interface LocationType {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
names: Bilingual;
|
||||||
|
level: number;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListResponse<T> {
|
||||||
|
count: number;
|
||||||
|
items: T[];
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ import { useNavigate, Link } from 'react-router-dom';
|
|||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
|
import { useErrorHandler, passwordSchema, PasswordRequirements, joinPersonName } from '@ema-platform/ui';
|
||||||
import { AuthShell } from '../components/AuthShell';
|
import { AuthShell } from '../components/AuthShell';
|
||||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||||
import type { AuthUser } from '../types/auth.types';
|
import type { AuthUser } from '../types/auth.types';
|
||||||
@@ -124,7 +124,7 @@ export function SignupPage() {
|
|||||||
username: values.username,
|
username: values.username,
|
||||||
phoneNumber: values.phoneNumber,
|
phoneNumber: values.phoneNumber,
|
||||||
userType: values.userType,
|
userType: values.userType,
|
||||||
name: { en: values.nameEn, am: values.nameAm ?? '' },
|
name: { en: joinPersonName(values), am: values.nameAm ?? '' },
|
||||||
password: values.password,
|
password: values.password,
|
||||||
confirmPassword: values.confirmPassword,
|
confirmPassword: values.confirmPassword,
|
||||||
};
|
};
|
||||||
@@ -213,23 +213,38 @@ export function SignupPage() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label={t('signup.firstNameLabel', 'First name')}
|
||||||
|
placeholder={t('signup.firstNamePlaceholder', 'Abebe')}
|
||||||
|
leftSection={<IconUser size={18} />}
|
||||||
|
error={errors.firstName?.message}
|
||||||
|
{...register('firstName')}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('signup.nameEnLabel', 'Full name (English)')}
|
label={t('signup.nameEnLabel', 'Full name (English)')}
|
||||||
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
|
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
|
||||||
leftSection={<IconUser size={18} />}
|
leftSection={<IconUser size={18} />}
|
||||||
error={errors.nameEn?.message}
|
error={errors.middleName?.message}
|
||||||
{...register('nameEn')}
|
{...register('middleName')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('signup.nameAmLabel', 'Name (Amharic)')}
|
label={t('signup.lastNameLabel', 'Last name')}
|
||||||
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
|
placeholder={t('signup.lastNamePlaceholder', 'Bekele')}
|
||||||
leftSection={<IconUser size={18} />}
|
leftSection={<IconUser size={18} />}
|
||||||
error={errors.nameAm?.message}
|
error={errors.lastName?.message}
|
||||||
{...register('nameAm')}
|
{...register('lastName')}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={t('signup.nameAmLabel', 'Name (Amharic)')}
|
||||||
|
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
|
||||||
|
leftSection={<IconUser size={18} />}
|
||||||
|
error={errors.nameAm?.message}
|
||||||
|
{...register('nameAm')}
|
||||||
|
/>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('signup.emailLabel', 'Email address')}
|
label={t('signup.emailLabel', 'Email address')}
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ export * from "./lib/feedback/use-error-handler";
|
|||||||
export * from "./lib/data/useServerTable";
|
export * from "./lib/data/useServerTable";
|
||||||
export * from "./lib/landing/LandingPage";
|
export * from "./lib/landing/LandingPage";
|
||||||
export * from "./lib/landing/landing-copy";
|
export * from "./lib/landing/landing-copy";
|
||||||
|
export * from "./lib/utils/person-name";
|
||||||
|
|||||||
19
libs/ui/src/lib/utils/person-name.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Splits and joins a person's name between the account's single `name.en`
|
||||||
|
* string and the profile's separate `firstName`/`middleName`/`lastName`
|
||||||
|
* fields.
|
||||||
|
*
|
||||||
|
* The account has no first/middle/last columns of its own (that model lives
|
||||||
|
* only on the profile), so this is the one place that heuristic lives —
|
||||||
|
* reused everywhere a name crosses that boundary: the signup form joins into
|
||||||
|
* it, the Identity Details wizard step and the Profile page's Personal tab
|
||||||
|
* both split out of it.
|
||||||
|
*/
|
||||||
|
export function splitPersonName(fullName: string) {
|
||||||
|
const [firstName = '', middleName = '', ...rest] = fullName.trim().split(/\s+/);
|
||||||
|
return { firstName, middleName, lastName: rest.join(' ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinPersonName(parts: { firstName: string; middleName?: string; lastName: string }) {
|
||||||
|
return [parts.firstName, parts.middleName, parts.lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
canvas: true
|
core-js: set this to true or false
|
||||||
core-js: false
|
esbuild: set this to true or false
|
||||||
esbuild: false
|
nx: set this to true or false
|
||||||
nx: false
|
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
{
|
{
|
||||||
"status": "failed",
|
"status": "passed",
|
||||||
"failedTests": [
|
"failedTests": []
|
||||||
"98dcbc0c174eb3697418-75794b7db9eaf01c737f",
|
|
||||||
"98dcbc0c174eb3697418-34fd1a52a2c14f879d3a",
|
|
||||||
"98dcbc0c174eb3697418-bd195edac5a95d796827",
|
|
||||||
"98dcbc0c174eb3697418-c505dae67d8cd7469ff3",
|
|
||||||
"98dcbc0c174eb3697418-ba62eb9d11839aca30c0",
|
|
||||||
"98dcbc0c174eb3697418-07ce101789b6b7b7985c",
|
|
||||||
"98dcbc0c174eb3697418-1d2cbda982bd085da606",
|
|
||||||
"98dcbc0c174eb3697418-46dd670046a70e730e93"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
- Following Playwright test failed.
|
|
||||||
- Explain why, be concise, respect Playwright best practices.
|
|
||||||
- Provide a snippet of code with the fix, if possible.
|
|
||||||
|
|
||||||
# Test info
|
|
||||||
|
|
||||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> opening the wizard creates the draft up front
|
|
||||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:206:7
|
|
||||||
|
|
||||||
# Error details
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
```
|
|
||||||
|
|
||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [ref=f1e3]:
|
|
||||||
- banner [ref=f1e4]:
|
|
||||||
- generic [ref=f1e5]:
|
|
||||||
- generic [ref=f1e6]:
|
|
||||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
|
||||||
- generic [ref=f1e10]:
|
|
||||||
- generic [ref=f1e11]: Dashboard
|
|
||||||
- generic [ref=f1e13]: Profile
|
|
||||||
- generic [ref=f1e17]:
|
|
||||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
|
||||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
|
||||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e27]: "1"
|
|
||||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
|
||||||
- navigation [ref=f1e34]:
|
|
||||||
- generic [ref=f1e35]:
|
|
||||||
- img "EMA" [ref=f1e36]
|
|
||||||
- generic [ref=f1e37]:
|
|
||||||
- paragraph [ref=f1e38]: EMA Portal
|
|
||||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
|
||||||
- generic [ref=f1e43]:
|
|
||||||
- generic [ref=f1e44]:
|
|
||||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
|
||||||
- generic [ref=f1e52] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e57]: Notifications
|
|
||||||
- generic "1 pending" [ref=f1e59]: "1"
|
|
||||||
- generic [ref=f1e61]:
|
|
||||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e63]: Licensing
|
|
||||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
|
||||||
- generic [ref=f1e73]:
|
|
||||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e75]: Seafarer Services
|
|
||||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
|
||||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
|
||||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
|
||||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
|
||||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
|
||||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
|
||||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
|
||||||
- generic [ref=f1e113]:
|
|
||||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e115]: Account
|
|
||||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
|
||||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
|
||||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
|
||||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
|
||||||
- main [ref=f1e143]:
|
|
||||||
- generic [ref=f1e145]:
|
|
||||||
- generic [ref=f1e147]:
|
|
||||||
- heading "My Profile" [level=2] [ref=f1e148]
|
|
||||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
|
||||||
- alert [ref=f1e150]:
|
|
||||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
|
||||||
- generic [ref=f1e159]:
|
|
||||||
- paragraph [ref=f1e161]: ES
|
|
||||||
- generic [ref=f1e162]:
|
|
||||||
- generic [ref=f1e163]:
|
|
||||||
- heading "E2E seafarer 3450" [level=4] [ref=f1e164]
|
|
||||||
- generic [ref=f1e165]: Unverified
|
|
||||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042323383450@example.test
|
|
||||||
- generic [ref=f1e172]: e2eseafarer1787042323383450
|
|
||||||
- generic "0% complete" [ref=f1e178]:
|
|
||||||
- paragraph [ref=f1e183]: 0%
|
|
||||||
- generic [ref=f1e184]:
|
|
||||||
- tablist [ref=f1e185]:
|
|
||||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
|
||||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
|
||||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
|
||||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
|
||||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
|
||||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
|
||||||
- tabpanel "Profile" [ref=f1e224]:
|
|
||||||
- generic [ref=f1e227]:
|
|
||||||
- generic [ref=f1e228]:
|
|
||||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
|
||||||
- paragraph [ref=f1e230]: Your professional maritime details
|
|
||||||
- generic [ref=f1e231]:
|
|
||||||
- generic [ref=f1e232]:
|
|
||||||
- generic [ref=f1e233]: Profession *
|
|
||||||
- textbox "Profession" [ref=f1e235]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: Master Mariner
|
|
||||||
- generic [ref=f1e236]:
|
|
||||||
- generic [ref=f1e237]: First Name *
|
|
||||||
- textbox "First Name" [ref=f1e239]:
|
|
||||||
- /placeholder: Enter first name
|
|
||||||
- text: Dawit
|
|
||||||
- generic [ref=f1e240]:
|
|
||||||
- generic [ref=f1e241]: Middle Name *
|
|
||||||
- textbox "Middle Name" [ref=f1e243]:
|
|
||||||
- /placeholder: Enter middle name
|
|
||||||
- text: Bekele
|
|
||||||
- generic [ref=f1e244]:
|
|
||||||
- generic [ref=f1e245]: Last Name *
|
|
||||||
- textbox "Last Name" [ref=f1e247]:
|
|
||||||
- /placeholder: Enter last name
|
|
||||||
- text: Tesfaye
|
|
||||||
- generic [ref=f1e248]:
|
|
||||||
- generic [ref=f1e249]: Gender *
|
|
||||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: MALE
|
|
||||||
- generic [ref=f1e252]:
|
|
||||||
- generic [ref=f1e253]: Date of Birth *
|
|
||||||
- generic [ref=f1e254]:
|
|
||||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e257]: EN
|
|
||||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
|
||||||
- button [ref=f1e261] [cursor=pointer]
|
|
||||||
- generic [ref=f1e266]:
|
|
||||||
- generic [ref=f1e267]: Place of Birth
|
|
||||||
- textbox "Place of Birth" [ref=f1e269]:
|
|
||||||
- /placeholder: City, Region
|
|
||||||
- generic [ref=f1e270]:
|
|
||||||
- generic [ref=f1e271]: Marital Status *
|
|
||||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: SINGLE
|
|
||||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
|
||||||
```
|
|
||||||
|
|
||||||
# Test source
|
|
||||||
|
|
||||||
```ts
|
|
||||||
46 | await openTab(page, 'Address');
|
|
||||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
|
||||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
|
||||||
49 | // A country select, not a free-text field.
|
|
||||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
|
||||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
|
||||||
52 | // never submits and no request is made for `save` to wait on.
|
|
||||||
53 | await page
|
|
||||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
|
||||||
55 | .fill('+251911234567');
|
|
||||||
56 | await save(page);
|
|
||||||
57 | }
|
|
||||||
58 |
|
|
||||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
|
||||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
|
||||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
|
||||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
|
||||||
63 | timeout: 15_000,
|
|
||||||
64 | });
|
|
||||||
65 | }
|
|
||||||
66 |
|
|
||||||
67 | /**
|
|
||||||
68 | * Picks a value from a Mantine select.
|
|
||||||
69 | *
|
|
||||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
|
||||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
|
||||||
72 | * names the control itself.
|
|
||||||
73 | */
|
|
||||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
|
||||||
75 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
76 | await page.getByRole('option', { name: option }).first().click();
|
|
||||||
77 | }
|
|
||||||
78 |
|
|
||||||
79 | /**
|
|
||||||
80 | * Sets the date of birth through the picker's own UI.
|
|
||||||
81 | *
|
|
||||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
|
||||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
|
||||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
|
||||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
|
||||||
86 | *
|
|
||||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
|
||||||
88 | * the caption dropdowns, then click the day.
|
|
||||||
89 | */
|
|
||||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
|
||||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
|
||||||
92 |
|
|
||||||
93 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
|
||||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
|
||||||
96 |
|
|
||||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
|
||||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
|
||||||
99 | await calendar
|
|
||||||
100 | .locator('select')
|
|
||||||
101 | .first()
|
|
||||||
102 | .selectOption({ index: month - 1 });
|
|
||||||
103 |
|
|
||||||
104 | // Each day is a button whose accessible name is the full date
|
|
||||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
|
||||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
|
||||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
|
||||||
108 | // re-renders the grid.
|
|
||||||
109 | const cell = calendar
|
|
||||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
|
||||||
111 | .first();
|
|
||||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
|
||||||
113 | await cell.click();
|
|
||||||
114 |
|
|
||||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
|
||||||
116 |
|
|
||||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
|
||||||
118 | // an empty field and the failure would surface later as a refused submit.
|
|
||||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
|
||||||
120 | timeout: 10_000,
|
|
||||||
121 | });
|
|
||||||
122 | }
|
|
||||||
123 |
|
|
||||||
124 | async function save(page: Page): Promise<void> {
|
|
||||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
|
||||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
|
||||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
|
||||||
128 | const saved = page.waitForResponse(
|
|
||||||
129 | (r) =>
|
|
||||||
130 | r.request().method() !== 'GET' &&
|
|
||||||
131 | r.status() < 400 &&
|
|
||||||
132 | /(profile|address|user)/i.test(r.url()),
|
|
||||||
133 | { timeout: 20_000 },
|
|
||||||
134 | );
|
|
||||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
|
||||||
136 |
|
|
||||||
137 | try {
|
|
||||||
138 | await saved;
|
|
||||||
139 | } catch (cause) {
|
|
||||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
|
||||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
|
||||||
142 | // that refused to submit. Surface the field errors instead.
|
|
||||||
143 | const messages = await page
|
|
||||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
|
||||||
145 | .allTextContents();
|
|
||||||
> 146 | throw new Error(
|
|
||||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
147 | messages.length
|
|
||||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
|
||||||
149 | : 'Save produced no request and reported no validation error.',
|
|
||||||
150 | { cause },
|
|
||||||
151 | );
|
|
||||||
152 | }
|
|
||||||
153 | }
|
|
||||||
154 |
|
|
||||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
|
||||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
|
||||||
157 | const offset = await signUp(page, applicant);
|
|
||||||
158 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
160 | await page
|
|
||||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
162 | .first()
|
|
||||||
163 | .check();
|
|
||||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
|
||||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
|
||||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
168 | await completeProfile(page);
|
|
||||||
169 | }
|
|
||||||
170 |
|
|
||||||
171 | test.describe('seafarer registration', () => {
|
|
||||||
172 | let applicant: Applicant;
|
|
||||||
173 |
|
|
||||||
174 | test.beforeEach(() => {
|
|
||||||
175 | applicant = newApplicant('seafarer');
|
|
||||||
176 | });
|
|
||||||
177 |
|
|
||||||
178 | test.afterEach(() => {
|
|
||||||
179 | deleteApplicant(applicant.email);
|
|
||||||
180 | });
|
|
||||||
181 |
|
|
||||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
|
||||||
183 | page,
|
|
||||||
184 | }) => {
|
|
||||||
185 | const offset = await signUp(page, applicant);
|
|
||||||
186 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
188 | await page
|
|
||||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
190 | .first()
|
|
||||||
191 | .check();
|
|
||||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
194 |
|
|
||||||
195 | // A new account holds none of the identity the registration is filled in
|
|
||||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
|
||||||
197 | await page.goto('/seafarer-registration');
|
|
||||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
199 |
|
|
||||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
|
||||||
201 | // decoration a deep link walks straight past.
|
|
||||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
204 | });
|
|
||||||
205 |
|
|
||||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
|
||||||
207 | await readyApplicant(page, applicant);
|
|
||||||
208 |
|
|
||||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
211 |
|
|
||||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
|
||||||
213 | // and closing the browser mid-wizard loses nothing.
|
|
||||||
214 | const number = await waitForApplication(applicant.email);
|
|
||||||
215 | expect(number).toMatch(/^SFR/);
|
|
||||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
|
||||||
217 | });
|
|
||||||
218 |
|
|
||||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
|
||||||
220 | page,
|
|
||||||
221 | }) => {
|
|
||||||
222 | await readyApplicant(page, applicant);
|
|
||||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
224 | const number = await waitForApplication(applicant.email);
|
|
||||||
225 | const id = idOf(number);
|
|
||||||
226 |
|
|
||||||
227 | await submit(id);
|
|
||||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
|
||||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
230 |
|
|
||||||
231 | // The licence course's middle stages have nothing to hold in a
|
|
||||||
232 | // registration, and the transition table is the authority regardless of
|
|
||||||
233 | // which endpoint is called.
|
|
||||||
234 | const refused = await runWorkflow(id, [
|
|
||||||
235 | { path: 'complete-review', expectFailure: true },
|
|
||||||
236 | { path: 'approve-documents', expectFailure: true },
|
|
||||||
237 | { path: 'record-inspection', expectFailure: true },
|
|
||||||
238 | ]);
|
|
||||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
|
||||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
241 | });
|
|
||||||
242 |
|
|
||||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
|
||||||
244 | page,
|
|
||||||
245 | }) => {
|
|
||||||
246 | await readyApplicant(page, applicant);
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
- Following Playwright test failed.
|
|
||||||
- Explain why, be concise, respect Playwright best practices.
|
|
||||||
- Provide a snippet of code with the fix, if possible.
|
|
||||||
|
|
||||||
# Test info
|
|
||||||
|
|
||||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> a registered seafarer cannot start a second registration
|
|
||||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:355:7
|
|
||||||
|
|
||||||
# Error details
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
```
|
|
||||||
|
|
||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [ref=f1e3]:
|
|
||||||
- banner [ref=f1e4]:
|
|
||||||
- generic [ref=f1e5]:
|
|
||||||
- generic [ref=f1e6]:
|
|
||||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
|
||||||
- generic [ref=f1e10]:
|
|
||||||
- generic [ref=f1e11]: Dashboard
|
|
||||||
- generic [ref=f1e13]: Profile
|
|
||||||
- generic [ref=f1e17]:
|
|
||||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
|
||||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
|
||||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e27]: "1"
|
|
||||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
|
||||||
- navigation [ref=f1e34]:
|
|
||||||
- generic [ref=f1e35]:
|
|
||||||
- img "EMA" [ref=f1e36]
|
|
||||||
- generic [ref=f1e37]:
|
|
||||||
- paragraph [ref=f1e38]: EMA Portal
|
|
||||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
|
||||||
- generic [ref=f1e43]:
|
|
||||||
- generic [ref=f1e44]:
|
|
||||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
|
||||||
- generic [ref=f1e52] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e57]: Notifications
|
|
||||||
- generic "1 pending" [ref=f1e59]: "1"
|
|
||||||
- generic [ref=f1e61]:
|
|
||||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e63]: Licensing
|
|
||||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
|
||||||
- generic [ref=f1e73]:
|
|
||||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e75]: Seafarer Services
|
|
||||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
|
||||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
|
||||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
|
||||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
|
||||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
|
||||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
|
||||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
|
||||||
- generic [ref=f1e113]:
|
|
||||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e115]: Account
|
|
||||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
|
||||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
|
||||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
|
||||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
|
||||||
- main [ref=f1e143]:
|
|
||||||
- generic [ref=f1e145]:
|
|
||||||
- generic [ref=f1e147]:
|
|
||||||
- heading "My Profile" [level=2] [ref=f1e148]
|
|
||||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
|
||||||
- alert [ref=f1e150]:
|
|
||||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
|
||||||
- generic [ref=f1e159]:
|
|
||||||
- paragraph [ref=f1e161]: ES
|
|
||||||
- generic [ref=f1e162]:
|
|
||||||
- generic [ref=f1e163]:
|
|
||||||
- heading "E2E seafarer 2475" [level=4] [ref=f1e164]
|
|
||||||
- generic [ref=f1e165]: Unverified
|
|
||||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042544962475@example.test
|
|
||||||
- generic [ref=f1e172]: e2eseafarer1787042544962475
|
|
||||||
- generic "0% complete" [ref=f1e178]:
|
|
||||||
- paragraph [ref=f1e183]: 0%
|
|
||||||
- generic [ref=f1e184]:
|
|
||||||
- tablist [ref=f1e185]:
|
|
||||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
|
||||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
|
||||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
|
||||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
|
||||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
|
||||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
|
||||||
- tabpanel "Profile" [ref=f1e224]:
|
|
||||||
- generic [ref=f1e227]:
|
|
||||||
- generic [ref=f1e228]:
|
|
||||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
|
||||||
- paragraph [ref=f1e230]: Your professional maritime details
|
|
||||||
- generic [ref=f1e231]:
|
|
||||||
- generic [ref=f1e232]:
|
|
||||||
- generic [ref=f1e233]: Profession *
|
|
||||||
- textbox "Profession" [ref=f1e235]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: Master Mariner
|
|
||||||
- generic [ref=f1e236]:
|
|
||||||
- generic [ref=f1e237]: First Name *
|
|
||||||
- textbox "First Name" [ref=f1e239]:
|
|
||||||
- /placeholder: Enter first name
|
|
||||||
- text: Dawit
|
|
||||||
- generic [ref=f1e240]:
|
|
||||||
- generic [ref=f1e241]: Middle Name *
|
|
||||||
- textbox "Middle Name" [ref=f1e243]:
|
|
||||||
- /placeholder: Enter middle name
|
|
||||||
- text: Bekele
|
|
||||||
- generic [ref=f1e244]:
|
|
||||||
- generic [ref=f1e245]: Last Name *
|
|
||||||
- textbox "Last Name" [ref=f1e247]:
|
|
||||||
- /placeholder: Enter last name
|
|
||||||
- text: Tesfaye
|
|
||||||
- generic [ref=f1e248]:
|
|
||||||
- generic [ref=f1e249]: Gender *
|
|
||||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: MALE
|
|
||||||
- generic [ref=f1e252]:
|
|
||||||
- generic [ref=f1e253]: Date of Birth *
|
|
||||||
- generic [ref=f1e254]:
|
|
||||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e257]: EN
|
|
||||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
|
||||||
- button [ref=f1e261] [cursor=pointer]
|
|
||||||
- generic [ref=f1e266]:
|
|
||||||
- generic [ref=f1e267]: Place of Birth
|
|
||||||
- textbox "Place of Birth" [ref=f1e269]:
|
|
||||||
- /placeholder: City, Region
|
|
||||||
- generic [ref=f1e270]:
|
|
||||||
- generic [ref=f1e271]: Marital Status *
|
|
||||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: SINGLE
|
|
||||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
|
||||||
```
|
|
||||||
|
|
||||||
# Test source
|
|
||||||
|
|
||||||
```ts
|
|
||||||
46 | await openTab(page, 'Address');
|
|
||||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
|
||||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
|
||||||
49 | // A country select, not a free-text field.
|
|
||||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
|
||||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
|
||||||
52 | // never submits and no request is made for `save` to wait on.
|
|
||||||
53 | await page
|
|
||||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
|
||||||
55 | .fill('+251911234567');
|
|
||||||
56 | await save(page);
|
|
||||||
57 | }
|
|
||||||
58 |
|
|
||||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
|
||||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
|
||||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
|
||||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
|
||||||
63 | timeout: 15_000,
|
|
||||||
64 | });
|
|
||||||
65 | }
|
|
||||||
66 |
|
|
||||||
67 | /**
|
|
||||||
68 | * Picks a value from a Mantine select.
|
|
||||||
69 | *
|
|
||||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
|
||||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
|
||||||
72 | * names the control itself.
|
|
||||||
73 | */
|
|
||||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
|
||||||
75 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
76 | await page.getByRole('option', { name: option }).first().click();
|
|
||||||
77 | }
|
|
||||||
78 |
|
|
||||||
79 | /**
|
|
||||||
80 | * Sets the date of birth through the picker's own UI.
|
|
||||||
81 | *
|
|
||||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
|
||||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
|
||||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
|
||||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
|
||||||
86 | *
|
|
||||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
|
||||||
88 | * the caption dropdowns, then click the day.
|
|
||||||
89 | */
|
|
||||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
|
||||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
|
||||||
92 |
|
|
||||||
93 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
|
||||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
|
||||||
96 |
|
|
||||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
|
||||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
|
||||||
99 | await calendar
|
|
||||||
100 | .locator('select')
|
|
||||||
101 | .first()
|
|
||||||
102 | .selectOption({ index: month - 1 });
|
|
||||||
103 |
|
|
||||||
104 | // Each day is a button whose accessible name is the full date
|
|
||||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
|
||||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
|
||||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
|
||||||
108 | // re-renders the grid.
|
|
||||||
109 | const cell = calendar
|
|
||||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
|
||||||
111 | .first();
|
|
||||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
|
||||||
113 | await cell.click();
|
|
||||||
114 |
|
|
||||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
|
||||||
116 |
|
|
||||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
|
||||||
118 | // an empty field and the failure would surface later as a refused submit.
|
|
||||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
|
||||||
120 | timeout: 10_000,
|
|
||||||
121 | });
|
|
||||||
122 | }
|
|
||||||
123 |
|
|
||||||
124 | async function save(page: Page): Promise<void> {
|
|
||||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
|
||||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
|
||||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
|
||||||
128 | const saved = page.waitForResponse(
|
|
||||||
129 | (r) =>
|
|
||||||
130 | r.request().method() !== 'GET' &&
|
|
||||||
131 | r.status() < 400 &&
|
|
||||||
132 | /(profile|address|user)/i.test(r.url()),
|
|
||||||
133 | { timeout: 20_000 },
|
|
||||||
134 | );
|
|
||||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
|
||||||
136 |
|
|
||||||
137 | try {
|
|
||||||
138 | await saved;
|
|
||||||
139 | } catch (cause) {
|
|
||||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
|
||||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
|
||||||
142 | // that refused to submit. Surface the field errors instead.
|
|
||||||
143 | const messages = await page
|
|
||||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
|
||||||
145 | .allTextContents();
|
|
||||||
> 146 | throw new Error(
|
|
||||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
147 | messages.length
|
|
||||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
|
||||||
149 | : 'Save produced no request and reported no validation error.',
|
|
||||||
150 | { cause },
|
|
||||||
151 | );
|
|
||||||
152 | }
|
|
||||||
153 | }
|
|
||||||
154 |
|
|
||||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
|
||||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
|
||||||
157 | const offset = await signUp(page, applicant);
|
|
||||||
158 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
160 | await page
|
|
||||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
162 | .first()
|
|
||||||
163 | .check();
|
|
||||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
|
||||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
|
||||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
168 | await completeProfile(page);
|
|
||||||
169 | }
|
|
||||||
170 |
|
|
||||||
171 | test.describe('seafarer registration', () => {
|
|
||||||
172 | let applicant: Applicant;
|
|
||||||
173 |
|
|
||||||
174 | test.beforeEach(() => {
|
|
||||||
175 | applicant = newApplicant('seafarer');
|
|
||||||
176 | });
|
|
||||||
177 |
|
|
||||||
178 | test.afterEach(() => {
|
|
||||||
179 | deleteApplicant(applicant.email);
|
|
||||||
180 | });
|
|
||||||
181 |
|
|
||||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
|
||||||
183 | page,
|
|
||||||
184 | }) => {
|
|
||||||
185 | const offset = await signUp(page, applicant);
|
|
||||||
186 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
188 | await page
|
|
||||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
190 | .first()
|
|
||||||
191 | .check();
|
|
||||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
194 |
|
|
||||||
195 | // A new account holds none of the identity the registration is filled in
|
|
||||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
|
||||||
197 | await page.goto('/seafarer-registration');
|
|
||||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
199 |
|
|
||||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
|
||||||
201 | // decoration a deep link walks straight past.
|
|
||||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
204 | });
|
|
||||||
205 |
|
|
||||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
|
||||||
207 | await readyApplicant(page, applicant);
|
|
||||||
208 |
|
|
||||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
211 |
|
|
||||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
|
||||||
213 | // and closing the browser mid-wizard loses nothing.
|
|
||||||
214 | const number = await waitForApplication(applicant.email);
|
|
||||||
215 | expect(number).toMatch(/^SFR/);
|
|
||||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
|
||||||
217 | });
|
|
||||||
218 |
|
|
||||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
|
||||||
220 | page,
|
|
||||||
221 | }) => {
|
|
||||||
222 | await readyApplicant(page, applicant);
|
|
||||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
224 | const number = await waitForApplication(applicant.email);
|
|
||||||
225 | const id = idOf(number);
|
|
||||||
226 |
|
|
||||||
227 | await submit(id);
|
|
||||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
|
||||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
230 |
|
|
||||||
231 | // The licence course's middle stages have nothing to hold in a
|
|
||||||
232 | // registration, and the transition table is the authority regardless of
|
|
||||||
233 | // which endpoint is called.
|
|
||||||
234 | const refused = await runWorkflow(id, [
|
|
||||||
235 | { path: 'complete-review', expectFailure: true },
|
|
||||||
236 | { path: 'approve-documents', expectFailure: true },
|
|
||||||
237 | { path: 'record-inspection', expectFailure: true },
|
|
||||||
238 | ]);
|
|
||||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
|
||||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
241 | });
|
|
||||||
242 |
|
|
||||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
|
||||||
244 | page,
|
|
||||||
245 | }) => {
|
|
||||||
246 | await readyApplicant(page, applicant);
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
- Following Playwright test failed.
|
|
||||||
- Explain why, be concise, respect Playwright best practices.
|
|
||||||
- Provide a snippet of code with the fix, if possible.
|
|
||||||
|
|
||||||
# Test info
|
|
||||||
|
|
||||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> a registration never reaches evaluation or inspection
|
|
||||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:219:7
|
|
||||||
|
|
||||||
# Error details
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
```
|
|
||||||
|
|
||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [ref=f1e3]:
|
|
||||||
- banner [ref=f1e4]:
|
|
||||||
- generic [ref=f1e5]:
|
|
||||||
- generic [ref=f1e6]:
|
|
||||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
|
||||||
- generic [ref=f1e10]:
|
|
||||||
- generic [ref=f1e11]: Dashboard
|
|
||||||
- generic [ref=f1e13]: Profile
|
|
||||||
- generic [ref=f1e17]:
|
|
||||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
|
||||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
|
||||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e27]: "1"
|
|
||||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
|
||||||
- navigation [ref=f1e34]:
|
|
||||||
- generic [ref=f1e35]:
|
|
||||||
- img "EMA" [ref=f1e36]
|
|
||||||
- generic [ref=f1e37]:
|
|
||||||
- paragraph [ref=f1e38]: EMA Portal
|
|
||||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
|
||||||
- generic [ref=f1e43]:
|
|
||||||
- generic [ref=f1e44]:
|
|
||||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
|
||||||
- generic [ref=f1e52] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e57]: Notifications
|
|
||||||
- generic "1 pending" [ref=f1e59]: "1"
|
|
||||||
- generic [ref=f1e61]:
|
|
||||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e63]: Licensing
|
|
||||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
|
||||||
- generic [ref=f1e73]:
|
|
||||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e75]: Seafarer Services
|
|
||||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
|
||||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
|
||||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
|
||||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
|
||||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
|
||||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
|
||||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
|
||||||
- generic [ref=f1e113]:
|
|
||||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e115]: Account
|
|
||||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
|
||||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
|
||||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
|
||||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
|
||||||
- main [ref=f1e143]:
|
|
||||||
- generic [ref=f1e145]:
|
|
||||||
- generic [ref=f1e147]:
|
|
||||||
- heading "My Profile" [level=2] [ref=f1e148]
|
|
||||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
|
||||||
- alert [ref=f1e150]:
|
|
||||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
|
||||||
- generic [ref=f1e159]:
|
|
||||||
- paragraph [ref=f1e161]: ES
|
|
||||||
- generic [ref=f1e162]:
|
|
||||||
- generic [ref=f1e163]:
|
|
||||||
- heading "E2E seafarer 8190" [level=4] [ref=f1e164]
|
|
||||||
- generic [ref=f1e165]: Unverified
|
|
||||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042357258190@example.test
|
|
||||||
- generic [ref=f1e172]: e2eseafarer1787042357258190
|
|
||||||
- generic "0% complete" [ref=f1e178]:
|
|
||||||
- paragraph [ref=f1e183]: 0%
|
|
||||||
- generic [ref=f1e184]:
|
|
||||||
- tablist [ref=f1e185]:
|
|
||||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
|
||||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
|
||||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
|
||||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
|
||||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
|
||||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
|
||||||
- tabpanel "Profile" [ref=f1e224]:
|
|
||||||
- generic [ref=f1e227]:
|
|
||||||
- generic [ref=f1e228]:
|
|
||||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
|
||||||
- paragraph [ref=f1e230]: Your professional maritime details
|
|
||||||
- generic [ref=f1e231]:
|
|
||||||
- generic [ref=f1e232]:
|
|
||||||
- generic [ref=f1e233]: Profession *
|
|
||||||
- textbox "Profession" [ref=f1e235]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: Master Mariner
|
|
||||||
- generic [ref=f1e236]:
|
|
||||||
- generic [ref=f1e237]: First Name *
|
|
||||||
- textbox "First Name" [ref=f1e239]:
|
|
||||||
- /placeholder: Enter first name
|
|
||||||
- text: Dawit
|
|
||||||
- generic [ref=f1e240]:
|
|
||||||
- generic [ref=f1e241]: Middle Name *
|
|
||||||
- textbox "Middle Name" [ref=f1e243]:
|
|
||||||
- /placeholder: Enter middle name
|
|
||||||
- text: Bekele
|
|
||||||
- generic [ref=f1e244]:
|
|
||||||
- generic [ref=f1e245]: Last Name *
|
|
||||||
- textbox "Last Name" [ref=f1e247]:
|
|
||||||
- /placeholder: Enter last name
|
|
||||||
- text: Tesfaye
|
|
||||||
- generic [ref=f1e248]:
|
|
||||||
- generic [ref=f1e249]: Gender *
|
|
||||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: MALE
|
|
||||||
- generic [ref=f1e252]:
|
|
||||||
- generic [ref=f1e253]: Date of Birth *
|
|
||||||
- generic [ref=f1e254]:
|
|
||||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e257]: EN
|
|
||||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
|
||||||
- button [ref=f1e261] [cursor=pointer]
|
|
||||||
- generic [ref=f1e266]:
|
|
||||||
- generic [ref=f1e267]: Place of Birth
|
|
||||||
- textbox "Place of Birth" [ref=f1e269]:
|
|
||||||
- /placeholder: City, Region
|
|
||||||
- generic [ref=f1e270]:
|
|
||||||
- generic [ref=f1e271]: Marital Status *
|
|
||||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: SINGLE
|
|
||||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
|
||||||
```
|
|
||||||
|
|
||||||
# Test source
|
|
||||||
|
|
||||||
```ts
|
|
||||||
46 | await openTab(page, 'Address');
|
|
||||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
|
||||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
|
||||||
49 | // A country select, not a free-text field.
|
|
||||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
|
||||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
|
||||||
52 | // never submits and no request is made for `save` to wait on.
|
|
||||||
53 | await page
|
|
||||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
|
||||||
55 | .fill('+251911234567');
|
|
||||||
56 | await save(page);
|
|
||||||
57 | }
|
|
||||||
58 |
|
|
||||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
|
||||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
|
||||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
|
||||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
|
||||||
63 | timeout: 15_000,
|
|
||||||
64 | });
|
|
||||||
65 | }
|
|
||||||
66 |
|
|
||||||
67 | /**
|
|
||||||
68 | * Picks a value from a Mantine select.
|
|
||||||
69 | *
|
|
||||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
|
||||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
|
||||||
72 | * names the control itself.
|
|
||||||
73 | */
|
|
||||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
|
||||||
75 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
76 | await page.getByRole('option', { name: option }).first().click();
|
|
||||||
77 | }
|
|
||||||
78 |
|
|
||||||
79 | /**
|
|
||||||
80 | * Sets the date of birth through the picker's own UI.
|
|
||||||
81 | *
|
|
||||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
|
||||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
|
||||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
|
||||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
|
||||||
86 | *
|
|
||||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
|
||||||
88 | * the caption dropdowns, then click the day.
|
|
||||||
89 | */
|
|
||||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
|
||||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
|
||||||
92 |
|
|
||||||
93 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
|
||||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
|
||||||
96 |
|
|
||||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
|
||||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
|
||||||
99 | await calendar
|
|
||||||
100 | .locator('select')
|
|
||||||
101 | .first()
|
|
||||||
102 | .selectOption({ index: month - 1 });
|
|
||||||
103 |
|
|
||||||
104 | // Each day is a button whose accessible name is the full date
|
|
||||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
|
||||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
|
||||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
|
||||||
108 | // re-renders the grid.
|
|
||||||
109 | const cell = calendar
|
|
||||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
|
||||||
111 | .first();
|
|
||||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
|
||||||
113 | await cell.click();
|
|
||||||
114 |
|
|
||||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
|
||||||
116 |
|
|
||||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
|
||||||
118 | // an empty field and the failure would surface later as a refused submit.
|
|
||||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
|
||||||
120 | timeout: 10_000,
|
|
||||||
121 | });
|
|
||||||
122 | }
|
|
||||||
123 |
|
|
||||||
124 | async function save(page: Page): Promise<void> {
|
|
||||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
|
||||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
|
||||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
|
||||||
128 | const saved = page.waitForResponse(
|
|
||||||
129 | (r) =>
|
|
||||||
130 | r.request().method() !== 'GET' &&
|
|
||||||
131 | r.status() < 400 &&
|
|
||||||
132 | /(profile|address|user)/i.test(r.url()),
|
|
||||||
133 | { timeout: 20_000 },
|
|
||||||
134 | );
|
|
||||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
|
||||||
136 |
|
|
||||||
137 | try {
|
|
||||||
138 | await saved;
|
|
||||||
139 | } catch (cause) {
|
|
||||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
|
||||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
|
||||||
142 | // that refused to submit. Surface the field errors instead.
|
|
||||||
143 | const messages = await page
|
|
||||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
|
||||||
145 | .allTextContents();
|
|
||||||
> 146 | throw new Error(
|
|
||||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
147 | messages.length
|
|
||||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
|
||||||
149 | : 'Save produced no request and reported no validation error.',
|
|
||||||
150 | { cause },
|
|
||||||
151 | );
|
|
||||||
152 | }
|
|
||||||
153 | }
|
|
||||||
154 |
|
|
||||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
|
||||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
|
||||||
157 | const offset = await signUp(page, applicant);
|
|
||||||
158 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
160 | await page
|
|
||||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
162 | .first()
|
|
||||||
163 | .check();
|
|
||||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
|
||||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
|
||||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
168 | await completeProfile(page);
|
|
||||||
169 | }
|
|
||||||
170 |
|
|
||||||
171 | test.describe('seafarer registration', () => {
|
|
||||||
172 | let applicant: Applicant;
|
|
||||||
173 |
|
|
||||||
174 | test.beforeEach(() => {
|
|
||||||
175 | applicant = newApplicant('seafarer');
|
|
||||||
176 | });
|
|
||||||
177 |
|
|
||||||
178 | test.afterEach(() => {
|
|
||||||
179 | deleteApplicant(applicant.email);
|
|
||||||
180 | });
|
|
||||||
181 |
|
|
||||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
|
||||||
183 | page,
|
|
||||||
184 | }) => {
|
|
||||||
185 | const offset = await signUp(page, applicant);
|
|
||||||
186 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
188 | await page
|
|
||||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
190 | .first()
|
|
||||||
191 | .check();
|
|
||||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
194 |
|
|
||||||
195 | // A new account holds none of the identity the registration is filled in
|
|
||||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
|
||||||
197 | await page.goto('/seafarer-registration');
|
|
||||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
199 |
|
|
||||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
|
||||||
201 | // decoration a deep link walks straight past.
|
|
||||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
204 | });
|
|
||||||
205 |
|
|
||||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
|
||||||
207 | await readyApplicant(page, applicant);
|
|
||||||
208 |
|
|
||||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
211 |
|
|
||||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
|
||||||
213 | // and closing the browser mid-wizard loses nothing.
|
|
||||||
214 | const number = await waitForApplication(applicant.email);
|
|
||||||
215 | expect(number).toMatch(/^SFR/);
|
|
||||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
|
||||||
217 | });
|
|
||||||
218 |
|
|
||||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
|
||||||
220 | page,
|
|
||||||
221 | }) => {
|
|
||||||
222 | await readyApplicant(page, applicant);
|
|
||||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
224 | const number = await waitForApplication(applicant.email);
|
|
||||||
225 | const id = idOf(number);
|
|
||||||
226 |
|
|
||||||
227 | await submit(id);
|
|
||||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
|
||||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
230 |
|
|
||||||
231 | // The licence course's middle stages have nothing to hold in a
|
|
||||||
232 | // registration, and the transition table is the authority regardless of
|
|
||||||
233 | // which endpoint is called.
|
|
||||||
234 | const refused = await runWorkflow(id, [
|
|
||||||
235 | { path: 'complete-review', expectFailure: true },
|
|
||||||
236 | { path: 'approve-documents', expectFailure: true },
|
|
||||||
237 | { path: 'record-inspection', expectFailure: true },
|
|
||||||
238 | ]);
|
|
||||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
|
||||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
241 | });
|
|
||||||
242 |
|
|
||||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
|
||||||
244 | page,
|
|
||||||
245 | }) => {
|
|
||||||
246 | await readyApplicant(page, applicant);
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
- Following Playwright test failed.
|
|
||||||
- Explain why, be concise, respect Playwright best practices.
|
|
||||||
- Provide a snippet of code with the fix, if possible.
|
|
||||||
|
|
||||||
# Test info
|
|
||||||
|
|
||||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> approval numbers the profile and opens both child applications
|
|
||||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:303:7
|
|
||||||
|
|
||||||
# Error details
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
```
|
|
||||||
|
|
||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [ref=f1e3]:
|
|
||||||
- banner [ref=f1e4]:
|
|
||||||
- generic [ref=f1e5]:
|
|
||||||
- generic [ref=f1e6]:
|
|
||||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
|
||||||
- generic [ref=f1e10]:
|
|
||||||
- generic [ref=f1e11]: Dashboard
|
|
||||||
- generic [ref=f1e13]: Profile
|
|
||||||
- generic [ref=f1e17]:
|
|
||||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
|
||||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
|
||||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e27]: "1"
|
|
||||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
|
||||||
- navigation [ref=f1e34]:
|
|
||||||
- generic [ref=f1e35]:
|
|
||||||
- img "EMA" [ref=f1e36]
|
|
||||||
- generic [ref=f1e37]:
|
|
||||||
- paragraph [ref=f1e38]: EMA Portal
|
|
||||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
|
||||||
- generic [ref=f1e43]:
|
|
||||||
- generic [ref=f1e44]:
|
|
||||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
|
||||||
- generic [ref=f1e52] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e57]: Notifications
|
|
||||||
- generic "1 pending" [ref=f1e59]: "1"
|
|
||||||
- generic [ref=f1e61]:
|
|
||||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e63]: Licensing
|
|
||||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
|
||||||
- generic [ref=f1e73]:
|
|
||||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e75]: Seafarer Services
|
|
||||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
|
||||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
|
||||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
|
||||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
|
||||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
|
||||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
|
||||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
|
||||||
- generic [ref=f1e113]:
|
|
||||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e115]: Account
|
|
||||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
|
||||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
|
||||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
|
||||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
|
||||||
- main [ref=f1e143]:
|
|
||||||
- generic [ref=f1e145]:
|
|
||||||
- generic [ref=f1e147]:
|
|
||||||
- heading "My Profile" [level=2] [ref=f1e148]
|
|
||||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
|
||||||
- alert [ref=f1e150]:
|
|
||||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
|
||||||
- generic [ref=f1e159]:
|
|
||||||
- paragraph [ref=f1e161]: ES
|
|
||||||
- generic [ref=f1e162]:
|
|
||||||
- generic [ref=f1e163]:
|
|
||||||
- heading "E2E seafarer 4609" [level=4] [ref=f1e164]
|
|
||||||
- generic [ref=f1e165]: Unverified
|
|
||||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042485274609@example.test
|
|
||||||
- generic [ref=f1e172]: e2eseafarer1787042485274609
|
|
||||||
- generic "0% complete" [ref=f1e178]:
|
|
||||||
- paragraph [ref=f1e183]: 0%
|
|
||||||
- generic [ref=f1e184]:
|
|
||||||
- tablist [ref=f1e185]:
|
|
||||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
|
||||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
|
||||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
|
||||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
|
||||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
|
||||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
|
||||||
- tabpanel "Profile" [ref=f1e224]:
|
|
||||||
- generic [ref=f1e227]:
|
|
||||||
- generic [ref=f1e228]:
|
|
||||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
|
||||||
- paragraph [ref=f1e230]: Your professional maritime details
|
|
||||||
- generic [ref=f1e231]:
|
|
||||||
- generic [ref=f1e232]:
|
|
||||||
- generic [ref=f1e233]: Profession *
|
|
||||||
- textbox "Profession" [ref=f1e235]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: Master Mariner
|
|
||||||
- generic [ref=f1e236]:
|
|
||||||
- generic [ref=f1e237]: First Name *
|
|
||||||
- textbox "First Name" [ref=f1e239]:
|
|
||||||
- /placeholder: Enter first name
|
|
||||||
- text: Dawit
|
|
||||||
- generic [ref=f1e240]:
|
|
||||||
- generic [ref=f1e241]: Middle Name *
|
|
||||||
- textbox "Middle Name" [ref=f1e243]:
|
|
||||||
- /placeholder: Enter middle name
|
|
||||||
- text: Bekele
|
|
||||||
- generic [ref=f1e244]:
|
|
||||||
- generic [ref=f1e245]: Last Name *
|
|
||||||
- textbox "Last Name" [ref=f1e247]:
|
|
||||||
- /placeholder: Enter last name
|
|
||||||
- text: Tesfaye
|
|
||||||
- generic [ref=f1e248]:
|
|
||||||
- generic [ref=f1e249]: Gender *
|
|
||||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: MALE
|
|
||||||
- generic [ref=f1e252]:
|
|
||||||
- generic [ref=f1e253]: Date of Birth *
|
|
||||||
- generic [ref=f1e254]:
|
|
||||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e257]: EN
|
|
||||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
|
||||||
- button [ref=f1e261] [cursor=pointer]
|
|
||||||
- generic [ref=f1e266]:
|
|
||||||
- generic [ref=f1e267]: Place of Birth
|
|
||||||
- textbox "Place of Birth" [ref=f1e269]:
|
|
||||||
- /placeholder: City, Region
|
|
||||||
- generic [ref=f1e270]:
|
|
||||||
- generic [ref=f1e271]: Marital Status *
|
|
||||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: SINGLE
|
|
||||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
|
||||||
```
|
|
||||||
|
|
||||||
# Test source
|
|
||||||
|
|
||||||
```ts
|
|
||||||
46 | await openTab(page, 'Address');
|
|
||||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
|
||||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
|
||||||
49 | // A country select, not a free-text field.
|
|
||||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
|
||||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
|
||||||
52 | // never submits and no request is made for `save` to wait on.
|
|
||||||
53 | await page
|
|
||||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
|
||||||
55 | .fill('+251911234567');
|
|
||||||
56 | await save(page);
|
|
||||||
57 | }
|
|
||||||
58 |
|
|
||||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
|
||||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
|
||||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
|
||||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
|
||||||
63 | timeout: 15_000,
|
|
||||||
64 | });
|
|
||||||
65 | }
|
|
||||||
66 |
|
|
||||||
67 | /**
|
|
||||||
68 | * Picks a value from a Mantine select.
|
|
||||||
69 | *
|
|
||||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
|
||||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
|
||||||
72 | * names the control itself.
|
|
||||||
73 | */
|
|
||||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
|
||||||
75 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
76 | await page.getByRole('option', { name: option }).first().click();
|
|
||||||
77 | }
|
|
||||||
78 |
|
|
||||||
79 | /**
|
|
||||||
80 | * Sets the date of birth through the picker's own UI.
|
|
||||||
81 | *
|
|
||||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
|
||||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
|
||||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
|
||||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
|
||||||
86 | *
|
|
||||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
|
||||||
88 | * the caption dropdowns, then click the day.
|
|
||||||
89 | */
|
|
||||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
|
||||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
|
||||||
92 |
|
|
||||||
93 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
|
||||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
|
||||||
96 |
|
|
||||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
|
||||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
|
||||||
99 | await calendar
|
|
||||||
100 | .locator('select')
|
|
||||||
101 | .first()
|
|
||||||
102 | .selectOption({ index: month - 1 });
|
|
||||||
103 |
|
|
||||||
104 | // Each day is a button whose accessible name is the full date
|
|
||||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
|
||||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
|
||||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
|
||||||
108 | // re-renders the grid.
|
|
||||||
109 | const cell = calendar
|
|
||||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
|
||||||
111 | .first();
|
|
||||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
|
||||||
113 | await cell.click();
|
|
||||||
114 |
|
|
||||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
|
||||||
116 |
|
|
||||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
|
||||||
118 | // an empty field and the failure would surface later as a refused submit.
|
|
||||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
|
||||||
120 | timeout: 10_000,
|
|
||||||
121 | });
|
|
||||||
122 | }
|
|
||||||
123 |
|
|
||||||
124 | async function save(page: Page): Promise<void> {
|
|
||||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
|
||||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
|
||||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
|
||||||
128 | const saved = page.waitForResponse(
|
|
||||||
129 | (r) =>
|
|
||||||
130 | r.request().method() !== 'GET' &&
|
|
||||||
131 | r.status() < 400 &&
|
|
||||||
132 | /(profile|address|user)/i.test(r.url()),
|
|
||||||
133 | { timeout: 20_000 },
|
|
||||||
134 | );
|
|
||||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
|
||||||
136 |
|
|
||||||
137 | try {
|
|
||||||
138 | await saved;
|
|
||||||
139 | } catch (cause) {
|
|
||||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
|
||||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
|
||||||
142 | // that refused to submit. Surface the field errors instead.
|
|
||||||
143 | const messages = await page
|
|
||||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
|
||||||
145 | .allTextContents();
|
|
||||||
> 146 | throw new Error(
|
|
||||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
147 | messages.length
|
|
||||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
|
||||||
149 | : 'Save produced no request and reported no validation error.',
|
|
||||||
150 | { cause },
|
|
||||||
151 | );
|
|
||||||
152 | }
|
|
||||||
153 | }
|
|
||||||
154 |
|
|
||||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
|
||||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
|
||||||
157 | const offset = await signUp(page, applicant);
|
|
||||||
158 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
160 | await page
|
|
||||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
162 | .first()
|
|
||||||
163 | .check();
|
|
||||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
|
||||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
|
||||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
168 | await completeProfile(page);
|
|
||||||
169 | }
|
|
||||||
170 |
|
|
||||||
171 | test.describe('seafarer registration', () => {
|
|
||||||
172 | let applicant: Applicant;
|
|
||||||
173 |
|
|
||||||
174 | test.beforeEach(() => {
|
|
||||||
175 | applicant = newApplicant('seafarer');
|
|
||||||
176 | });
|
|
||||||
177 |
|
|
||||||
178 | test.afterEach(() => {
|
|
||||||
179 | deleteApplicant(applicant.email);
|
|
||||||
180 | });
|
|
||||||
181 |
|
|
||||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
|
||||||
183 | page,
|
|
||||||
184 | }) => {
|
|
||||||
185 | const offset = await signUp(page, applicant);
|
|
||||||
186 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
188 | await page
|
|
||||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
190 | .first()
|
|
||||||
191 | .check();
|
|
||||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
194 |
|
|
||||||
195 | // A new account holds none of the identity the registration is filled in
|
|
||||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
|
||||||
197 | await page.goto('/seafarer-registration');
|
|
||||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
199 |
|
|
||||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
|
||||||
201 | // decoration a deep link walks straight past.
|
|
||||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
204 | });
|
|
||||||
205 |
|
|
||||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
|
||||||
207 | await readyApplicant(page, applicant);
|
|
||||||
208 |
|
|
||||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
211 |
|
|
||||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
|
||||||
213 | // and closing the browser mid-wizard loses nothing.
|
|
||||||
214 | const number = await waitForApplication(applicant.email);
|
|
||||||
215 | expect(number).toMatch(/^SFR/);
|
|
||||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
|
||||||
217 | });
|
|
||||||
218 |
|
|
||||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
|
||||||
220 | page,
|
|
||||||
221 | }) => {
|
|
||||||
222 | await readyApplicant(page, applicant);
|
|
||||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
224 | const number = await waitForApplication(applicant.email);
|
|
||||||
225 | const id = idOf(number);
|
|
||||||
226 |
|
|
||||||
227 | await submit(id);
|
|
||||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
|
||||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
230 |
|
|
||||||
231 | // The licence course's middle stages have nothing to hold in a
|
|
||||||
232 | // registration, and the transition table is the authority regardless of
|
|
||||||
233 | // which endpoint is called.
|
|
||||||
234 | const refused = await runWorkflow(id, [
|
|
||||||
235 | { path: 'complete-review', expectFailure: true },
|
|
||||||
236 | { path: 'approve-documents', expectFailure: true },
|
|
||||||
237 | { path: 'record-inspection', expectFailure: true },
|
|
||||||
238 | ]);
|
|
||||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
|
||||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
241 | });
|
|
||||||
242 |
|
|
||||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
|
||||||
244 | page,
|
|
||||||
245 | }) => {
|
|
||||||
246 | await readyApplicant(page, applicant);
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
- Following Playwright test failed.
|
|
||||||
- Explain why, be concise, respect Playwright best practices.
|
|
||||||
- Provide a snippet of code with the fix, if possible.
|
|
||||||
|
|
||||||
# Test info
|
|
||||||
|
|
||||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> a re-fired approval renumbers nobody and opens no second pair
|
|
||||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:336:7
|
|
||||||
|
|
||||||
# Error details
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
```
|
|
||||||
|
|
||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [ref=f1e3]:
|
|
||||||
- banner [ref=f1e4]:
|
|
||||||
- generic [ref=f1e5]:
|
|
||||||
- generic [ref=f1e6]:
|
|
||||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
|
||||||
- generic [ref=f1e10]:
|
|
||||||
- generic [ref=f1e11]: Dashboard
|
|
||||||
- generic [ref=f1e13]: Profile
|
|
||||||
- generic [ref=f1e17]:
|
|
||||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
|
||||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
|
||||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e27]: "1"
|
|
||||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
|
||||||
- navigation [ref=f1e34]:
|
|
||||||
- generic [ref=f1e35]:
|
|
||||||
- img "EMA" [ref=f1e36]
|
|
||||||
- generic [ref=f1e37]:
|
|
||||||
- paragraph [ref=f1e38]: EMA Portal
|
|
||||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
|
||||||
- generic [ref=f1e43]:
|
|
||||||
- generic [ref=f1e44]:
|
|
||||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
|
||||||
- generic [ref=f1e52] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e57]: Notifications
|
|
||||||
- generic "1 pending" [ref=f1e59]: "1"
|
|
||||||
- generic [ref=f1e61]:
|
|
||||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e63]: Licensing
|
|
||||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
|
||||||
- generic [ref=f1e73]:
|
|
||||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e75]: Seafarer Services
|
|
||||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
|
||||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
|
||||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
|
||||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
|
||||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
|
||||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
|
||||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
|
||||||
- generic [ref=f1e113]:
|
|
||||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e115]: Account
|
|
||||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
|
||||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
|
||||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
|
||||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
|
||||||
- main [ref=f1e143]:
|
|
||||||
- generic [ref=f1e145]:
|
|
||||||
- generic [ref=f1e147]:
|
|
||||||
- heading "My Profile" [level=2] [ref=f1e148]
|
|
||||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
|
||||||
- alert [ref=f1e150]:
|
|
||||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
|
||||||
- generic [ref=f1e159]:
|
|
||||||
- paragraph [ref=f1e161]: ES
|
|
||||||
- generic [ref=f1e162]:
|
|
||||||
- generic [ref=f1e163]:
|
|
||||||
- heading "E2E seafarer 2538" [level=4] [ref=f1e164]
|
|
||||||
- generic [ref=f1e165]: Unverified
|
|
||||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042515032538@example.test
|
|
||||||
- generic [ref=f1e172]: e2eseafarer1787042515032538
|
|
||||||
- generic "0% complete" [ref=f1e178]:
|
|
||||||
- paragraph [ref=f1e183]: 0%
|
|
||||||
- generic [ref=f1e184]:
|
|
||||||
- tablist [ref=f1e185]:
|
|
||||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
|
||||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
|
||||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
|
||||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
|
||||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
|
||||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
|
||||||
- tabpanel "Profile" [ref=f1e224]:
|
|
||||||
- generic [ref=f1e227]:
|
|
||||||
- generic [ref=f1e228]:
|
|
||||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
|
||||||
- paragraph [ref=f1e230]: Your professional maritime details
|
|
||||||
- generic [ref=f1e231]:
|
|
||||||
- generic [ref=f1e232]:
|
|
||||||
- generic [ref=f1e233]: Profession *
|
|
||||||
- textbox "Profession" [ref=f1e235]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: Master Mariner
|
|
||||||
- generic [ref=f1e236]:
|
|
||||||
- generic [ref=f1e237]: First Name *
|
|
||||||
- textbox "First Name" [ref=f1e239]:
|
|
||||||
- /placeholder: Enter first name
|
|
||||||
- text: Dawit
|
|
||||||
- generic [ref=f1e240]:
|
|
||||||
- generic [ref=f1e241]: Middle Name *
|
|
||||||
- textbox "Middle Name" [ref=f1e243]:
|
|
||||||
- /placeholder: Enter middle name
|
|
||||||
- text: Bekele
|
|
||||||
- generic [ref=f1e244]:
|
|
||||||
- generic [ref=f1e245]: Last Name *
|
|
||||||
- textbox "Last Name" [ref=f1e247]:
|
|
||||||
- /placeholder: Enter last name
|
|
||||||
- text: Tesfaye
|
|
||||||
- generic [ref=f1e248]:
|
|
||||||
- generic [ref=f1e249]: Gender *
|
|
||||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: MALE
|
|
||||||
- generic [ref=f1e252]:
|
|
||||||
- generic [ref=f1e253]: Date of Birth *
|
|
||||||
- generic [ref=f1e254]:
|
|
||||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e257]: EN
|
|
||||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
|
||||||
- button [ref=f1e261] [cursor=pointer]
|
|
||||||
- generic [ref=f1e266]:
|
|
||||||
- generic [ref=f1e267]: Place of Birth
|
|
||||||
- textbox "Place of Birth" [ref=f1e269]:
|
|
||||||
- /placeholder: City, Region
|
|
||||||
- generic [ref=f1e270]:
|
|
||||||
- generic [ref=f1e271]: Marital Status *
|
|
||||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: SINGLE
|
|
||||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
|
||||||
```
|
|
||||||
|
|
||||||
# Test source
|
|
||||||
|
|
||||||
```ts
|
|
||||||
46 | await openTab(page, 'Address');
|
|
||||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
|
||||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
|
||||||
49 | // A country select, not a free-text field.
|
|
||||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
|
||||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
|
||||||
52 | // never submits and no request is made for `save` to wait on.
|
|
||||||
53 | await page
|
|
||||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
|
||||||
55 | .fill('+251911234567');
|
|
||||||
56 | await save(page);
|
|
||||||
57 | }
|
|
||||||
58 |
|
|
||||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
|
||||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
|
||||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
|
||||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
|
||||||
63 | timeout: 15_000,
|
|
||||||
64 | });
|
|
||||||
65 | }
|
|
||||||
66 |
|
|
||||||
67 | /**
|
|
||||||
68 | * Picks a value from a Mantine select.
|
|
||||||
69 | *
|
|
||||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
|
||||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
|
||||||
72 | * names the control itself.
|
|
||||||
73 | */
|
|
||||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
|
||||||
75 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
76 | await page.getByRole('option', { name: option }).first().click();
|
|
||||||
77 | }
|
|
||||||
78 |
|
|
||||||
79 | /**
|
|
||||||
80 | * Sets the date of birth through the picker's own UI.
|
|
||||||
81 | *
|
|
||||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
|
||||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
|
||||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
|
||||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
|
||||||
86 | *
|
|
||||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
|
||||||
88 | * the caption dropdowns, then click the day.
|
|
||||||
89 | */
|
|
||||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
|
||||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
|
||||||
92 |
|
|
||||||
93 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
|
||||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
|
||||||
96 |
|
|
||||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
|
||||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
|
||||||
99 | await calendar
|
|
||||||
100 | .locator('select')
|
|
||||||
101 | .first()
|
|
||||||
102 | .selectOption({ index: month - 1 });
|
|
||||||
103 |
|
|
||||||
104 | // Each day is a button whose accessible name is the full date
|
|
||||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
|
||||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
|
||||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
|
||||||
108 | // re-renders the grid.
|
|
||||||
109 | const cell = calendar
|
|
||||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
|
||||||
111 | .first();
|
|
||||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
|
||||||
113 | await cell.click();
|
|
||||||
114 |
|
|
||||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
|
||||||
116 |
|
|
||||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
|
||||||
118 | // an empty field and the failure would surface later as a refused submit.
|
|
||||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
|
||||||
120 | timeout: 10_000,
|
|
||||||
121 | });
|
|
||||||
122 | }
|
|
||||||
123 |
|
|
||||||
124 | async function save(page: Page): Promise<void> {
|
|
||||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
|
||||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
|
||||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
|
||||||
128 | const saved = page.waitForResponse(
|
|
||||||
129 | (r) =>
|
|
||||||
130 | r.request().method() !== 'GET' &&
|
|
||||||
131 | r.status() < 400 &&
|
|
||||||
132 | /(profile|address|user)/i.test(r.url()),
|
|
||||||
133 | { timeout: 20_000 },
|
|
||||||
134 | );
|
|
||||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
|
||||||
136 |
|
|
||||||
137 | try {
|
|
||||||
138 | await saved;
|
|
||||||
139 | } catch (cause) {
|
|
||||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
|
||||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
|
||||||
142 | // that refused to submit. Surface the field errors instead.
|
|
||||||
143 | const messages = await page
|
|
||||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
|
||||||
145 | .allTextContents();
|
|
||||||
> 146 | throw new Error(
|
|
||||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
147 | messages.length
|
|
||||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
|
||||||
149 | : 'Save produced no request and reported no validation error.',
|
|
||||||
150 | { cause },
|
|
||||||
151 | );
|
|
||||||
152 | }
|
|
||||||
153 | }
|
|
||||||
154 |
|
|
||||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
|
||||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
|
||||||
157 | const offset = await signUp(page, applicant);
|
|
||||||
158 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
160 | await page
|
|
||||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
162 | .first()
|
|
||||||
163 | .check();
|
|
||||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
|
||||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
|
||||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
168 | await completeProfile(page);
|
|
||||||
169 | }
|
|
||||||
170 |
|
|
||||||
171 | test.describe('seafarer registration', () => {
|
|
||||||
172 | let applicant: Applicant;
|
|
||||||
173 |
|
|
||||||
174 | test.beforeEach(() => {
|
|
||||||
175 | applicant = newApplicant('seafarer');
|
|
||||||
176 | });
|
|
||||||
177 |
|
|
||||||
178 | test.afterEach(() => {
|
|
||||||
179 | deleteApplicant(applicant.email);
|
|
||||||
180 | });
|
|
||||||
181 |
|
|
||||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
|
||||||
183 | page,
|
|
||||||
184 | }) => {
|
|
||||||
185 | const offset = await signUp(page, applicant);
|
|
||||||
186 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
188 | await page
|
|
||||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
190 | .first()
|
|
||||||
191 | .check();
|
|
||||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
194 |
|
|
||||||
195 | // A new account holds none of the identity the registration is filled in
|
|
||||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
|
||||||
197 | await page.goto('/seafarer-registration');
|
|
||||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
199 |
|
|
||||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
|
||||||
201 | // decoration a deep link walks straight past.
|
|
||||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
204 | });
|
|
||||||
205 |
|
|
||||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
|
||||||
207 | await readyApplicant(page, applicant);
|
|
||||||
208 |
|
|
||||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
211 |
|
|
||||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
|
||||||
213 | // and closing the browser mid-wizard loses nothing.
|
|
||||||
214 | const number = await waitForApplication(applicant.email);
|
|
||||||
215 | expect(number).toMatch(/^SFR/);
|
|
||||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
|
||||||
217 | });
|
|
||||||
218 |
|
|
||||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
|
||||||
220 | page,
|
|
||||||
221 | }) => {
|
|
||||||
222 | await readyApplicant(page, applicant);
|
|
||||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
224 | const number = await waitForApplication(applicant.email);
|
|
||||||
225 | const id = idOf(number);
|
|
||||||
226 |
|
|
||||||
227 | await submit(id);
|
|
||||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
|
||||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
230 |
|
|
||||||
231 | // The licence course's middle stages have nothing to hold in a
|
|
||||||
232 | // registration, and the transition table is the authority regardless of
|
|
||||||
233 | // which endpoint is called.
|
|
||||||
234 | const refused = await runWorkflow(id, [
|
|
||||||
235 | { path: 'complete-review', expectFailure: true },
|
|
||||||
236 | { path: 'approve-documents', expectFailure: true },
|
|
||||||
237 | { path: 'record-inspection', expectFailure: true },
|
|
||||||
238 | ]);
|
|
||||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
|
||||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
241 | });
|
|
||||||
242 |
|
|
||||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
|
||||||
244 | page,
|
|
||||||
245 | }) => {
|
|
||||||
246 | await readyApplicant(page, applicant);
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
- Following Playwright test failed.
|
|
||||||
- Explain why, be concise, respect Playwright best practices.
|
|
||||||
- Provide a snippet of code with the fix, if possible.
|
|
||||||
|
|
||||||
# Test info
|
|
||||||
|
|
||||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> an officer can return a registration for correction and take it back
|
|
||||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:243:7
|
|
||||||
|
|
||||||
# Error details
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
```
|
|
||||||
|
|
||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [ref=f1e3]:
|
|
||||||
- banner [ref=f1e4]:
|
|
||||||
- generic [ref=f1e5]:
|
|
||||||
- generic [ref=f1e6]:
|
|
||||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
|
||||||
- generic [ref=f1e10]:
|
|
||||||
- generic [ref=f1e11]: Dashboard
|
|
||||||
- generic [ref=f1e13]: Profile
|
|
||||||
- generic [ref=f1e17]:
|
|
||||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
|
||||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
|
||||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e27]: "1"
|
|
||||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
|
||||||
- navigation [ref=f1e34]:
|
|
||||||
- generic [ref=f1e35]:
|
|
||||||
- img "EMA" [ref=f1e36]
|
|
||||||
- generic [ref=f1e37]:
|
|
||||||
- paragraph [ref=f1e38]: EMA Portal
|
|
||||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
|
||||||
- generic [ref=f1e43]:
|
|
||||||
- generic [ref=f1e44]:
|
|
||||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
|
||||||
- generic [ref=f1e52] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e57]: Notifications
|
|
||||||
- generic "1 pending" [ref=f1e59]: "1"
|
|
||||||
- generic [ref=f1e61]:
|
|
||||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e63]: Licensing
|
|
||||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
|
||||||
- generic [ref=f1e73]:
|
|
||||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e75]: Seafarer Services
|
|
||||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
|
||||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
|
||||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
|
||||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
|
||||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
|
||||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
|
||||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
|
||||||
- generic [ref=f1e113]:
|
|
||||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e115]: Account
|
|
||||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
|
||||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
|
||||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
|
||||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
|
||||||
- main [ref=f1e143]:
|
|
||||||
- generic [ref=f1e145]:
|
|
||||||
- generic [ref=f1e147]:
|
|
||||||
- heading "My Profile" [level=2] [ref=f1e148]
|
|
||||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
|
||||||
- alert [ref=f1e150]:
|
|
||||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
|
||||||
- generic [ref=f1e159]:
|
|
||||||
- paragraph [ref=f1e161]: ES
|
|
||||||
- generic [ref=f1e162]:
|
|
||||||
- generic [ref=f1e163]:
|
|
||||||
- heading "E2E seafarer 5517" [level=4] [ref=f1e164]
|
|
||||||
- generic [ref=f1e165]: Unverified
|
|
||||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042391965517@example.test
|
|
||||||
- generic [ref=f1e172]: e2eseafarer1787042391965517
|
|
||||||
- generic "0% complete" [ref=f1e178]:
|
|
||||||
- paragraph [ref=f1e183]: 0%
|
|
||||||
- generic [ref=f1e184]:
|
|
||||||
- tablist [ref=f1e185]:
|
|
||||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
|
||||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
|
||||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
|
||||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
|
||||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
|
||||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
|
||||||
- tabpanel "Profile" [ref=f1e224]:
|
|
||||||
- generic [ref=f1e227]:
|
|
||||||
- generic [ref=f1e228]:
|
|
||||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
|
||||||
- paragraph [ref=f1e230]: Your professional maritime details
|
|
||||||
- generic [ref=f1e231]:
|
|
||||||
- generic [ref=f1e232]:
|
|
||||||
- generic [ref=f1e233]: Profession *
|
|
||||||
- textbox "Profession" [ref=f1e235]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: Master Mariner
|
|
||||||
- generic [ref=f1e236]:
|
|
||||||
- generic [ref=f1e237]: First Name *
|
|
||||||
- textbox "First Name" [ref=f1e239]:
|
|
||||||
- /placeholder: Enter first name
|
|
||||||
- text: Dawit
|
|
||||||
- generic [ref=f1e240]:
|
|
||||||
- generic [ref=f1e241]: Middle Name *
|
|
||||||
- textbox "Middle Name" [ref=f1e243]:
|
|
||||||
- /placeholder: Enter middle name
|
|
||||||
- text: Bekele
|
|
||||||
- generic [ref=f1e244]:
|
|
||||||
- generic [ref=f1e245]: Last Name *
|
|
||||||
- textbox "Last Name" [ref=f1e247]:
|
|
||||||
- /placeholder: Enter last name
|
|
||||||
- text: Tesfaye
|
|
||||||
- generic [ref=f1e248]:
|
|
||||||
- generic [ref=f1e249]: Gender *
|
|
||||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: MALE
|
|
||||||
- generic [ref=f1e252]:
|
|
||||||
- generic [ref=f1e253]: Date of Birth *
|
|
||||||
- generic [ref=f1e254]:
|
|
||||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e257]: EN
|
|
||||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
|
||||||
- button [ref=f1e261] [cursor=pointer]
|
|
||||||
- generic [ref=f1e266]:
|
|
||||||
- generic [ref=f1e267]: Place of Birth
|
|
||||||
- textbox "Place of Birth" [ref=f1e269]:
|
|
||||||
- /placeholder: City, Region
|
|
||||||
- generic [ref=f1e270]:
|
|
||||||
- generic [ref=f1e271]: Marital Status *
|
|
||||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: SINGLE
|
|
||||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
|
||||||
```
|
|
||||||
|
|
||||||
# Test source
|
|
||||||
|
|
||||||
```ts
|
|
||||||
46 | await openTab(page, 'Address');
|
|
||||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
|
||||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
|
||||||
49 | // A country select, not a free-text field.
|
|
||||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
|
||||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
|
||||||
52 | // never submits and no request is made for `save` to wait on.
|
|
||||||
53 | await page
|
|
||||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
|
||||||
55 | .fill('+251911234567');
|
|
||||||
56 | await save(page);
|
|
||||||
57 | }
|
|
||||||
58 |
|
|
||||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
|
||||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
|
||||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
|
||||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
|
||||||
63 | timeout: 15_000,
|
|
||||||
64 | });
|
|
||||||
65 | }
|
|
||||||
66 |
|
|
||||||
67 | /**
|
|
||||||
68 | * Picks a value from a Mantine select.
|
|
||||||
69 | *
|
|
||||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
|
||||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
|
||||||
72 | * names the control itself.
|
|
||||||
73 | */
|
|
||||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
|
||||||
75 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
76 | await page.getByRole('option', { name: option }).first().click();
|
|
||||||
77 | }
|
|
||||||
78 |
|
|
||||||
79 | /**
|
|
||||||
80 | * Sets the date of birth through the picker's own UI.
|
|
||||||
81 | *
|
|
||||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
|
||||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
|
||||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
|
||||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
|
||||||
86 | *
|
|
||||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
|
||||||
88 | * the caption dropdowns, then click the day.
|
|
||||||
89 | */
|
|
||||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
|
||||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
|
||||||
92 |
|
|
||||||
93 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
|
||||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
|
||||||
96 |
|
|
||||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
|
||||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
|
||||||
99 | await calendar
|
|
||||||
100 | .locator('select')
|
|
||||||
101 | .first()
|
|
||||||
102 | .selectOption({ index: month - 1 });
|
|
||||||
103 |
|
|
||||||
104 | // Each day is a button whose accessible name is the full date
|
|
||||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
|
||||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
|
||||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
|
||||||
108 | // re-renders the grid.
|
|
||||||
109 | const cell = calendar
|
|
||||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
|
||||||
111 | .first();
|
|
||||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
|
||||||
113 | await cell.click();
|
|
||||||
114 |
|
|
||||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
|
||||||
116 |
|
|
||||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
|
||||||
118 | // an empty field and the failure would surface later as a refused submit.
|
|
||||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
|
||||||
120 | timeout: 10_000,
|
|
||||||
121 | });
|
|
||||||
122 | }
|
|
||||||
123 |
|
|
||||||
124 | async function save(page: Page): Promise<void> {
|
|
||||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
|
||||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
|
||||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
|
||||||
128 | const saved = page.waitForResponse(
|
|
||||||
129 | (r) =>
|
|
||||||
130 | r.request().method() !== 'GET' &&
|
|
||||||
131 | r.status() < 400 &&
|
|
||||||
132 | /(profile|address|user)/i.test(r.url()),
|
|
||||||
133 | { timeout: 20_000 },
|
|
||||||
134 | );
|
|
||||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
|
||||||
136 |
|
|
||||||
137 | try {
|
|
||||||
138 | await saved;
|
|
||||||
139 | } catch (cause) {
|
|
||||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
|
||||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
|
||||||
142 | // that refused to submit. Surface the field errors instead.
|
|
||||||
143 | const messages = await page
|
|
||||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
|
||||||
145 | .allTextContents();
|
|
||||||
> 146 | throw new Error(
|
|
||||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
147 | messages.length
|
|
||||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
|
||||||
149 | : 'Save produced no request and reported no validation error.',
|
|
||||||
150 | { cause },
|
|
||||||
151 | );
|
|
||||||
152 | }
|
|
||||||
153 | }
|
|
||||||
154 |
|
|
||||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
|
||||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
|
||||||
157 | const offset = await signUp(page, applicant);
|
|
||||||
158 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
160 | await page
|
|
||||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
162 | .first()
|
|
||||||
163 | .check();
|
|
||||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
|
||||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
|
||||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
168 | await completeProfile(page);
|
|
||||||
169 | }
|
|
||||||
170 |
|
|
||||||
171 | test.describe('seafarer registration', () => {
|
|
||||||
172 | let applicant: Applicant;
|
|
||||||
173 |
|
|
||||||
174 | test.beforeEach(() => {
|
|
||||||
175 | applicant = newApplicant('seafarer');
|
|
||||||
176 | });
|
|
||||||
177 |
|
|
||||||
178 | test.afterEach(() => {
|
|
||||||
179 | deleteApplicant(applicant.email);
|
|
||||||
180 | });
|
|
||||||
181 |
|
|
||||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
|
||||||
183 | page,
|
|
||||||
184 | }) => {
|
|
||||||
185 | const offset = await signUp(page, applicant);
|
|
||||||
186 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
188 | await page
|
|
||||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
190 | .first()
|
|
||||||
191 | .check();
|
|
||||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
194 |
|
|
||||||
195 | // A new account holds none of the identity the registration is filled in
|
|
||||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
|
||||||
197 | await page.goto('/seafarer-registration');
|
|
||||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
199 |
|
|
||||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
|
||||||
201 | // decoration a deep link walks straight past.
|
|
||||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
204 | });
|
|
||||||
205 |
|
|
||||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
|
||||||
207 | await readyApplicant(page, applicant);
|
|
||||||
208 |
|
|
||||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
211 |
|
|
||||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
|
||||||
213 | // and closing the browser mid-wizard loses nothing.
|
|
||||||
214 | const number = await waitForApplication(applicant.email);
|
|
||||||
215 | expect(number).toMatch(/^SFR/);
|
|
||||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
|
||||||
217 | });
|
|
||||||
218 |
|
|
||||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
|
||||||
220 | page,
|
|
||||||
221 | }) => {
|
|
||||||
222 | await readyApplicant(page, applicant);
|
|
||||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
224 | const number = await waitForApplication(applicant.email);
|
|
||||||
225 | const id = idOf(number);
|
|
||||||
226 |
|
|
||||||
227 | await submit(id);
|
|
||||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
|
||||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
230 |
|
|
||||||
231 | // The licence course's middle stages have nothing to hold in a
|
|
||||||
232 | // registration, and the transition table is the authority regardless of
|
|
||||||
233 | // which endpoint is called.
|
|
||||||
234 | const refused = await runWorkflow(id, [
|
|
||||||
235 | { path: 'complete-review', expectFailure: true },
|
|
||||||
236 | { path: 'approve-documents', expectFailure: true },
|
|
||||||
237 | { path: 'record-inspection', expectFailure: true },
|
|
||||||
238 | ]);
|
|
||||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
|
||||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
241 | });
|
|
||||||
242 |
|
|
||||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
|
||||||
244 | page,
|
|
||||||
245 | }) => {
|
|
||||||
246 | await readyApplicant(page, applicant);
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
- Following Playwright test failed.
|
|
||||||
- Explain why, be concise, respect Playwright best practices.
|
|
||||||
- Provide a snippet of code with the fix, if possible.
|
|
||||||
|
|
||||||
# Test info
|
|
||||||
|
|
||||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> an officer can hold and resume a registration
|
|
||||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:267:7
|
|
||||||
|
|
||||||
# Error details
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
```
|
|
||||||
|
|
||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [ref=f1e3]:
|
|
||||||
- banner [ref=f1e4]:
|
|
||||||
- generic [ref=f1e5]:
|
|
||||||
- generic [ref=f1e6]:
|
|
||||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
|
||||||
- generic [ref=f1e10]:
|
|
||||||
- generic [ref=f1e11]: Dashboard
|
|
||||||
- generic [ref=f1e13]: Profile
|
|
||||||
- generic [ref=f1e17]:
|
|
||||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
|
||||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
|
||||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e27]: "1"
|
|
||||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
|
||||||
- navigation [ref=f1e34]:
|
|
||||||
- generic [ref=f1e35]:
|
|
||||||
- img "EMA" [ref=f1e36]
|
|
||||||
- generic [ref=f1e37]:
|
|
||||||
- paragraph [ref=f1e38]: EMA Portal
|
|
||||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
|
||||||
- generic [ref=f1e43]:
|
|
||||||
- generic [ref=f1e44]:
|
|
||||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
|
||||||
- generic [ref=f1e52] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e57]: Notifications
|
|
||||||
- generic "1 pending" [ref=f1e59]: "1"
|
|
||||||
- generic [ref=f1e61]:
|
|
||||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e63]: Licensing
|
|
||||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
|
||||||
- generic [ref=f1e73]:
|
|
||||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e75]: Seafarer Services
|
|
||||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
|
||||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
|
||||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
|
||||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
|
||||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
|
||||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
|
||||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
|
||||||
- generic [ref=f1e113]:
|
|
||||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e115]: Account
|
|
||||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
|
||||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
|
||||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
|
||||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
|
||||||
- main [ref=f1e143]:
|
|
||||||
- generic [ref=f1e145]:
|
|
||||||
- generic [ref=f1e147]:
|
|
||||||
- heading "My Profile" [level=2] [ref=f1e148]
|
|
||||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
|
||||||
- alert [ref=f1e150]:
|
|
||||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
|
||||||
- generic [ref=f1e159]:
|
|
||||||
- paragraph [ref=f1e161]: ES
|
|
||||||
- generic [ref=f1e162]:
|
|
||||||
- generic [ref=f1e163]:
|
|
||||||
- heading "E2E seafarer 2368" [level=4] [ref=f1e164]
|
|
||||||
- generic [ref=f1e165]: Unverified
|
|
||||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042424082368@example.test
|
|
||||||
- generic [ref=f1e172]: e2eseafarer1787042424082368
|
|
||||||
- generic "0% complete" [ref=f1e178]:
|
|
||||||
- paragraph [ref=f1e183]: 0%
|
|
||||||
- generic [ref=f1e184]:
|
|
||||||
- tablist [ref=f1e185]:
|
|
||||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
|
||||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
|
||||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
|
||||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
|
||||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
|
||||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
|
||||||
- tabpanel "Profile" [ref=f1e224]:
|
|
||||||
- generic [ref=f1e227]:
|
|
||||||
- generic [ref=f1e228]:
|
|
||||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
|
||||||
- paragraph [ref=f1e230]: Your professional maritime details
|
|
||||||
- generic [ref=f1e231]:
|
|
||||||
- generic [ref=f1e232]:
|
|
||||||
- generic [ref=f1e233]: Profession *
|
|
||||||
- textbox "Profession" [ref=f1e235]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: Master Mariner
|
|
||||||
- generic [ref=f1e236]:
|
|
||||||
- generic [ref=f1e237]: First Name *
|
|
||||||
- textbox "First Name" [ref=f1e239]:
|
|
||||||
- /placeholder: Enter first name
|
|
||||||
- text: Dawit
|
|
||||||
- generic [ref=f1e240]:
|
|
||||||
- generic [ref=f1e241]: Middle Name *
|
|
||||||
- textbox "Middle Name" [ref=f1e243]:
|
|
||||||
- /placeholder: Enter middle name
|
|
||||||
- text: Bekele
|
|
||||||
- generic [ref=f1e244]:
|
|
||||||
- generic [ref=f1e245]: Last Name *
|
|
||||||
- textbox "Last Name" [ref=f1e247]:
|
|
||||||
- /placeholder: Enter last name
|
|
||||||
- text: Tesfaye
|
|
||||||
- generic [ref=f1e248]:
|
|
||||||
- generic [ref=f1e249]: Gender *
|
|
||||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: MALE
|
|
||||||
- generic [ref=f1e252]:
|
|
||||||
- generic [ref=f1e253]: Date of Birth *
|
|
||||||
- generic [ref=f1e254]:
|
|
||||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e257]: EN
|
|
||||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
|
||||||
- button [ref=f1e261] [cursor=pointer]
|
|
||||||
- generic [ref=f1e266]:
|
|
||||||
- generic [ref=f1e267]: Place of Birth
|
|
||||||
- textbox "Place of Birth" [ref=f1e269]:
|
|
||||||
- /placeholder: City, Region
|
|
||||||
- generic [ref=f1e270]:
|
|
||||||
- generic [ref=f1e271]: Marital Status *
|
|
||||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: SINGLE
|
|
||||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
|
||||||
```
|
|
||||||
|
|
||||||
# Test source
|
|
||||||
|
|
||||||
```ts
|
|
||||||
46 | await openTab(page, 'Address');
|
|
||||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
|
||||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
|
||||||
49 | // A country select, not a free-text field.
|
|
||||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
|
||||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
|
||||||
52 | // never submits and no request is made for `save` to wait on.
|
|
||||||
53 | await page
|
|
||||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
|
||||||
55 | .fill('+251911234567');
|
|
||||||
56 | await save(page);
|
|
||||||
57 | }
|
|
||||||
58 |
|
|
||||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
|
||||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
|
||||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
|
||||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
|
||||||
63 | timeout: 15_000,
|
|
||||||
64 | });
|
|
||||||
65 | }
|
|
||||||
66 |
|
|
||||||
67 | /**
|
|
||||||
68 | * Picks a value from a Mantine select.
|
|
||||||
69 | *
|
|
||||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
|
||||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
|
||||||
72 | * names the control itself.
|
|
||||||
73 | */
|
|
||||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
|
||||||
75 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
76 | await page.getByRole('option', { name: option }).first().click();
|
|
||||||
77 | }
|
|
||||||
78 |
|
|
||||||
79 | /**
|
|
||||||
80 | * Sets the date of birth through the picker's own UI.
|
|
||||||
81 | *
|
|
||||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
|
||||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
|
||||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
|
||||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
|
||||||
86 | *
|
|
||||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
|
||||||
88 | * the caption dropdowns, then click the day.
|
|
||||||
89 | */
|
|
||||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
|
||||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
|
||||||
92 |
|
|
||||||
93 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
|
||||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
|
||||||
96 |
|
|
||||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
|
||||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
|
||||||
99 | await calendar
|
|
||||||
100 | .locator('select')
|
|
||||||
101 | .first()
|
|
||||||
102 | .selectOption({ index: month - 1 });
|
|
||||||
103 |
|
|
||||||
104 | // Each day is a button whose accessible name is the full date
|
|
||||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
|
||||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
|
||||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
|
||||||
108 | // re-renders the grid.
|
|
||||||
109 | const cell = calendar
|
|
||||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
|
||||||
111 | .first();
|
|
||||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
|
||||||
113 | await cell.click();
|
|
||||||
114 |
|
|
||||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
|
||||||
116 |
|
|
||||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
|
||||||
118 | // an empty field and the failure would surface later as a refused submit.
|
|
||||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
|
||||||
120 | timeout: 10_000,
|
|
||||||
121 | });
|
|
||||||
122 | }
|
|
||||||
123 |
|
|
||||||
124 | async function save(page: Page): Promise<void> {
|
|
||||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
|
||||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
|
||||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
|
||||||
128 | const saved = page.waitForResponse(
|
|
||||||
129 | (r) =>
|
|
||||||
130 | r.request().method() !== 'GET' &&
|
|
||||||
131 | r.status() < 400 &&
|
|
||||||
132 | /(profile|address|user)/i.test(r.url()),
|
|
||||||
133 | { timeout: 20_000 },
|
|
||||||
134 | );
|
|
||||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
|
||||||
136 |
|
|
||||||
137 | try {
|
|
||||||
138 | await saved;
|
|
||||||
139 | } catch (cause) {
|
|
||||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
|
||||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
|
||||||
142 | // that refused to submit. Surface the field errors instead.
|
|
||||||
143 | const messages = await page
|
|
||||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
|
||||||
145 | .allTextContents();
|
|
||||||
> 146 | throw new Error(
|
|
||||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
147 | messages.length
|
|
||||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
|
||||||
149 | : 'Save produced no request and reported no validation error.',
|
|
||||||
150 | { cause },
|
|
||||||
151 | );
|
|
||||||
152 | }
|
|
||||||
153 | }
|
|
||||||
154 |
|
|
||||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
|
||||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
|
||||||
157 | const offset = await signUp(page, applicant);
|
|
||||||
158 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
160 | await page
|
|
||||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
162 | .first()
|
|
||||||
163 | .check();
|
|
||||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
|
||||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
|
||||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
168 | await completeProfile(page);
|
|
||||||
169 | }
|
|
||||||
170 |
|
|
||||||
171 | test.describe('seafarer registration', () => {
|
|
||||||
172 | let applicant: Applicant;
|
|
||||||
173 |
|
|
||||||
174 | test.beforeEach(() => {
|
|
||||||
175 | applicant = newApplicant('seafarer');
|
|
||||||
176 | });
|
|
||||||
177 |
|
|
||||||
178 | test.afterEach(() => {
|
|
||||||
179 | deleteApplicant(applicant.email);
|
|
||||||
180 | });
|
|
||||||
181 |
|
|
||||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
|
||||||
183 | page,
|
|
||||||
184 | }) => {
|
|
||||||
185 | const offset = await signUp(page, applicant);
|
|
||||||
186 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
188 | await page
|
|
||||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
190 | .first()
|
|
||||||
191 | .check();
|
|
||||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
194 |
|
|
||||||
195 | // A new account holds none of the identity the registration is filled in
|
|
||||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
|
||||||
197 | await page.goto('/seafarer-registration');
|
|
||||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
199 |
|
|
||||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
|
||||||
201 | // decoration a deep link walks straight past.
|
|
||||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
204 | });
|
|
||||||
205 |
|
|
||||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
|
||||||
207 | await readyApplicant(page, applicant);
|
|
||||||
208 |
|
|
||||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
211 |
|
|
||||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
|
||||||
213 | // and closing the browser mid-wizard loses nothing.
|
|
||||||
214 | const number = await waitForApplication(applicant.email);
|
|
||||||
215 | expect(number).toMatch(/^SFR/);
|
|
||||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
|
||||||
217 | });
|
|
||||||
218 |
|
|
||||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
|
||||||
220 | page,
|
|
||||||
221 | }) => {
|
|
||||||
222 | await readyApplicant(page, applicant);
|
|
||||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
224 | const number = await waitForApplication(applicant.email);
|
|
||||||
225 | const id = idOf(number);
|
|
||||||
226 |
|
|
||||||
227 | await submit(id);
|
|
||||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
|
||||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
230 |
|
|
||||||
231 | // The licence course's middle stages have nothing to hold in a
|
|
||||||
232 | // registration, and the transition table is the authority regardless of
|
|
||||||
233 | // which endpoint is called.
|
|
||||||
234 | const refused = await runWorkflow(id, [
|
|
||||||
235 | { path: 'complete-review', expectFailure: true },
|
|
||||||
236 | { path: 'approve-documents', expectFailure: true },
|
|
||||||
237 | { path: 'record-inspection', expectFailure: true },
|
|
||||||
238 | ]);
|
|
||||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
|
||||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
241 | });
|
|
||||||
242 |
|
|
||||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
|
||||||
244 | page,
|
|
||||||
245 | }) => {
|
|
||||||
246 | await readyApplicant(page, applicant);
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
- Following Playwright test failed.
|
|
||||||
- Explain why, be concise, respect Playwright best practices.
|
|
||||||
- Provide a snippet of code with the fix, if possible.
|
|
||||||
|
|
||||||
# Test info
|
|
||||||
|
|
||||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> an officer can reject a registration with a reason
|
|
||||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:285:7
|
|
||||||
|
|
||||||
# Error details
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
```
|
|
||||||
|
|
||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [ref=f1e3]:
|
|
||||||
- banner [ref=f1e4]:
|
|
||||||
- generic [ref=f1e5]:
|
|
||||||
- generic [ref=f1e6]:
|
|
||||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
|
||||||
- generic [ref=f1e10]:
|
|
||||||
- generic [ref=f1e11]: Dashboard
|
|
||||||
- generic [ref=f1e13]: Profile
|
|
||||||
- generic [ref=f1e17]:
|
|
||||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
|
||||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
|
||||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e27]: "1"
|
|
||||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
|
||||||
- navigation [ref=f1e34]:
|
|
||||||
- generic [ref=f1e35]:
|
|
||||||
- img "EMA" [ref=f1e36]
|
|
||||||
- generic [ref=f1e37]:
|
|
||||||
- paragraph [ref=f1e38]: EMA Portal
|
|
||||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
|
||||||
- generic [ref=f1e43]:
|
|
||||||
- generic [ref=f1e44]:
|
|
||||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
|
||||||
- generic [ref=f1e52] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e57]: Notifications
|
|
||||||
- generic "1 pending" [ref=f1e59]: "1"
|
|
||||||
- generic [ref=f1e61]:
|
|
||||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e63]: Licensing
|
|
||||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
|
||||||
- generic [ref=f1e73]:
|
|
||||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e75]: Seafarer Services
|
|
||||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
|
||||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
|
||||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
|
||||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
|
||||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
|
||||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
|
||||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
|
||||||
- generic [ref=f1e113]:
|
|
||||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
|
||||||
- paragraph [ref=f1e115]: Account
|
|
||||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
|
||||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
|
||||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
|
||||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
|
||||||
- main [ref=f1e143]:
|
|
||||||
- generic [ref=f1e145]:
|
|
||||||
- generic [ref=f1e147]:
|
|
||||||
- heading "My Profile" [level=2] [ref=f1e148]
|
|
||||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
|
||||||
- alert [ref=f1e150]:
|
|
||||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
|
||||||
- generic [ref=f1e159]:
|
|
||||||
- paragraph [ref=f1e161]: ES
|
|
||||||
- generic [ref=f1e162]:
|
|
||||||
- generic [ref=f1e163]:
|
|
||||||
- heading "E2E seafarer 9173" [level=4] [ref=f1e164]
|
|
||||||
- generic [ref=f1e165]: Unverified
|
|
||||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042455309173@example.test
|
|
||||||
- generic [ref=f1e172]: e2eseafarer1787042455309173
|
|
||||||
- generic "0% complete" [ref=f1e178]:
|
|
||||||
- paragraph [ref=f1e183]: 0%
|
|
||||||
- generic [ref=f1e184]:
|
|
||||||
- tablist [ref=f1e185]:
|
|
||||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
|
||||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
|
||||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
|
||||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
|
||||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
|
||||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
|
||||||
- tabpanel "Profile" [ref=f1e224]:
|
|
||||||
- generic [ref=f1e227]:
|
|
||||||
- generic [ref=f1e228]:
|
|
||||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
|
||||||
- paragraph [ref=f1e230]: Your professional maritime details
|
|
||||||
- generic [ref=f1e231]:
|
|
||||||
- generic [ref=f1e232]:
|
|
||||||
- generic [ref=f1e233]: Profession *
|
|
||||||
- textbox "Profession" [ref=f1e235]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: Master Mariner
|
|
||||||
- generic [ref=f1e236]:
|
|
||||||
- generic [ref=f1e237]: First Name *
|
|
||||||
- textbox "First Name" [ref=f1e239]:
|
|
||||||
- /placeholder: Enter first name
|
|
||||||
- text: Dawit
|
|
||||||
- generic [ref=f1e240]:
|
|
||||||
- generic [ref=f1e241]: Middle Name *
|
|
||||||
- textbox "Middle Name" [ref=f1e243]:
|
|
||||||
- /placeholder: Enter middle name
|
|
||||||
- text: Bekele
|
|
||||||
- generic [ref=f1e244]:
|
|
||||||
- generic [ref=f1e245]: Last Name *
|
|
||||||
- textbox "Last Name" [ref=f1e247]:
|
|
||||||
- /placeholder: Enter last name
|
|
||||||
- text: Tesfaye
|
|
||||||
- generic [ref=f1e248]:
|
|
||||||
- generic [ref=f1e249]: Gender *
|
|
||||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: MALE
|
|
||||||
- generic [ref=f1e252]:
|
|
||||||
- generic [ref=f1e253]: Date of Birth *
|
|
||||||
- generic [ref=f1e254]:
|
|
||||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e257]: EN
|
|
||||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
|
||||||
- button [ref=f1e261] [cursor=pointer]
|
|
||||||
- generic [ref=f1e266]:
|
|
||||||
- generic [ref=f1e267]: Place of Birth
|
|
||||||
- textbox "Place of Birth" [ref=f1e269]:
|
|
||||||
- /placeholder: City, Region
|
|
||||||
- generic [ref=f1e270]:
|
|
||||||
- generic [ref=f1e271]: Marital Status *
|
|
||||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
|
||||||
- /placeholder: Select
|
|
||||||
- text: SINGLE
|
|
||||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
|
||||||
```
|
|
||||||
|
|
||||||
# Test source
|
|
||||||
|
|
||||||
```ts
|
|
||||||
46 | await openTab(page, 'Address');
|
|
||||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
|
||||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
|
||||||
49 | // A country select, not a free-text field.
|
|
||||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
|
||||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
|
||||||
52 | // never submits and no request is made for `save` to wait on.
|
|
||||||
53 | await page
|
|
||||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
|
||||||
55 | .fill('+251911234567');
|
|
||||||
56 | await save(page);
|
|
||||||
57 | }
|
|
||||||
58 |
|
|
||||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
|
||||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
|
||||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
|
||||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
|
||||||
63 | timeout: 15_000,
|
|
||||||
64 | });
|
|
||||||
65 | }
|
|
||||||
66 |
|
|
||||||
67 | /**
|
|
||||||
68 | * Picks a value from a Mantine select.
|
|
||||||
69 | *
|
|
||||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
|
||||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
|
||||||
72 | * names the control itself.
|
|
||||||
73 | */
|
|
||||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
|
||||||
75 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
76 | await page.getByRole('option', { name: option }).first().click();
|
|
||||||
77 | }
|
|
||||||
78 |
|
|
||||||
79 | /**
|
|
||||||
80 | * Sets the date of birth through the picker's own UI.
|
|
||||||
81 | *
|
|
||||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
|
||||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
|
||||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
|
||||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
|
||||||
86 | *
|
|
||||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
|
||||||
88 | * the caption dropdowns, then click the day.
|
|
||||||
89 | */
|
|
||||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
|
||||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
|
||||||
92 |
|
|
||||||
93 | await page.getByRole('textbox', { name: label }).click();
|
|
||||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
|
||||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
|
||||||
96 |
|
|
||||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
|
||||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
|
||||||
99 | await calendar
|
|
||||||
100 | .locator('select')
|
|
||||||
101 | .first()
|
|
||||||
102 | .selectOption({ index: month - 1 });
|
|
||||||
103 |
|
|
||||||
104 | // Each day is a button whose accessible name is the full date
|
|
||||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
|
||||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
|
||||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
|
||||||
108 | // re-renders the grid.
|
|
||||||
109 | const cell = calendar
|
|
||||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
|
||||||
111 | .first();
|
|
||||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
|
||||||
113 | await cell.click();
|
|
||||||
114 |
|
|
||||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
|
||||||
116 |
|
|
||||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
|
||||||
118 | // an empty field and the failure would surface later as a refused submit.
|
|
||||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
|
||||||
120 | timeout: 10_000,
|
|
||||||
121 | });
|
|
||||||
122 | }
|
|
||||||
123 |
|
|
||||||
124 | async function save(page: Page): Promise<void> {
|
|
||||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
|
||||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
|
||||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
|
||||||
128 | const saved = page.waitForResponse(
|
|
||||||
129 | (r) =>
|
|
||||||
130 | r.request().method() !== 'GET' &&
|
|
||||||
131 | r.status() < 400 &&
|
|
||||||
132 | /(profile|address|user)/i.test(r.url()),
|
|
||||||
133 | { timeout: 20_000 },
|
|
||||||
134 | );
|
|
||||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
|
||||||
136 |
|
|
||||||
137 | try {
|
|
||||||
138 | await saved;
|
|
||||||
139 | } catch (cause) {
|
|
||||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
|
||||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
|
||||||
142 | // that refused to submit. Surface the field errors instead.
|
|
||||||
143 | const messages = await page
|
|
||||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
|
||||||
145 | .allTextContents();
|
|
||||||
> 146 | throw new Error(
|
|
||||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
|
||||||
147 | messages.length
|
|
||||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
|
||||||
149 | : 'Save produced no request and reported no validation error.',
|
|
||||||
150 | { cause },
|
|
||||||
151 | );
|
|
||||||
152 | }
|
|
||||||
153 | }
|
|
||||||
154 |
|
|
||||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
|
||||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
|
||||||
157 | const offset = await signUp(page, applicant);
|
|
||||||
158 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
160 | await page
|
|
||||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
162 | .first()
|
|
||||||
163 | .check();
|
|
||||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
|
||||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
|
||||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
168 | await completeProfile(page);
|
|
||||||
169 | }
|
|
||||||
170 |
|
|
||||||
171 | test.describe('seafarer registration', () => {
|
|
||||||
172 | let applicant: Applicant;
|
|
||||||
173 |
|
|
||||||
174 | test.beforeEach(() => {
|
|
||||||
175 | applicant = newApplicant('seafarer');
|
|
||||||
176 | });
|
|
||||||
177 |
|
|
||||||
178 | test.afterEach(() => {
|
|
||||||
179 | deleteApplicant(applicant.email);
|
|
||||||
180 | });
|
|
||||||
181 |
|
|
||||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
|
||||||
183 | page,
|
|
||||||
184 | }) => {
|
|
||||||
185 | const offset = await signUp(page, applicant);
|
|
||||||
186 | await verifyOtpIfPrompted(page, offset);
|
|
||||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
|
||||||
188 | await page
|
|
||||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
|
||||||
190 | .first()
|
|
||||||
191 | .check();
|
|
||||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
|
||||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
194 |
|
|
||||||
195 | // A new account holds none of the identity the registration is filled in
|
|
||||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
|
||||||
197 | await page.goto('/seafarer-registration');
|
|
||||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
199 |
|
|
||||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
|
||||||
201 | // decoration a deep link walks straight past.
|
|
||||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
204 | });
|
|
||||||
205 |
|
|
||||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
|
||||||
207 | await readyApplicant(page, applicant);
|
|
||||||
208 |
|
|
||||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
||||||
211 |
|
|
||||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
|
||||||
213 | // and closing the browser mid-wizard loses nothing.
|
|
||||||
214 | const number = await waitForApplication(applicant.email);
|
|
||||||
215 | expect(number).toMatch(/^SFR/);
|
|
||||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
|
||||||
217 | });
|
|
||||||
218 |
|
|
||||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
|
||||||
220 | page,
|
|
||||||
221 | }) => {
|
|
||||||
222 | await readyApplicant(page, applicant);
|
|
||||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
||||||
224 | const number = await waitForApplication(applicant.email);
|
|
||||||
225 | const id = idOf(number);
|
|
||||||
226 |
|
|
||||||
227 | await submit(id);
|
|
||||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
|
||||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
230 |
|
|
||||||
231 | // The licence course's middle stages have nothing to hold in a
|
|
||||||
232 | // registration, and the transition table is the authority regardless of
|
|
||||||
233 | // which endpoint is called.
|
|
||||||
234 | const refused = await runWorkflow(id, [
|
|
||||||
235 | { path: 'complete-review', expectFailure: true },
|
|
||||||
236 | { path: 'approve-documents', expectFailure: true },
|
|
||||||
237 | { path: 'record-inspection', expectFailure: true },
|
|
||||||
238 | ]);
|
|
||||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
|
||||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
||||||
241 | });
|
|
||||||
242 |
|
|
||||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
|
||||||
244 | page,
|
|
||||||
245 | }) => {
|
|
||||||
246 | await readyApplicant(page, applicant);
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB |