mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 10:40:59 +00:00
feat: implement 18+ age validation, enforce document review completion for approvals, and refactor DecisionBar UI
This commit is contained in:
@@ -76,9 +76,13 @@ export function DecisionBar({
|
|||||||
role="region"
|
role="region"
|
||||||
aria-label={t('review.decisionBar', 'Decision bar')}
|
aria-label={t('review.decisionBar', 'Decision bar')}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
{/* Wraps rather than overflows: at narrow widths the nowrap row pushed
|
||||||
|
the workflow buttons past the viewport edge, so Assign, Escalate and
|
||||||
|
Hold were simply not there. Wrapping drops them onto a second line
|
||||||
|
instead of off the screen. */}
|
||||||
|
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||||
{/* Left: where the application stands, and who has it. */}
|
{/* Left: where the application stands, and who has it. */}
|
||||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
<Group gap="sm" wrap="wrap" style={{ minWidth: 0, flex: '1 1 auto' }}>
|
||||||
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
|
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
|
||||||
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -124,7 +128,7 @@ export function DecisionBar({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Right: the decision. */}
|
{/* Right: the decision. */}
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flex: '0 1 auto' }}>
|
||||||
{primary.map((action) => (
|
{primary.map((action) => (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
key={action.id}
|
key={action.id}
|
||||||
@@ -194,10 +198,17 @@ function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
|
|||||||
const button = (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
size={size}
|
size={size}
|
||||||
variant={action.emphasis === 'filled' ? 'filled' : action.emphasis ?? 'light'}
|
variant={
|
||||||
|
action.emphasis === 'filled'
|
||||||
|
? 'filled'
|
||||||
|
: action.emphasis === 'subtle'
|
||||||
|
? 'default'
|
||||||
|
: 'light'
|
||||||
|
}
|
||||||
color={action.color}
|
color={action.color}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
disabled={!action.enabled}
|
disabled={!action.enabled}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
onClick={() => onAction(action)}
|
onClick={() => onAction(action)}
|
||||||
>
|
>
|
||||||
{t(action.labelKey)}
|
{t(action.labelKey)}
|
||||||
|
|||||||
@@ -253,11 +253,18 @@ export interface ResolveContext {
|
|||||||
needsFlags: string;
|
needsFlags: string;
|
||||||
needsCapital: string;
|
needsCapital: string;
|
||||||
needsInspection: string;
|
needsInspection: string;
|
||||||
|
needsDocumentReviews: string;
|
||||||
};
|
};
|
||||||
/** Number of sections/documents the officer has flagged for correction. */
|
/** Number of sections/documents the officer has flagged for correction. */
|
||||||
flaggedCount: number;
|
flaggedCount: number;
|
||||||
/** True when an inspection is scheduled and awaiting a result. */
|
/** True when an inspection is scheduled and awaiting a result. */
|
||||||
hasPendingInspection: boolean;
|
hasPendingInspection: boolean;
|
||||||
|
/**
|
||||||
|
* False while any uploaded document is still unjudged or rejected. Approving
|
||||||
|
* is a statement that every document was checked, so the button stays dead
|
||||||
|
* until the officer has actually judged each one.
|
||||||
|
*/
|
||||||
|
allDocumentsAccepted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,6 +307,13 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
|||||||
return disabled(reasons.notAssigned);
|
return disabled(reasons.notAssigned);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(action.id === 'approve-documents' || action.id === 'final-approve') &&
|
||||||
|
!ctx.allDocumentsAccepted
|
||||||
|
) {
|
||||||
|
return disabled(reasons.needsDocumentReviews);
|
||||||
|
}
|
||||||
|
|
||||||
if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
|
if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
|
||||||
return disabled(reasons.needsFlags);
|
return disabled(reasons.needsFlags);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
useFinalApproveMutation,
|
useFinalApproveMutation,
|
||||||
useGetApplicationForReviewQuery,
|
useGetApplicationForReviewQuery,
|
||||||
useGetAttachmentsQuery,
|
useGetAttachmentsQuery,
|
||||||
|
useGetDocumentReviewsQuery,
|
||||||
useGetInspectionsQuery,
|
useGetInspectionsQuery,
|
||||||
useGetAssignableOfficersQuery,
|
useGetAssignableOfficersQuery,
|
||||||
useGetLicenseTypeRequirementsQuery,
|
useGetLicenseTypeRequirementsQuery,
|
||||||
@@ -162,6 +163,9 @@ export function LicenseReviewPage() {
|
|||||||
// Real officer list, so Assign and Escalate name a person instead of
|
// Real officer list, so Assign and Escalate name a person instead of
|
||||||
// silently reassigning to whoever already held the application.
|
// silently reassigning to whoever already held the application.
|
||||||
const { data: officers = [] } = useGetAssignableOfficersQuery();
|
const { data: officers = [] } = useGetAssignableOfficersQuery();
|
||||||
|
// Same cached query the Documents tab reads, so the decision bar reacts the
|
||||||
|
// moment a verdict is saved.
|
||||||
|
const { data: documentReviews = [] } = useGetDocumentReviewsQuery(id, { skip: !id });
|
||||||
|
|
||||||
const staffTable = useServerTable();
|
const staffTable = useServerTable();
|
||||||
const [flags, setFlags] = useState<FlagMap>({});
|
const [flags, setFlags] = useState<FlagMap>({});
|
||||||
@@ -208,6 +212,19 @@ export function LicenseReviewPage() {
|
|||||||
}, [flags]);
|
}, [flags]);
|
||||||
|
|
||||||
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
|
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
|
||||||
|
|
||||||
|
// Approving means every uploaded document was accepted — one unjudged or
|
||||||
|
// rejected file is enough to keep the decision buttons dead.
|
||||||
|
const allDocumentsAccepted = useMemo(() => {
|
||||||
|
const attachments = data?.attachments ?? [];
|
||||||
|
if (attachments.length === 0) return false;
|
||||||
|
const accepted = new Set(
|
||||||
|
documentReviews
|
||||||
|
.filter((review) => review.decision === 'ACCEPTED')
|
||||||
|
.map((review) => review.documentKey),
|
||||||
|
);
|
||||||
|
return attachments.every((a) => accepted.has(a.documentKey));
|
||||||
|
}, [data?.attachments, documentReviews]);
|
||||||
const flagged = Object.entries(flags);
|
const flagged = Object.entries(flags);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -247,6 +264,7 @@ export function LicenseReviewPage() {
|
|||||||
can,
|
can,
|
||||||
flaggedCount: flagged.length,
|
flaggedCount: flagged.length,
|
||||||
hasPendingInspection: Boolean(pendingInspection),
|
hasPendingInspection: Boolean(pendingInspection),
|
||||||
|
allDocumentsAccepted,
|
||||||
reasons: {
|
reasons: {
|
||||||
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
|
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
|
||||||
notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'),
|
notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'),
|
||||||
@@ -254,9 +272,13 @@ export function LicenseReviewPage() {
|
|||||||
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
|
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
|
||||||
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
|
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
|
||||||
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
|
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
|
||||||
|
needsDocumentReviews: t(
|
||||||
|
'review.disabled.needsDocumentReviews',
|
||||||
|
'Accept every document first',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
|
}, [data, currentUserId, can, flagged.length, pendingInspection, allDocumentsAccepted, t]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
// Skeleton mirrors the real three-zone layout so nothing jumps on load.
|
// Skeleton mirrors the real three-zone layout so nothing jumps on load.
|
||||||
|
|||||||
@@ -949,6 +949,7 @@ export const am: Translations = {
|
|||||||
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
|
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
|
||||||
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
|
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
|
||||||
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
|
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
|
||||||
|
needsDocumentReviews: "መጀመሪያ ሁሉንም ሰነዶች ተቀበል",
|
||||||
},
|
},
|
||||||
reasons: {
|
reasons: {
|
||||||
incompleteDocuments: "ያልተሟሉ ሰነዶች",
|
incompleteDocuments: "ያልተሟሉ ሰነዶች",
|
||||||
|
|||||||
@@ -950,6 +950,7 @@ export const en = {
|
|||||||
needsFlags: 'Flag at least one item to request a correction',
|
needsFlags: 'Flag at least one item to request a correction',
|
||||||
needsCapital: 'Record the verified capital first',
|
needsCapital: 'Record the verified capital first',
|
||||||
needsInspection: 'Requires an inspection result',
|
needsInspection: 'Requires an inspection result',
|
||||||
|
needsDocumentReviews: 'Accept every document first',
|
||||||
},
|
},
|
||||||
reasons: {
|
reasons: {
|
||||||
incompleteDocuments: 'Incomplete documents',
|
incompleteDocuments: 'Incomplete documents',
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import type { TFunction } from 'i18next';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AmharicDatePicker } from '@ema-platform/ui';
|
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) =>
|
export const profileSchema = (t: TFunction) =>
|
||||||
z.object({
|
z.object({
|
||||||
professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
|
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')),
|
middleName: z.string().min(3, t('profileForm.validation.middleNameMin')),
|
||||||
lastName: z.string().min(3, t('profileForm.validation.lastNameMin')),
|
lastName: z.string().min(3, t('profileForm.validation.lastNameMin')),
|
||||||
gender: z.string().min(1, t('profileForm.validation.genderRequired')),
|
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(),
|
pob: z.string().optional(),
|
||||||
maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
|
maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -224,7 +224,8 @@ export function ProfilePage() {
|
|||||||
idType: currentProfile.address?.idType || '',
|
idType: currentProfile.address?.idType || '',
|
||||||
idNumber: currentProfile.address?.idNumber || '',
|
idNumber: currentProfile.address?.idNumber || '',
|
||||||
// Stored as a country name; the select works in alpha-2 codes.
|
// 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 || '',
|
primaryPhoneNumber: user?.phoneNumber || '',
|
||||||
secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '',
|
secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '',
|
||||||
email: user?.email || '',
|
email: user?.email || '',
|
||||||
|
|||||||
@@ -70,29 +70,10 @@ function EvidenceModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery(
|
const { data: attachments, isLoading } = useGetAttachmentsQuery(
|
||||||
{ ownerType, ownerId: ownerId ?? '' },
|
{ ownerType, ownerId: ownerId ?? '' },
|
||||||
{ skip: !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);
|
const files = (attachments ?? []).flatMap((a) => a.files);
|
||||||
|
|
||||||
@@ -119,25 +100,46 @@ function EvidenceModal({
|
|||||||
</Group>
|
</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>
|
</Stack>
|
||||||
</Modal>
|
</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
|
// ---------------------------------------------------------------- sea service
|
||||||
|
|
||||||
const EMPTY_SEA_SERVICE = {
|
const EMPTY_SEA_SERVICE = {
|
||||||
@@ -168,11 +170,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 [uploading, setUploading] = 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,6 +194,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,13 +213,31 @@ 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(t('seaRecords.seaService.updated'));
|
|
||||||
} else {
|
} else {
|
||||||
await createRecord(body).unwrap();
|
recordId = (await createRecord(body).unwrap()).id;
|
||||||
notify.success(t('seaRecords.seaService.added'));
|
|
||||||
}
|
}
|
||||||
|
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);
|
setModalOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed')));
|
notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed')));
|
||||||
@@ -363,6 +387,7 @@ function SeaServiceTab() {
|
|||||||
setForm({ ...form, dutiesDescription: e.target.value })
|
setForm({ ...form, dutiesDescription: e.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<EvidenceField file={evidenceFile} onChange={setEvidenceFile} />
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
@@ -370,7 +395,7 @@ function SeaServiceTab() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!valid}
|
disabled={!valid}
|
||||||
loading={creating || updating}
|
loading={creating || updating || uploading}
|
||||||
>
|
>
|
||||||
{editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
|
{editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -413,10 +438,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 [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setForm(EMPTY_MEDICAL);
|
setForm(EMPTY_MEDICAL);
|
||||||
|
setEvidenceFile(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -430,6 +458,7 @@ function MedicalTab() {
|
|||||||
fitnessStatus: certificate.fitnessStatus,
|
fitnessStatus: certificate.fitnessStatus,
|
||||||
restrictions: certificate.restrictions ?? '',
|
restrictions: certificate.restrictions ?? '',
|
||||||
});
|
});
|
||||||
|
setEvidenceFile(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -445,13 +474,31 @@ 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(t('seaRecords.medical.updated'));
|
|
||||||
} else {
|
} else {
|
||||||
await createCertificate(body).unwrap();
|
certificateId = (await createCertificate(body).unwrap()).id;
|
||||||
notify.success(t('seaRecords.medical.added'));
|
|
||||||
}
|
}
|
||||||
|
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);
|
setModalOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed')));
|
notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed')));
|
||||||
@@ -575,6 +622,7 @@ function MedicalTab() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<EvidenceField file={evidenceFile} onChange={setEvidenceFile} />
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
@@ -582,7 +630,7 @@ function MedicalTab() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!valid}
|
disabled={!valid}
|
||||||
loading={creating || updating}
|
loading={creating || updating || uploading}
|
||||||
>
|
>
|
||||||
{editing ? t('common.save') : t('seaRecords.medical.add')}
|
{editing ? t('common.save') : t('seaRecords.medical.add')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ export const am: Translations = {
|
|||||||
lastNameMin: 'የአያት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
|
lastNameMin: 'የአያት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
|
||||||
genderRequired: 'ጾታዎን ይምረጡ',
|
genderRequired: 'ጾታዎን ይምረጡ',
|
||||||
dobRequired: 'የትውልድ ቀንዎን ይምረጡ',
|
dobRequired: 'የትውልድ ቀንዎን ይምረጡ',
|
||||||
|
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
|
||||||
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
|
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
|
||||||
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
|
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ export const en = {
|
|||||||
lastNameMin: 'Last name must be at least 3 characters',
|
lastNameMin: 'Last name must be at least 3 characters',
|
||||||
genderRequired: 'Select your gender',
|
genderRequired: 'Select your gender',
|
||||||
dobRequired: 'Select your date of birth',
|
dobRequired: 'Select your date of birth',
|
||||||
|
dobMinAge: 'You must be at least 18 years old',
|
||||||
maritalStatusRequired: 'Select your marital status',
|
maritalStatusRequired: 'Select your marital status',
|
||||||
nameParts: 'Enter your first, middle, and last name',
|
nameParts: 'Enter your first, middle, and last name',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user