From 629b02fd26b8781d0eee19b0da6d2f0b368b47d3 Mon Sep 17 00:00:00 2001 From: Nati Date: Wed, 19 Aug 2026 05:52:26 +0000 Subject: [PATCH] feat: implement person name utility functions and update related components for name handling --- .../pages/LicenseApplicationPage.tsx | 49 ++++++++++-- .../features/profile/pages/ProfilePage.tsx | 19 ++--- .../seafarer/pages/MySeaRecordsPage/index.tsx | 76 ++++++++++++++++++- libs/auth/src/lib/pages/SignupPage.tsx | 41 +++++++--- libs/ui/src/index.ts | 1 + libs/ui/src/lib/utils/person-name.ts | 19 +++++ 6 files changed, 169 insertions(+), 36 deletions(-) create mode 100644 libs/ui/src/lib/utils/person-name.ts diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index da2fd40bd..193eb4dbe 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -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(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; diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 067ba3340..0f574e1e1 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -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) { - 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[] = [ 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; diff --git a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx index c8a7f7b90..55e87200b 100644 --- a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx +++ b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx @@ -165,11 +165,14 @@ function SeaServiceTab() { const [evidenceFor, setEvidenceFor] = useState(null); const [form, setForm] = useState(EMPTY_SEA_SERVICE); const [grossTonnage, setGrossTonnage] = useState(''); + const [evidenceFile, setEvidenceFile] = useState(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 }) } /> + + {(props) => ( + + )} + @@ -410,10 +444,13 @@ function MedicalTab() { const [modalOpen, setModalOpen] = useState(false); const [evidenceFor, setEvidenceFor] = useState(null); const [form, setForm] = useState(EMPTY_MEDICAL); + const [evidenceFile, setEvidenceFile] = useState(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() { } /> )} + + {(props) => ( + + )} + diff --git a/libs/auth/src/lib/pages/SignupPage.tsx b/libs/auth/src/lib/pages/SignupPage.tsx index 2ce426e1f..60e625ad1 100644 --- a/libs/auth/src/lib/pages/SignupPage.tsx +++ b/libs/auth/src/lib/pages/SignupPage.tsx @@ -27,7 +27,7 @@ import { useNavigate, Link } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; 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 { loginSuccess, setUser } from '../store/auth.slice'; 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') }), phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }), 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(), password: passwordSchema(8, passwordRuleLabels), confirmPassword: z.string().min(1, { message: t('signup.confirmPasswordRequired', 'Confirm your password') }), @@ -109,7 +111,7 @@ export function SignupPage() { username: values.username, phoneNumber: values.phoneNumber, userType: values.userType, - name: { en: values.nameEn, am: values.nameAm ?? '' }, + name: { en: joinPersonName(values), am: values.nameAm ?? '' }, password: values.password, confirmPassword: values.confirmPassword, }; @@ -172,23 +174,38 @@ export function SignupPage() {
- + } - error={errors.nameEn?.message} - {...register('nameEn')} + error={errors.firstName?.message} + {...register('firstName')} /> } - error={errors.nameAm?.message} - {...register('nameAm')} + error={errors.middleName?.message} + {...register('middleName')} + /> + } + error={errors.lastName?.message} + {...register('lastName')} /> + } + error={errors.nameAm?.message} + {...register('nameAm')} + /> +