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, useGetApplicationQuery,
useGetAttachmentsQuery, useGetAttachmentsQuery,
useGetLicenseTypeRequirementsQuery, useGetLicenseTypeRequirementsQuery,
useGetMyApplicationsQuery,
useGetMyVesselsQuery, useGetMyVesselsQuery,
usePatchSectionMutation, usePatchSectionMutation,
useRemoveStaffMutation, useRemoveStaffMutation,
@@ -56,7 +57,7 @@ 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,
@@ -103,6 +104,10 @@ export function LicenseApplicationPage() {
const { data: vessels } = useGetMyVesselsQuery(); const { data: vessels } = useGetMyVesselsQuery();
const [createApplication] = useCreateApplicationMutation(); const [createApplication] = useCreateApplicationMutation();
const [appId, setAppId] = useState<string | undefined>(applicationId); 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 // 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.
@@ -111,14 +116,30 @@ export function LicenseApplicationPage() {
createApplication({ licenseType: typeCode }) createApplication({ licenseType: typeCode })
.unwrap() .unwrap()
.then((app) => setAppId(app.id)) .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({ notifications.show({
color: "red", color: "red",
title: "Could not start application", title: "Could not start application",
message: extractErrorMessage(err), message: extractErrorMessage(err),
}), });
); });
}, [appId, config, createApplication, typeCode]); }, [appId, config, createApplication, typeCode, myApplications, fetchMyApplications]);
const { data: detail, refetch } = useGetApplicationQuery(appId as string, { const { data: detail, refetch } = useGetApplicationQuery(appId as string, {
skip: !appId, skip: !appId,
@@ -241,7 +262,23 @@ export function LicenseApplicationPage() {
// authoritative, so those keep tracking it. // 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 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) => { setDraft((prev) => {
let changed = false; let changed = false;

View File

@@ -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 || '',
@@ -254,7 +245,7 @@ export function ProfilePage() {
nameEn: z nameEn: z
.string() .string()
.refine( .refine(
(name) => Object.values(splitProfileName(name)).every(Boolean), (name) => Object.values(splitPersonName(name)).every(Boolean),
{ message: 'Enter your first, middle, and last name' }, { message: 'Enter your first, middle, and last name' },
), ),
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }), nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
@@ -285,7 +276,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',
@@ -362,7 +353,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('Profile name must match the name in the Personal tab.'); notify.error('Profile name must match the name in the Personal tab.');
return; return;

View File

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

View File

@@ -27,7 +27,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';
@@ -80,7 +80,9 @@ export function SignupPage() {
username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }), username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }),
phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }), phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }),
userType: z.literal('individual'), userType: z.literal('individual'),
nameEn: z.string().min(1, { message: t('signup.nameEnRequired', 'Name (English) is required') }), firstName: z.string().min(1, { message: t('signup.firstNameRequired', 'First name is required') }),
middleName: z.string().optional(),
lastName: z.string().min(1, { message: t('signup.lastNameRequired', 'Last name is required') }),
nameAm: z.string().optional(), nameAm: z.string().optional(),
password: passwordSchema(8, passwordRuleLabels), password: passwordSchema(8, passwordRuleLabels),
confirmPassword: z.string().min(1, { message: t('signup.confirmPasswordRequired', 'Confirm your password') }), confirmPassword: z.string().min(1, { message: t('signup.confirmPasswordRequired', 'Confirm your password') }),
@@ -109,7 +111,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,
}; };
@@ -172,14 +174,30 @@ 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 <TextInput
label={t('signup.nameEnLabel', 'Name (English)')} label={t('signup.firstNameLabel', 'First name')}
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')} placeholder={t('signup.firstNamePlaceholder', 'Abebe')}
leftSection={<IconUser size={18} />} leftSection={<IconUser size={18} />}
error={errors.nameEn?.message} error={errors.firstName?.message}
{...register('nameEn')} {...register('firstName')}
/> />
<TextInput
label={t('signup.middleNameLabel', 'Middle name')}
placeholder={t('signup.middleNamePlaceholder', 'Kebede')}
leftSection={<IconUser size={18} />}
error={errors.middleName?.message}
{...register('middleName')}
/>
<TextInput
label={t('signup.lastNameLabel', 'Last name')}
placeholder={t('signup.lastNamePlaceholder', 'Bekele')}
leftSection={<IconUser size={18} />}
error={errors.lastName?.message}
{...register('lastName')}
/>
</SimpleGrid>
<TextInput <TextInput
label={t('signup.nameAmLabel', 'Name (Amharic)')} label={t('signup.nameAmLabel', 'Name (Amharic)')}
placeholder={t('signup.nameAmPlaceholder', 'ስም')} placeholder={t('signup.nameAmPlaceholder', 'ስም')}
@@ -187,7 +205,6 @@ export function SignupPage() {
error={errors.nameAm?.message} error={errors.nameAm?.message}
{...register('nameAm')} {...register('nameAm')}
/> />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<TextInput <TextInput

View File

@@ -23,3 +23,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";

View 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(' ');
}