mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Stack,
|
|
Title,
|
|
Group,
|
|
Button,
|
|
Badge,
|
|
Modal,
|
|
Text,
|
|
TextInput,
|
|
Alert,
|
|
Select,
|
|
NumberInput,
|
|
Textarea,
|
|
} from '@mantine/core';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
|
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
|
import { extractErrorMessage } from '@ema-platform/api';
|
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
|
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
|
import {
|
|
useGetQuestionsQuery,
|
|
useCreateQuestionMutation,
|
|
useUpdateQuestionMutation,
|
|
useDeleteQuestionMutation,
|
|
useSubmitQuestionMutation,
|
|
useReviewQuestionMutation,
|
|
} from '../../api/question-api';
|
|
import type { Question, QuestionForm } from '../../types/question';
|
|
import { questionColumns } from './columns';
|
|
import { questionActionsColumn } from './actions';
|
|
|
|
function QuestionForm({
|
|
editing,
|
|
certOptions,
|
|
isSubmitting,
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
editing: Question | null;
|
|
certOptions: { value: string; label: string }[];
|
|
isSubmitting: boolean;
|
|
onSubmit: (values: {
|
|
certificationId: string;
|
|
titleEn: string;
|
|
titleAm: string;
|
|
form: string;
|
|
points: number;
|
|
days: number;
|
|
hours: number;
|
|
minutes: number;
|
|
}, isEdit: boolean) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
|
|
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
|
|
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
|
|
|
|
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
|
const [points, setPoints] = useState<number>(editing?.points ?? 0);
|
|
const [days, setDays] = useState(editing?.time?.days ?? 0);
|
|
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
|
|
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!certificationId || !titleEn || !titleAm || !form) {
|
|
notify.error('Please fill all required fields');
|
|
return;
|
|
}
|
|
onSubmit({
|
|
certificationId, titleEn, titleAm, form, points, days, hours, minutes
|
|
}, !!editing);
|
|
};
|
|
|
|
return (
|
|
<Modal opened onClose={onCancel} title={editing ? t('question.update') : t('question.addQuestion')} size="lg">
|
|
<form onSubmit={handleSubmit}>
|
|
<Stack gap="sm">
|
|
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
|
<TextInput label={t('question.form.titleEn')} placeholder={t('question.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
|
|
<TextInput label={t('question.form.titleAm')} placeholder={t('question.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
|
|
<Select label={t('question.form.form')} placeholder={t('question.form.selectForm')} data={[{ value: 'ESSAY', label: t('question.form.essay') }, { value: 'CHOICE', label: t('question.form.choice') }]} value={form} onChange={setForm} size="sm" required />
|
|
<NumberInput label={t('question.form.points')} placeholder={t('question.form.pointsPlaceholder')} value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required />
|
|
<Text fz="sm" fw={500}>{t('question.form.timeAllowed')}</Text>
|
|
<Group gap="sm" grow>
|
|
<NumberInput label={t('question.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
|
|
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
|
|
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
|
</Group>
|
|
<ModalFooter>
|
|
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
|
|
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
|
|
</ModalFooter>
|
|
</Stack>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export function QuestionPage() {
|
|
const { t, i18n } = useTranslation();
|
|
const locale = i18n.language as 'en' | 'am';
|
|
const { handleError } = useErrorHandler();
|
|
const { data: certRes } = useGetCertificationsQuery();
|
|
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
|
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
|
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
|
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
|
const [deleteQ] = useDeleteQuestionMutation();
|
|
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
|
|
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
|
|
|
|
const certifications = certRes?.items ?? [];
|
|
const questions = data?.items ?? [];
|
|
|
|
const [editing, setEditing] = useState<Question | null>(null);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
|
|
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
|
const [certFilter, setCertFilter] = useState<string | null>(null);
|
|
const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
|
|
const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED');
|
|
const [reviewRemark, setReviewRemark] = useState('');
|
|
|
|
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
|
|
|
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
|
|
const page = paginate(filtered);
|
|
|
|
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
|
|
|
const resetForm = () => { setEditing(null); setShowForm(false); };
|
|
|
|
const handleSubmit = async (values: {
|
|
certificationId: string; titleEn: string; titleAm: string;
|
|
form: string; points: number; days: number; hours: number; minutes: number;
|
|
}, isEdit: boolean) => {
|
|
const title = { en: values.titleEn, am: values.titleAm };
|
|
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
|
|
try {
|
|
if (isEdit && editing) {
|
|
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
|
|
notify.success(t('question.updated'));
|
|
} else {
|
|
await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap();
|
|
notify.success(t('question.created'));
|
|
}
|
|
resetForm();
|
|
} catch {
|
|
notify.error(t('question.error'));
|
|
}
|
|
};
|
|
|
|
const handleSubmitForApproval = async (question: Question) => {
|
|
try {
|
|
await submitQ(question.id).unwrap();
|
|
notify.success(t('question.qc.submitted'));
|
|
} catch (error) {
|
|
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
|
}
|
|
};
|
|
|
|
const openReview = (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => {
|
|
setReviewTarget(question);
|
|
setReviewOutcome(outcome);
|
|
setReviewRemark('');
|
|
};
|
|
|
|
const handleReview = async () => {
|
|
if (!reviewTarget) return;
|
|
if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) {
|
|
notify.error(t('question.qc.remarkRequired'));
|
|
return;
|
|
}
|
|
try {
|
|
await reviewQ({
|
|
id: reviewTarget.id,
|
|
outcome: reviewOutcome,
|
|
remark: reviewRemark.trim() || undefined,
|
|
}).unwrap();
|
|
notify.success(t('question.qc.reviewed'));
|
|
setReviewTarget(null);
|
|
} catch (error) {
|
|
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
try {
|
|
await deleteQ(deleteTarget.id).unwrap();
|
|
notify.success(t('question.deleted'));
|
|
closeDelete();
|
|
setDeleteTarget(null);
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
};
|
|
|
|
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
|
|
|
|
const columns: AdvancedColumn<Question>[] = [
|
|
...questionColumns(t, { locale, getCertName }),
|
|
questionActionsColumn(t, {
|
|
isSubmittingReview,
|
|
onSubmitForApproval: handleSubmitForApproval,
|
|
onReview: openReview,
|
|
onEdit: (q) => { setEditing(q); setShowForm(true); },
|
|
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
|
|
}),
|
|
];
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
<Group justify="space-between" align="flex-end">
|
|
<Title order={2}>{t('question.title')}</Title>
|
|
{!showForm && (
|
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
|
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
|
{t('question.addQuestion')}
|
|
</Button>
|
|
</RequirePermission>
|
|
)}
|
|
</Group>
|
|
|
|
{showForm && (
|
|
<QuestionForm
|
|
editing={editing}
|
|
certOptions={certOptions}
|
|
isSubmitting={isCreating || isUpdating}
|
|
onSubmit={handleSubmit}
|
|
onCancel={resetForm}
|
|
/>
|
|
)}
|
|
|
|
<AdvancedTable
|
|
columns={columns}
|
|
data={page.rows}
|
|
title={t('question.pool')}
|
|
tableName={t('question.title')}
|
|
toolbar={
|
|
<Select
|
|
placeholder={t('question.filterByCertification')}
|
|
data={[{ value: '', label: 'All' }, ...certOptions]}
|
|
value={certFilter}
|
|
onChange={(v) => { setCertFilter(v ?? null); setPageIndex(0); }}
|
|
size="sm"
|
|
style={{ width: 280 }}
|
|
clearable
|
|
/>
|
|
}
|
|
itemCount={page.itemCount}
|
|
pageIndex={page.pageIndex}
|
|
onPageChange={setPageIndex}
|
|
pageSize={pageSize}
|
|
onPageSizeChange={setPageSize}
|
|
refresh={refetch}
|
|
isLoading={isFetching}
|
|
emptyText={t('question.noQuestions')}
|
|
/>
|
|
|
|
<Modal
|
|
opened={Boolean(reviewTarget)}
|
|
onClose={() => setReviewTarget(null)}
|
|
title={t('question.qc.reviewTitle')}
|
|
size="md"
|
|
radius="lg"
|
|
>
|
|
<Stack gap="sm">
|
|
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text>
|
|
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text>
|
|
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content">
|
|
{t(`question.qc.${reviewOutcome}`)}
|
|
</Badge>
|
|
<Textarea
|
|
label={t('question.qc.remark')}
|
|
minRows={3}
|
|
autosize
|
|
value={reviewRemark}
|
|
onChange={(e) => setReviewRemark(e.currentTarget.value)}
|
|
required={reviewOutcome !== 'APPROVED'}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" size="sm" onClick={() => setReviewTarget(null)}>
|
|
{t('question.cancel')}
|
|
</Button>
|
|
<Button size="sm" loading={isReviewing} onClick={handleReview}>
|
|
{t(`question.qc.${reviewOutcome === 'APPROVED' ? 'approve' : reviewOutcome === 'REJECTED' ? 'reject' : 'retire'}`)}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
|
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
|
<ModalFooter>
|
|
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
|
|
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
|
|
</ModalFooter>
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
}
|