mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 17:38:14 +00:00
feat(i18n): add new translations for refresh, view, toggle columns, and no results
refactor(payment): remove payment API client and related tests, consolidating payment logic refactor(payment): delete payment modal and associated constants, hooks, and types fix(ui): enhance AdvancedTable with refresh button and column toggle functionality fix(ui): implement pagination in useServerTable for better data handling
This commit is contained in:
@@ -4,7 +4,6 @@ import {
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
@@ -12,14 +11,13 @@ import {
|
||||
TextInput,
|
||||
Textarea,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
@@ -76,7 +74,8 @@ export function CertificationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data, isLoading, isError } = useGetCertificationsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
||||
const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
|
||||
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||
const [deleteCert] = useDeleteCertificationMutation();
|
||||
@@ -122,9 +121,44 @@ export function CertificationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
||||
|
||||
const columns: AdvancedColumn<Certification>[] = [
|
||||
{
|
||||
header: t('certification.columns.name'),
|
||||
cell: ({ row }) => <Text fz="sm" fw={500}>{row.original.name[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.description'),
|
||||
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.isActive ? 'teal' : 'gray'}>
|
||||
{row.original.isActive ? t('certification.status.active') : t('certification.status.inactive')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('certification.columns.actions', 'Actions'),
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(row.original); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(row.original); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const page = paginate(certifications);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
@@ -148,50 +182,20 @@ export function CertificationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('certification.columns.name')}</Table.Th>
|
||||
<Table.Th>{t('certification.columns.description')}</Table.Th>
|
||||
<Table.Th>{t('certification.columns.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{certifications.map((cert) => (
|
||||
<Table.Tr key={cert.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{cert.name[locale]}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" lineClamp={2} maw={250}>{cert.description[locale]}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={cert.isActive ? 'teal' : 'gray'}>
|
||||
{cert.isActive ? t('certification.status.active') : t('certification.status.inactive')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(cert); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(cert); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{certifications.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('certification.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('certification.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
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?.name?.[locale] })}</Text>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
@@ -13,8 +12,7 @@ import {
|
||||
TextInput,
|
||||
Textarea,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
@@ -33,7 +31,7 @@ import {
|
||||
IconClipboardList,
|
||||
IconDetails,
|
||||
} from "@tabler/icons-react";
|
||||
import { notify, useErrorHandler } from "@ema-platform/ui";
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from "@ema-platform/ui";
|
||||
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
@@ -356,7 +354,8 @@ export function ExamPage() {
|
||||
const { handleError } = useErrorHandler();
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError } = useGetExamsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetExamsQuery();
|
||||
const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
|
||||
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
||||
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
||||
const [deleteExam] = useDeleteExamMutation();
|
||||
@@ -431,12 +430,6 @@ export function ExamPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
if (isError)
|
||||
return (
|
||||
<Alert
|
||||
@@ -446,6 +439,109 @@ export function ExamPage() {
|
||||
/>
|
||||
);
|
||||
|
||||
const columns: AdvancedColumn<Exam>[] = [
|
||||
{
|
||||
header: t("exam.columns.title"),
|
||||
cell: ({ row }) => (
|
||||
<Text
|
||||
fz="sm"
|
||||
fw={500}
|
||||
c="blue"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/exams/${row.original.id}`)}
|
||||
>
|
||||
{row.original.title[locale]}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.certification"),
|
||||
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.date"),
|
||||
cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>,
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.type"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.type === "WRITTEN" ? "blue" : "orange"}>
|
||||
{t(`exam.type.${row.original.type}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.form"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.form === "ESSAY" ? "blue" : "violet"}>
|
||||
{t(`exam.formType.${row.original.form}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.venue"),
|
||||
cell: ({ row }) => <Text fz="sm">{row.original.venue}</Text>,
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.questions"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{row.original.questions?.length ?? 0}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{t(`exam.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.actions"),
|
||||
align: "right",
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditing(row.original);
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDeleteTarget(row.original);
|
||||
openDelete();
|
||||
}}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigate(`/exams/${row.original.id}`);
|
||||
}}
|
||||
>
|
||||
<IconDetails size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const page = paginate(exams);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
@@ -477,127 +573,20 @@ export function ExamPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("exam.columns.title")}</Table.Th>
|
||||
<Table.Th>{t("exam.columns.certification")}</Table.Th>
|
||||
<Table.Th>{t("exam.columns.date")}</Table.Th>
|
||||
<Table.Th>{t("exam.columns.type")}</Table.Th>
|
||||
<Table.Th>{t("exam.columns.form")}</Table.Th>
|
||||
<Table.Th>{t("exam.columns.venue")}</Table.Th>
|
||||
<Table.Th>{t("exam.columns.questions")}</Table.Th>
|
||||
<Table.Th>{t("exam.columns.status")}</Table.Th>
|
||||
<Table.Th>{t("exam.columns.actions")}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{exams.map((exam) => (
|
||||
<Table.Tr key={exam.id}>
|
||||
<Table.Td>
|
||||
<Text
|
||||
fz="sm"
|
||||
fw={500}
|
||||
c="blue"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/exams/${exam.id}`)}
|
||||
>
|
||||
{exam.title[locale]}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{getCertName(exam.certificationId)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{exam.date}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={exam.type === "WRITTEN" ? "blue" : "orange"}
|
||||
>
|
||||
{t(`exam.type.${exam.type}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={exam.form === "ESSAY" ? "blue" : "violet"}
|
||||
>
|
||||
{t(`exam.formType.${exam.form}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{exam.venue}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{exam.questions?.length ?? 0}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[exam.status]}
|
||||
>
|
||||
{t(`exam.status.${exam.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditing(exam);
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDeleteTarget(exam);
|
||||
openDelete();
|
||||
}}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigate(`/exams/${exam.id}`);
|
||||
}}
|
||||
>
|
||||
<IconDetails size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{exams.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={9}>
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t("exam.noItems")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t("exam.title")}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t("exam.noItems")}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal
|
||||
|
||||
@@ -322,6 +322,7 @@ export function LicenseQueuePage() {
|
||||
},
|
||||
{
|
||||
header: sortableHeader(t('queue.number', 'App #'), 'applicationNumber'),
|
||||
label: t('queue.number', 'App #'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{row.original.applicationNumber}
|
||||
@@ -330,6 +331,7 @@ export function LicenseQueuePage() {
|
||||
},
|
||||
{
|
||||
header: sortableHeader(t('queue.company', 'Company'), 'companyName'),
|
||||
label: t('queue.company', 'Company'),
|
||||
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
@@ -346,6 +348,7 @@ export function LicenseQueuePage() {
|
||||
},
|
||||
{
|
||||
header: sortableHeader(t('queue.statusCol', 'Status'), 'status'),
|
||||
label: t('queue.statusCol', 'Status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
||||
{STATUS_LABELS[row.original.status]}
|
||||
@@ -354,6 +357,7 @@ export function LicenseQueuePage() {
|
||||
},
|
||||
{
|
||||
header: sortableHeader(t('queue.submitted', 'Submitted'), 'submittedAt'),
|
||||
label: t('queue.submitted', 'Submitted'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.submittedAt
|
||||
@@ -378,6 +382,7 @@ export function LicenseQueuePage() {
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('queue.actionsColumn', 'Actions'),
|
||||
align: 'right',
|
||||
size: 140,
|
||||
cell: ({ row }) =>
|
||||
|
||||
@@ -4,15 +4,13 @@ import {
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
@@ -20,7 +18,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
import {
|
||||
useGetQuestionsQuery,
|
||||
@@ -104,7 +102,8 @@ export function QuestionPage() {
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError } = useGetQuestionsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
|
||||
const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
|
||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||
const [deleteQ] = useDeleteQuestionMutation();
|
||||
@@ -121,6 +120,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] ?? '-';
|
||||
|
||||
@@ -158,9 +158,53 @@ export function QuestionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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>[] = [
|
||||
{
|
||||
header: t('question.columns.title'),
|
||||
cell: ({ row }) => <Text fz="sm" maw={300} lineClamp={2}>{row.original.title[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('question.columns.certification'),
|
||||
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('question.columns.form'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||
{t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('question.columns.points'),
|
||||
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.points}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('question.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.isActive ? 'teal' : 'gray'}>
|
||||
{row.original.isActive ? t('question.status.active') : t('question.status.inactive')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('question.columns.actions', 'Actions'),
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(row.original); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(row.original); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
@@ -182,54 +226,32 @@ 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>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('question.columns.title')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.certification')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.form')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.points')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((q) => (
|
||||
<Table.Tr key={q.id}>
|
||||
<Table.Td><Text fz="sm" maw={300} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{getCertName(q.certificationId)}</Text></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={q.isActive ? 'teal' : 'gray'}>{q.isActive ? t('question.status.active') : t('question.status.inactive')}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(q); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(q); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('question.noQuestions')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('question.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('question.noQuestions')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Modal,
|
||||
Text,
|
||||
Paper,
|
||||
Card,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
IconChartBar,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput, useErrorHandler } from '@ema-platform/ui';
|
||||
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
@@ -97,7 +98,8 @@ export function ResultPage() {
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: examRes } = useGetExamsQuery();
|
||||
const { data, isLoading, isError } = useGetResultsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetResultsQuery();
|
||||
const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
|
||||
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
|
||||
|
||||
const exams = examRes?.items ?? [];
|
||||
@@ -207,9 +209,76 @@ export function ResultPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
|
||||
|
||||
const columns: AdvancedColumn<Result>[] = [
|
||||
{
|
||||
header: t('result.columns.seafarer'),
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm" fw={500}>
|
||||
{row.original.seafarer
|
||||
? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}`
|
||||
: row.original.seafarerId.slice(0, 8)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('result.columns.exam'),
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm">{row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('result.columns.totalScore'),
|
||||
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.totalScore}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('result.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[row.original.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('result.columns.date'),
|
||||
cell: ({ row }) => <Text fz="sm">{new Date(row.original.createdAt).toLocaleDateString()}</Text>,
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('result.columns.actions', 'Actions'),
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => viewDetail(row.original)}>
|
||||
{t('result.action.viewEdit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
onClick={() => { setDeleteTarget(row.original); openDelete(); }}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const page = paginate(filtered);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
@@ -229,7 +298,7 @@ export function ResultPage() {
|
||||
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Card withBorder padding={0}>
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>{t('result.section')}</Text>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
@@ -237,7 +306,7 @@ export function ResultPage() {
|
||||
placeholder={t('result.search.seafarer')}
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
onChange={(e) => { setSearchQuery(e.currentTarget.value); setPageIndex(0); }}
|
||||
size="sm"
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
@@ -245,7 +314,7 @@ export function ResultPage() {
|
||||
placeholder={t('result.search.filterByExam')}
|
||||
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
|
||||
value={examFilter}
|
||||
onChange={(v) => setExamFilter(v ?? null)}
|
||||
onChange={(v) => { setExamFilter(v ?? null); setPageIndex(0); }}
|
||||
size="sm"
|
||||
style={{ width: 280 }}
|
||||
clearable
|
||||
@@ -253,77 +322,19 @@ export function ResultPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('result.columns.seafarer')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.exam')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.status')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.date')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>
|
||||
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{r.exam ? r.exam.title[locale] : getExamTitle(r.examId)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{r.totalScore}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${r.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconEye size={13} />}
|
||||
onClick={() => viewDetail(r)}
|
||||
>
|
||||
{t('result.action.viewEdit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
onClick={() => { setDeleteTarget(r); openDelete(); }}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('result.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('result.noItems')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={detailOpened}
|
||||
|
||||
@@ -2,17 +2,15 @@ import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import { AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
@@ -34,11 +32,12 @@ interface ProfileRow {
|
||||
*/
|
||||
export function SeafarerRegistryPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const { data, isLoading } = useApiQuery<{ total: number; items: ProfileRow[] }>({
|
||||
const { data, isFetching, refetch } = useApiQuery<{ total: number; items: ProfileRow[] }>({
|
||||
url: '/profiles',
|
||||
method: 'GET',
|
||||
params: { q: 'i=profession,address&t=200' },
|
||||
});
|
||||
const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
|
||||
|
||||
const items = (data?.items ?? []).filter((p) => {
|
||||
if (!search.trim()) return true;
|
||||
@@ -47,6 +46,38 @@ export function SeafarerRegistryPage() {
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term));
|
||||
});
|
||||
const page = paginate(items);
|
||||
|
||||
const columns: AdvancedColumn<ProfileRow>[] = [
|
||||
{
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Profession',
|
||||
cell: ({ row }) => <Text size="sm">{row.original.profession?.name?.en ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: 'ID number',
|
||||
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: 'Phone',
|
||||
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}>
|
||||
{row.original.isComplete ? 'Complete' : 'Incomplete'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -61,64 +92,24 @@ export function SeafarerRegistryPage() {
|
||||
placeholder="Name or ID number"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onChange={(e) => { setSearch(e.currentTarget.value); setPageIndex(0); }}
|
||||
w={260}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
{isLoading ? (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : items.length === 0 ? (
|
||||
<Center h={160}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Profession</Table.Th>
|
||||
<Table.Th>ID number</Table.Th>
|
||||
<Table.Th>Phone</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((p) => (
|
||||
<Table.Tr key={p.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{p.profession?.name?.en ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.idNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.primaryPhoneNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
|
||||
{p.isComplete ? 'Complete' : 'Incomplete'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName="Seafarer registry"
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
|
||||
/>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user