mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 15:25:47 +00:00
220 lines
8.8 KiB
TypeScript
220 lines
8.8 KiB
TypeScript
import { useState } from 'react';
|
|
import { Stack, Button, Modal, Text, TextInput, Textarea, Select, Card, Switch } from '@mantine/core';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {IconPlus} from '@tabler/icons-react';
|
|
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
|
import { extractErrorMessage, useGetRanksQuery, useLocalized } from '@ema-platform/api';
|
|
import {
|
|
useGetCertificationsQuery,
|
|
useCreateCertificationMutation,
|
|
useUpdateCertificationMutation,
|
|
useDeleteCertificationMutation,
|
|
} from '../../api/certification-api';
|
|
import { type Certification } from '../../types/certification';
|
|
import { certificationColumns } from './columns';
|
|
import { certificationActionsColumn } from './actions';
|
|
|
|
interface CertificationFormValues {
|
|
nameEn: string;
|
|
nameAm: string;
|
|
descEn: string;
|
|
descAm: string;
|
|
rankKey: string | null;
|
|
isActive: boolean;
|
|
}
|
|
|
|
function CertificationForm({
|
|
editing,
|
|
rankOptions,
|
|
isSubmitting,
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
editing: Certification | null;
|
|
rankOptions: { value: string; label: string }[];
|
|
isSubmitting: boolean;
|
|
onSubmit: (values: CertificationFormValues, isEdit: boolean) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [nameEn, setNameEn] = useState(editing?.name?.en ?? '');
|
|
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
|
|
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
|
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
|
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
|
|
const [isActive, setIsActive] = useState(editing?.isActive ?? true);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!nameEn.trim() || !nameAm.trim()) {
|
|
notify.error(t('certification.validation.nameRequired', 'Both English and Amharic names are required'));
|
|
return;
|
|
}
|
|
onSubmit({ nameEn: nameEn.trim(), nameAm: nameAm.trim(), descEn, descAm, rankKey, isActive }, !!editing);
|
|
};
|
|
|
|
return (
|
|
<Modal opened onClose={onCancel} title={editing ? t('certification.update') : t('certification.add')} size="md">
|
|
<form onSubmit={handleSubmit}>
|
|
<Stack gap="sm">
|
|
<TextInput label={t('certification.form.nameEn')} placeholder={t('certification.form.nameEnPlaceholder')} value={nameEn} onChange={(e) => setNameEn(e.currentTarget.value)} size="sm" required />
|
|
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
|
|
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
|
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
|
<Select
|
|
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
|
|
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
|
|
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
|
|
data={rankOptions}
|
|
value={rankKey}
|
|
onChange={setRankKey}
|
|
size="sm"
|
|
clearable
|
|
searchable
|
|
/>
|
|
{editing && (
|
|
<Switch
|
|
label={t('certification.form.isActive', 'Active')}
|
|
description={t(
|
|
'certification.form.isActiveHint',
|
|
'Inactive certifications stay on existing exams but are not offered for new ones.',
|
|
)}
|
|
checked={isActive}
|
|
onChange={(e) => setIsActive(e.currentTarget.checked)}
|
|
size="sm"
|
|
/>
|
|
)}
|
|
<ModalFooter>
|
|
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
|
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
|
</ModalFooter>
|
|
</Stack>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export function CertificationPage() {
|
|
const { t, i18n } = useTranslation();
|
|
const locale = i18n.language as 'en' | 'am';
|
|
const localized = useLocalized();
|
|
// Server refusals (`rank_not_found`, `certification_in_use`) arrive as
|
|
// codes; this maps them to the sentences the administrator can act on.
|
|
const showError = (e: unknown) => notify.error(extractErrorMessage(e, t('certification.error')));
|
|
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
|
const { data: rankRes } = useGetRanksQuery();
|
|
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
|
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
|
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
|
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
|
const [deleteCert] = useDeleteCertificationMutation();
|
|
|
|
const certifications = data?.items ?? [];
|
|
|
|
const [editing, setEditing] = useState<Certification | null>(null);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<Certification | null>(null);
|
|
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
|
|
|
const resetForm = () => {
|
|
setEditing(null);
|
|
setShowForm(false);
|
|
};
|
|
|
|
const handleSubmit = async (values: CertificationFormValues, isEdit: boolean) => {
|
|
const name = { en: values.nameEn, am: values.nameAm };
|
|
const description = { en: values.descEn, am: values.descAm };
|
|
try {
|
|
if (isEdit && editing) {
|
|
// null clears a previously-set rank; undefined would leave it
|
|
// untouched server-side, so the two are not interchangeable here.
|
|
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey, isActive: values.isActive }).unwrap();
|
|
notify.success(t('certification.updated'));
|
|
} else {
|
|
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
|
|
notify.success(t('certification.created'));
|
|
}
|
|
resetForm();
|
|
} catch (e) {
|
|
showError(e);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
try {
|
|
await deleteCert(deleteTarget.id).unwrap();
|
|
notify.success(t('certification.deleted'));
|
|
closeDelete();
|
|
setDeleteTarget(null);
|
|
} catch (e) {
|
|
showError(e);
|
|
}
|
|
};
|
|
|
|
if (isError)
|
|
return <ErrorState title={t('certification.loadError')} onRetry={refetch} />;
|
|
|
|
const columns = [
|
|
...certificationColumns(t, locale),
|
|
certificationActionsColumn(t, {
|
|
onEdit: (cert) => { setEditing(cert); setShowForm(true); },
|
|
onDelete: (cert) => { setDeleteTarget(cert); openDelete(); },
|
|
}),
|
|
];
|
|
|
|
const page = paginate(certifications);
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
<PageHeader
|
|
title={t('certification.title')}
|
|
subtitle={t('certification.subtitle')}
|
|
noMargin
|
|
action={
|
|
!showForm && (
|
|
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
|
{t('certification.add')}
|
|
</Button>
|
|
)
|
|
}
|
|
/>
|
|
|
|
{showForm && (
|
|
<CertificationForm
|
|
editing={editing}
|
|
rankOptions={rankOptions}
|
|
isSubmitting={isCreating || isUpdating}
|
|
onSubmit={handleSubmit}
|
|
onCancel={resetForm}
|
|
/>
|
|
)}
|
|
|
|
<Card withBorder padding={0}>
|
|
<AdvancedTable
|
|
columns={columns}
|
|
data={page.rows}
|
|
tableName={t('certification.title')}
|
|
itemCount={page.itemCount}
|
|
pageIndex={page.pageIndex}
|
|
onPageChange={setPageIndex}
|
|
pageSize={pageSize}
|
|
onPageSizeChange={setPageSize}
|
|
refresh={refetch}
|
|
isLoading={isFetching}
|
|
emptyText={t('certification.noItems')}
|
|
/>
|
|
</Card>
|
|
|
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
|
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget ? localized(deleteTarget.name) : '' })}</Text>
|
|
<ModalFooter>
|
|
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
|
|
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
|
|
</ModalFooter>
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
}
|