From 12daba2951f9e2a5634cfdb3f779f63de4677310 Mon Sep 17 00:00:00 2001 From: estifanos Date: Thu, 20 Aug 2026 10:17:35 +0000 Subject: [PATCH] feat: enforce physical characteristics for seafarer profiles and add dynamic validation and form support --- .../pages/LicenseApplicationPage.tsx | 18 +++- .../profile/components/ProfileFormContent.tsx | 88 ++++++++++++++++++- .../features/profile/pages/ProfilePage.tsx | 23 ++++- apps/portal/src/app/i18n/locales/am.ts | 41 +++++++++ apps/portal/src/app/i18n/locales/en.ts | 41 +++++++++ libs/auth/src/lib/types/auth.types.ts | 5 ++ 6 files changed, 208 insertions(+), 8 deletions(-) diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index db8d10562..2cf452a1b 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -270,13 +270,18 @@ export function LicenseApplicationPage() { const nameFallback = accountName?.en ? splitPersonName(accountName.en) : null; + const firstName = profile.firstName || nameFallback?.firstName || ""; + const middleName = profile.middleName || nameFallback?.middleName || ""; + const lastName = profile.lastName || nameFallback?.lastName || ""; const context = { user: accountUser ?? profile.user, profile: { ...profile, - firstName: profile.firstName || nameFallback?.firstName || "", - middleName: profile.middleName || nameFallback?.middleName || "", - lastName: profile.lastName || nameFallback?.lastName || "", + firstName, + middleName, + lastName, + // The profile has no single "full name" column — it's first/middle/last. + fullName: [firstName, middleName, lastName].filter(Boolean).join(" "), }, }; @@ -291,7 +296,12 @@ export function LicenseApplicationPage() { const untouched = current === undefined || current === null || current === ""; if (!field.readOnly && !untouched) continue; - const value = readSourcePath(context, source); + const raw = readSourcePath(context, source); + // Profile dates arrive as ISO datetimes; a DATE field's picker wants + // yyyy-MM-dd. Seafarer registration's `profile.dob` source hits the + // same mismatch today — fixed once here rather than per config. + const value = + field.type === "DATE" && typeof raw === "string" ? raw.slice(0, 10) : raw; if (value === undefined || value === null || value === "") continue; if (current === value) continue; next[section.key] = { ...next[section.key], [field.key]: value }; diff --git a/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx b/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx index 766a8840e..354575a50 100644 --- a/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx +++ b/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx @@ -15,7 +15,12 @@ function isAtLeast18(dob: string): boolean { return birth <= cutoff; } -export const profileSchema = (t: TFunction) => +// Printed on the Seaman Book, so a seafarer account can't leave them blank — +// every other account type may. Blood type offers UNKNOWN, so requiring an +// answer never forces a claim. Mirrors seafarer-registration.seed-data.ts's +// `required: true` on the same fields, and the backend's own check in +// ProfileService.assertPhysicalCharacteristicsForSeafarer. +export const profileSchema = (t: TFunction, isSeafarer: boolean) => z.object({ professionId: z.string().min(1, t('profileForm.validation.professionRequired')), firstName: z.string().min(3, t('profileForm.validation.firstNameMin')), @@ -28,8 +33,32 @@ export const profileSchema = (t: TFunction) => .refine((value) => isAtLeast18(value), { message: t('profileForm.validation.dobMinAge'), }), - pob: z.string().optional(), + pob: isSeafarer + ? z.string().min(1, t('profileForm.validation.pobRequired')) + : z.string().optional(), maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')), + bloodType: isSeafarer + ? z.string().min(1, t('profileForm.validation.bloodTypeRequired')) + : z.string().optional(), + hairColor: isSeafarer + ? z.string().min(1, t('profileForm.validation.hairColorRequired')) + : z.string().optional(), + eyeColor: isSeafarer + ? z.string().min(1, t('profileForm.validation.eyeColorRequired')) + : z.string().optional(), + heightCm: isSeafarer + ? z + .string() + .min(1, t('profileForm.validation.heightRequired')) + .refine((v) => Number(v) >= 100 && Number(v) <= 250, { + message: t('profileForm.validation.heightRange'), + }) + : z + .string() + .optional() + .refine((v) => !v || (Number(v) >= 100 && Number(v) <= 250), { + message: t('profileForm.validation.heightRange'), + }), }); export type ProfileValues = z.infer>; @@ -37,6 +66,13 @@ export type ProfileValues = z.infer>; export const GENDERS = ['MALE', 'FEMALE'] as const; export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const; export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const; +// Must match EBloodType/EHairColor/EEyeColor on the backend (common/enums/user.enum.ts). +export const BLOOD_TYPES = [ + 'A_POSITIVE', 'A_NEGATIVE', 'B_POSITIVE', 'B_NEGATIVE', + 'AB_POSITIVE', 'AB_NEGATIVE', 'O_POSITIVE', 'O_NEGATIVE', 'UNKNOWN', +] as const; +export const HAIR_COLORS = ['BLACK', 'BROWN', 'BLONDE', 'RED', 'GREY', 'WHITE', 'BALD', 'OTHER'] as const; +export const EYE_COLORS = ['BROWN', 'BLACK', 'BLUE', 'GREEN', 'HAZEL', 'GREY', 'OTHER'] as const; interface ProfileFormContentProps { register: UseFormRegister; @@ -46,6 +82,8 @@ interface ProfileFormContentProps { trigger: UseFormTrigger; professionsLoading: boolean; professionOptions: Array<{ value: string; label: string }>; + /** Place of birth, hair/eye colour and height become required for these accounts. */ + isSeafarer: boolean; } export function ProfileFormContent({ @@ -56,6 +94,7 @@ export function ProfileFormContent({ trigger, professionsLoading, professionOptions, + isSeafarer, }: ProfileFormContentProps) { const { t } = useTranslation(); @@ -119,6 +158,7 @@ export function ProfileFormContent({ @@ -133,6 +173,50 @@ export function ProfileFormContent({ onBlur={() => trigger('maritalStatus')} name="maritalStatus" /> + ({ value: h, label: t(`profileForm.hairColors.${h}`) }))} + error={errors.hairColor?.message} + value={watch('hairColor') || null} + onChange={(val) => setValue('hairColor', val || '', { shouldValidate: true })} + onBlur={() => trigger('hairColor')} + name="hairColor" + /> +