feat: implement person name utility functions and update related components for name handling

This commit is contained in:
Nati
2026-08-19 05:52:26 +00:00
parent d44977cbf3
commit 629b02fd26
6 changed files with 169 additions and 36 deletions

View File

@@ -45,6 +45,7 @@ import {
useGetApplicationQuery,
useGetAttachmentsQuery,
useGetLicenseTypeRequirementsQuery,
useGetMyApplicationsQuery,
useGetMyVesselsQuery,
usePatchSectionMutation,
useRemoveStaffMutation,
@@ -56,7 +57,7 @@ import {
type ValidationIssue,
type Vessel,
} from "@ema-platform/api";
import { getCountryCode, getCountryName, ModalFooter } from "@ema-platform/ui";
import { getCountryCode, getCountryName, ModalFooter, splitPersonName } from "@ema-platform/ui";
import {
LICENSE_PERMISSIONS,
PORTAL_PERMISSIONS,
@@ -103,6 +104,10 @@ export function LicenseApplicationPage() {
const { data: vessels } = useGetMyVesselsQuery();
const [createApplication] = useCreateApplicationMutation();
const [appId, setAppId] = useState<string | undefined>(applicationId);
// Only fetched to recover from the 409 below — a fresh visit never needs
// the applicant's whole application list, so this stays lazy.
const { data: myApplications, refetch: fetchMyApplications } =
useGetMyApplicationsQuery(undefined, { skip: true });
// 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.
@@ -111,14 +116,30 @@ export function LicenseApplicationPage() {
createApplication({ licenseType: typeCode })
.unwrap()
.then((app) => setAppId(app.id))
.catch((err) =>
.catch(async (err) => {
// A one-shot registration (e.g. seafarer) already has a submitted (or
// further along) application — the backend refuses a second one
// rather than silently resuming it, unlike an unfinished DRAFT. The
// applicant's intent was still "open my registration", so find the
// existing one and load it instead of leaving the page stuck on this
// toast with nothing to fetch.
if (err?.status === 409) {
const mine = myApplications ?? (await fetchMyApplications().unwrap());
const existing = mine.items.find(
(a) => a.licenseTypeId === config.licenseType.id,
);
if (existing) {
setAppId(existing.id);
return;
}
}
notifications.show({
color: "red",
title: "Could not start application",
message: extractErrorMessage(err),
}),
);
}, [appId, config, createApplication, typeCode]);
});
});
}, [appId, config, createApplication, typeCode, myApplications, fetchMyApplications]);
const { data: detail, refetch } = useGetApplicationQuery(appId as string, {
skip: !appId,
@@ -241,7 +262,23 @@ export function LicenseApplicationPage() {
// authoritative, so those keep tracking it.
useEffect(() => {
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 nameFallback = profile.user?.name?.en
? splitPersonName(profile.user.name.en)
: null;
const context = {
user: profile.user,
profile: {
...profile,
firstName: profile.firstName || nameFallback?.firstName || "",
middleName: profile.middleName || nameFallback?.middleName || "",
lastName: profile.lastName || nameFallback?.lastName || "",
},
};
setDraft((prev) => {
let changed = false;

View File

@@ -49,7 +49,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
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 { PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
@@ -89,15 +89,6 @@ function getInitials(name: string, fallback: string) {
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) {
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.
const currentProfile = resolvedProfile ?? storedProfile;
if (currentProfile) {
const accountName = user?.name?.en ? splitProfileName(user.name.en) : null;
const accountName = user?.name?.en ? splitPersonName(user.name.en) : null;
setLoadedProfile({
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
firstName: accountName?.firstName || currentProfile.firstName || '',
@@ -254,7 +245,7 @@ export function ProfilePage() {
nameEn: z
.string()
.refine(
(name) => Object.values(splitProfileName(name)).every(Boolean),
(name) => Object.values(splitPersonName(name)).every(Boolean),
{ message: 'Enter your first, middle, and last name' },
),
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
@@ -285,7 +276,7 @@ export function ProfilePage() {
setIsSavingProfile(true);
try {
const profileName = splitProfileName(values.nameEn);
const profileName = splitPersonName(values.nameEn);
const saves: Promise<unknown>[] = [
updateTrigger({
url: '/auth/update-profile',
@@ -362,7 +353,7 @@ export function ProfilePage() {
const onSaveProfile = async (values: ProfileValues) => {
if (!profileId) return;
const fullName = formatProfileName(values);
const fullName = joinPersonName(values);
if (user && normalizeName(fullName) !== normalizeName(user.name.en)) {
notify.error('Profile name must match the name in the Personal tab.');
return;

View File

@@ -165,11 +165,14 @@ function SeaServiceTab() {
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_SEA_SERVICE);
const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
const [uploadingEvidence, setUploadingEvidence] = useState(false);
const openCreate = () => {
setEditing(null);
setForm(EMPTY_SEA_SERVICE);
setGrossTonnage('');
setEvidenceFile(null);
setModalOpen(true);
};
@@ -186,6 +189,7 @@ function SeaServiceTab() {
dutiesDescription: record.dutiesDescription ?? '',
});
setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : '');
setEvidenceFile(null);
setModalOpen(true);
};
@@ -204,13 +208,32 @@ function SeaServiceTab() {
...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}),
};
try {
let recordId = editing?.id;
if (editing) {
await updateRecord({ id: editing.id, body }).unwrap();
notify.success('Sea-service record updated');
} else {
await createRecord(body).unwrap();
const created = await createRecord(body).unwrap();
recordId = created.id;
notify.success('Sea-service record added');
}
if (evidenceFile && recordId) {
setUploadingEvidence(true);
const result = await uploadDocument({
ownerType: 'SEA_SERVICE_RECORD',
ownerId: recordId,
documentKey: 'evidence',
file: evidenceFile,
});
setUploadingEvidence(false);
if (result.ok) {
notify.success('Evidence uploaded');
} else {
notify.error(result.error);
}
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the record'));
@@ -361,6 +384,17 @@ function SeaServiceTab() {
setForm({ ...form, dutiesDescription: e.target.value })
}
/>
<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">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
@@ -368,7 +402,7 @@ function SeaServiceTab() {
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
loading={creating || updating || uploadingEvidence}
>
{editing ? 'Save changes' : 'Add record'}
</Button>
@@ -410,10 +444,13 @@ function MedicalTab() {
const [modalOpen, setModalOpen] = useState(false);
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_MEDICAL);
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
const [uploadingEvidence, setUploadingEvidence] = useState(false);
const openCreate = () => {
setEditing(null);
setForm(EMPTY_MEDICAL);
setEvidenceFile(null);
setModalOpen(true);
};
@@ -427,6 +464,7 @@ function MedicalTab() {
fitnessStatus: certificate.fitnessStatus,
restrictions: certificate.restrictions ?? '',
});
setEvidenceFile(null);
setModalOpen(true);
};
@@ -442,13 +480,32 @@ function MedicalTab() {
...(form.restrictions ? { restrictions: form.restrictions } : {}),
};
try {
let certificateId = editing?.id;
if (editing) {
await updateCertificate({ id: editing.id, body }).unwrap();
notify.success('Medical certificate updated');
} else {
await createCertificate(body).unwrap();
const created = await createCertificate(body).unwrap();
certificateId = created.id;
notify.success('Medical certificate added');
}
if (evidenceFile && certificateId) {
setUploadingEvidence(true);
const result = await uploadDocument({
ownerType: 'MEDICAL_CERTIFICATE',
ownerId: certificateId,
documentKey: 'evidence',
file: evidenceFile,
});
setUploadingEvidence(false);
if (result.ok) {
notify.success('Evidence uploaded');
} else {
notify.error(result.error);
}
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the certificate'));
@@ -575,6 +632,17 @@ function MedicalTab() {
}
/>
)}
<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">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
@@ -582,7 +650,7 @@ function MedicalTab() {
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
loading={creating || updating || uploadingEvidence}
>
{editing ? 'Save changes' : 'Add certificate'}
</Button>