mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-27 15:30:57 +00:00
feat: implement 18+ age validation, enforce document review completion for approvals, and refactor DecisionBar UI
This commit is contained in:
@@ -5,6 +5,16 @@ import type { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AmharicDatePicker } from '@ema-platform/ui';
|
||||
|
||||
// dob comes in as a plain yyyy-MM-dd string; skip on empty/malformed so
|
||||
// dobRequired's own message takes precedence.
|
||||
function isAtLeast18(dob: string): boolean {
|
||||
const birth = new Date(dob);
|
||||
if (Number.isNaN(birth.getTime())) return true;
|
||||
const today = new Date();
|
||||
const cutoff = new Date(today.getFullYear() - 18, today.getMonth(), today.getDate());
|
||||
return birth <= cutoff;
|
||||
}
|
||||
|
||||
export const profileSchema = (t: TFunction) =>
|
||||
z.object({
|
||||
professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
|
||||
@@ -12,7 +22,12 @@ export const profileSchema = (t: TFunction) =>
|
||||
middleName: z.string().min(3, t('profileForm.validation.middleNameMin')),
|
||||
lastName: z.string().min(3, t('profileForm.validation.lastNameMin')),
|
||||
gender: z.string().min(1, t('profileForm.validation.genderRequired')),
|
||||
dob: z.string().min(1, t('profileForm.validation.dobRequired')),
|
||||
dob: z
|
||||
.string()
|
||||
.min(1, t('profileForm.validation.dobRequired'))
|
||||
.refine((value) => isAtLeast18(value), {
|
||||
message: t('profileForm.validation.dobMinAge'),
|
||||
}),
|
||||
pob: z.string().optional(),
|
||||
maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
|
||||
});
|
||||
|
||||
@@ -224,7 +224,8 @@ export function ProfilePage() {
|
||||
idType: currentProfile.address?.idType || '',
|
||||
idNumber: currentProfile.address?.idNumber || '',
|
||||
// Stored as a country name; the select works in alpha-2 codes.
|
||||
nationality: getCountryCode(currentProfile.address?.nationality) || '',
|
||||
// Default to Ethiopian when no nationality is on record yet.
|
||||
nationality: getCountryCode(currentProfile.address?.nationality) || 'ET',
|
||||
primaryPhoneNumber: user?.phoneNumber || '',
|
||||
secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '',
|
||||
email: user?.email || '',
|
||||
|
||||
@@ -70,29 +70,10 @@ function EvidenceModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery(
|
||||
const { data: attachments, isLoading } = useGetAttachmentsQuery(
|
||||
{ ownerType, ownerId: ownerId ?? '' },
|
||||
{ skip: !ownerId },
|
||||
);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const upload = async (file: File | null) => {
|
||||
if (!file || !ownerId) return;
|
||||
setUploading(true);
|
||||
const result = await uploadDocument({
|
||||
ownerType,
|
||||
ownerId,
|
||||
documentKey: 'evidence',
|
||||
file,
|
||||
});
|
||||
setUploading(false);
|
||||
if (result.ok) {
|
||||
notify.success(t('seaRecords.evidence.uploaded'));
|
||||
refetch();
|
||||
} else {
|
||||
notify.error(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const files = (attachments ?? []).flatMap((a) => a.files);
|
||||
|
||||
@@ -119,25 +100,46 @@ function EvidenceModal({
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
|
||||
<FileButton onChange={upload} accept="image/*,application/pdf">
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="light"
|
||||
loading={uploading}
|
||||
leftSection={<IconFileUpload size={16} />}
|
||||
>
|
||||
{t('seaRecords.evidence.upload')}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</RequirePermission>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evidence picker that lives inside the add/edit form. The file is held in
|
||||
* component state and uploaded right after the record is saved, because the
|
||||
* attachment needs an owner id that only exists once the record does.
|
||||
*/
|
||||
function EvidenceField({
|
||||
file,
|
||||
onChange,
|
||||
}: {
|
||||
file: File | null;
|
||||
onChange: (file: File | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
|
||||
<Group gap="sm" align="center">
|
||||
<FileButton onChange={onChange} accept="image/*,application/pdf">
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="light"
|
||||
leftSection={<IconFileUpload size={16} />}
|
||||
>
|
||||
{t('seaRecords.evidence.upload')}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Text size="sm" c={file ? undefined : 'dimmed'}>
|
||||
{file ? file.name : t('seaRecords.evidence.none')}
|
||||
</Text>
|
||||
</Group>
|
||||
</RequirePermission>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sea service
|
||||
|
||||
const EMPTY_SEA_SERVICE = {
|
||||
@@ -168,11 +170,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 [uploading, setUploading] = useState(false);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_SEA_SERVICE);
|
||||
setGrossTonnage('');
|
||||
setEvidenceFile(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -189,6 +194,7 @@ function SeaServiceTab() {
|
||||
dutiesDescription: record.dutiesDescription ?? '',
|
||||
});
|
||||
setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : '');
|
||||
setEvidenceFile(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -207,13 +213,31 @@ function SeaServiceTab() {
|
||||
...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}),
|
||||
};
|
||||
try {
|
||||
let recordId = editing?.id;
|
||||
if (editing) {
|
||||
await updateRecord({ id: editing.id, body }).unwrap();
|
||||
notify.success(t('seaRecords.seaService.updated'));
|
||||
} else {
|
||||
await createRecord(body).unwrap();
|
||||
notify.success(t('seaRecords.seaService.added'));
|
||||
recordId = (await createRecord(body).unwrap()).id;
|
||||
}
|
||||
if (evidenceFile && recordId) {
|
||||
setUploading(true);
|
||||
const result = await uploadDocument({
|
||||
ownerType: 'SEA_SERVICE_RECORD',
|
||||
ownerId: recordId,
|
||||
documentKey: 'evidence',
|
||||
file: evidenceFile,
|
||||
});
|
||||
setUploading(false);
|
||||
if (!result.ok) {
|
||||
notify.error(result.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
notify.success(
|
||||
editing
|
||||
? t('seaRecords.seaService.updated')
|
||||
: t('seaRecords.seaService.added'),
|
||||
);
|
||||
setModalOpen(false);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed')));
|
||||
@@ -363,6 +387,7 @@ function SeaServiceTab() {
|
||||
setForm({ ...form, dutiesDescription: e.target.value })
|
||||
}
|
||||
/>
|
||||
<EvidenceField file={evidenceFile} onChange={setEvidenceFile} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
@@ -370,7 +395,7 @@ function SeaServiceTab() {
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={!valid}
|
||||
loading={creating || updating}
|
||||
loading={creating || updating || uploading}
|
||||
>
|
||||
{editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
|
||||
</Button>
|
||||
@@ -413,10 +438,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 [uploading, setUploading] = useState(false);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_MEDICAL);
|
||||
setEvidenceFile(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -430,6 +458,7 @@ function MedicalTab() {
|
||||
fitnessStatus: certificate.fitnessStatus,
|
||||
restrictions: certificate.restrictions ?? '',
|
||||
});
|
||||
setEvidenceFile(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -445,13 +474,31 @@ function MedicalTab() {
|
||||
...(form.restrictions ? { restrictions: form.restrictions } : {}),
|
||||
};
|
||||
try {
|
||||
let certificateId = editing?.id;
|
||||
if (editing) {
|
||||
await updateCertificate({ id: editing.id, body }).unwrap();
|
||||
notify.success(t('seaRecords.medical.updated'));
|
||||
} else {
|
||||
await createCertificate(body).unwrap();
|
||||
notify.success(t('seaRecords.medical.added'));
|
||||
certificateId = (await createCertificate(body).unwrap()).id;
|
||||
}
|
||||
if (evidenceFile && certificateId) {
|
||||
setUploading(true);
|
||||
const result = await uploadDocument({
|
||||
ownerType: 'MEDICAL_CERTIFICATE',
|
||||
ownerId: certificateId,
|
||||
documentKey: 'evidence',
|
||||
file: evidenceFile,
|
||||
});
|
||||
setUploading(false);
|
||||
if (!result.ok) {
|
||||
notify.error(result.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
notify.success(
|
||||
editing
|
||||
? t('seaRecords.medical.updated')
|
||||
: t('seaRecords.medical.added'),
|
||||
);
|
||||
setModalOpen(false);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed')));
|
||||
@@ -575,6 +622,7 @@ function MedicalTab() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<EvidenceField file={evidenceFile} onChange={setEvidenceFile} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
@@ -582,7 +630,7 @@ function MedicalTab() {
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={!valid}
|
||||
loading={creating || updating}
|
||||
loading={creating || updating || uploading}
|
||||
>
|
||||
{editing ? t('common.save') : t('seaRecords.medical.add')}
|
||||
</Button>
|
||||
|
||||
@@ -418,6 +418,7 @@ export const am: Translations = {
|
||||
lastNameMin: 'የአያት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
|
||||
genderRequired: 'ጾታዎን ይምረጡ',
|
||||
dobRequired: 'የትውልድ ቀንዎን ይምረጡ',
|
||||
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
|
||||
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
|
||||
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
|
||||
},
|
||||
|
||||
@@ -418,6 +418,7 @@ export const en = {
|
||||
lastNameMin: 'Last name must be at least 3 characters',
|
||||
genderRequired: 'Select your gender',
|
||||
dobRequired: 'Select your date of birth',
|
||||
dobMinAge: 'You must be at least 18 years old',
|
||||
maritalStatusRequired: 'Select your marital status',
|
||||
nameParts: 'Enter your first, middle, and last name',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user