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:
estifanos
2026-08-03 09:22:02 +00:00
parent 52e2f4c009
commit 599dc4aa83
19 changed files with 452 additions and 1395 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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 }) =>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>
);