mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 15:25:47 +00:00
Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor
Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical table component: kept its API plus teammate i18n/feature work, kept the folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all 27 tables, removed the parallel lib/table implementation, and fixed pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { ActionIcon, Button, Group } from '@mantine/core';
|
||||
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Question } from '../../types/question';
|
||||
|
||||
export function questionActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
isSubmittingReview: boolean;
|
||||
onSubmitForApproval: (question: Question) => void;
|
||||
onReview: (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => void;
|
||||
onEdit: (question: Question) => void;
|
||||
onDelete: (question: Question) => void;
|
||||
},
|
||||
): AdvancedColumn<Question> {
|
||||
return {
|
||||
header: '',
|
||||
label: t('question.columns.actions', 'Actions'),
|
||||
cell: ({ row }) => {
|
||||
const q = row.original;
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconSend size={12} />}
|
||||
loading={handlers.isSubmittingReview}
|
||||
onClick={() => handlers.onSubmitForApproval(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Button>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<>
|
||||
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
|
||||
{t('question.qc.approve')}
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
|
||||
{t('question.qc.reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => handlers.onReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ActionIcon, Badge, Button, Group, Text } from '@mantine/core';
|
||||
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Question, QuestionStatus } from '../../types/question';
|
||||
|
||||
/** Bank items travel DRAFT → PENDING_APPROVAL → APPROVED (US-EXAM-003). */
|
||||
@@ -13,103 +12,40 @@ const QC_COLOR: Record<QuestionStatus, string> = {
|
||||
RETIRED: 'dark',
|
||||
};
|
||||
|
||||
/** QC actions are text buttons by design — kept as a rendered column. */
|
||||
export function questionColumns(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
opts: {
|
||||
locale: 'en' | 'am';
|
||||
getCertName: (id: string) => string;
|
||||
isSubmittingReview: boolean;
|
||||
onSubmitForApproval: (question: Question) => void;
|
||||
onReview: (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => void;
|
||||
onEdit: (question: Question) => void;
|
||||
onDelete: (question: Question) => void;
|
||||
},
|
||||
): AdvancedTableColumn<Question>[] {
|
||||
): AdvancedColumn<Question>[] {
|
||||
return [
|
||||
{
|
||||
key: 'title',
|
||||
header: t('question.columns.title'),
|
||||
render: (q) => <Text fz="sm" maw={300} lineClamp={2}>{q.title[handlers.locale]}</Text>,
|
||||
cell: ({ row }) => <Text fz="sm" maw={300} lineClamp={2}>{row.original.title[opts.locale]}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'certification',
|
||||
header: t('question.columns.certification'),
|
||||
render: (q) => <Text fz="sm">{handlers.getCertName(q.certificationId)}</Text>,
|
||||
cell: ({ row }) => <Text fz="sm">{opts.getCertName(row.original.certificationId)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'form',
|
||||
header: t('question.columns.form'),
|
||||
render: (q) => <Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'points',
|
||||
header: t('question.columns.points'),
|
||||
render: (q) => <Text fz="sm" fw={600}>{q.points}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('question.qc.column'),
|
||||
render: (q) => (
|
||||
<Badge size="sm" variant="light" color={QC_COLOR[q.status] ?? 'gray'} title={q.reviewRemark ?? undefined}>
|
||||
{t(`question.qc.${q.status}`)}
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||
{t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (q) => (
|
||||
<Group gap="xs">
|
||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconSend size={12} />}
|
||||
loading={handlers.isSubmittingReview}
|
||||
onClick={() => handlers.onSubmitForApproval(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Button>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
onClick={() => handlers.onReview(q, 'APPROVED')}
|
||||
>
|
||||
{t('question.qc.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => handlers.onReview(q, 'REJECTED')}
|
||||
>
|
||||
{t('question.qc.reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => handlers.onReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
header: t('question.columns.points'),
|
||||
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.points}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('question.qc.column'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={QC_COLOR[row.original.status] ?? 'gray'} title={row.original.reviewRemark ?? undefined}>
|
||||
{t(`question.qc.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
@@ -19,7 +17,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
||||
import {
|
||||
@@ -32,6 +30,7 @@ import {
|
||||
} from '../../api/question-api';
|
||||
import type { Question, QuestionForm } from '../../types/question';
|
||||
import { questionColumns } from './columns';
|
||||
import { questionActionsColumn } from './actions';
|
||||
|
||||
function QuestionForm({
|
||||
editing,
|
||||
@@ -78,7 +77,7 @@ function QuestionForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<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 />
|
||||
@@ -92,21 +91,23 @@ function QuestionForm({
|
||||
<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>
|
||||
<Group justify="flex-end">
|
||||
<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>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError, refetch } = useGetQuestionsQuery();
|
||||
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();
|
||||
@@ -128,6 +129,7 @@ export function QuestionPage() {
|
||||
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] ?? '-';
|
||||
|
||||
@@ -194,14 +196,24 @@ export function QuestionPage() {
|
||||
notify.success(t('question.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('question.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
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">
|
||||
@@ -223,27 +235,33 @@ export function QuestionPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Card withBorder padding={0}>
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>{t('question.pool')}</Text>
|
||||
<Select placeholder={t('question.filterByCertification')} data={[{ value: '', label: 'All' }, ...certOptions]} value={certFilter} onChange={(v) => setCertFilter(v ?? null)} size="sm" style={{ width: 280 }} clearable />
|
||||
<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
|
||||
/>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={questionColumns(t, {
|
||||
locale,
|
||||
getCertName,
|
||||
isSubmittingReview,
|
||||
onSubmitForApproval: handleSubmitForApproval,
|
||||
onReview: openReview,
|
||||
onEdit: (q) => { setEditing(q); setShowForm(true); },
|
||||
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
|
||||
})}
|
||||
data={filtered}
|
||||
rowKey={(q) => q.id}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('question.noQuestions')}
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('question.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('question.noQuestions')}
|
||||
/>
|
||||
</Paper>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(reviewTarget)}
|
||||
@@ -279,10 +297,10 @@ export function QuestionPage() {
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user