mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: implement person name utility functions and update related components for name handling
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user