feat: enhance professions management with server-side pagination and advanced table component

This commit is contained in:
estifanos
2026-07-24 11:51:55 +00:00
parent 6519203e04
commit 7cfef9d09a
6 changed files with 264 additions and 46 deletions

View File

@@ -14,8 +14,11 @@ const configurationApi = baseApi.injectEndpoints({
providesTags: ['Api'],
}),
getProfessions: builder.query<ListResponse<Profession>, void>({
query: () => '/professions',
getProfessions: builder.query<
ListResponse<Profession>,
{ skip?: number; take?: number; q?: string } | void
>({
query: (arg) => ({ url: '/professions', params: arg ?? {} }),
providesTags: ['Api'],
}),
createProfession: builder.mutation<Profession, CreateProfessionPayload>({

View File

@@ -7,7 +7,6 @@ import {
Button,
TextInput,
Textarea,
Table,
ActionIcon,
Modal,
Text,
@@ -21,7 +20,13 @@ import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify, useErrorHandler } from '@ema-platform/ui';
import {
notify,
useErrorHandler,
AdvancedTable,
useServerTable,
type AdvancedColumn,
} from '@ema-platform/ui';
import { LocationPage } from '../../location/pages/LocationPage';
import { CertificationPage } from '../../certification/pages/CertificationPage';
import {
@@ -133,13 +138,24 @@ function ProfessionTab() {
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
const { data: deptRes } = useGetOrganizationsQuery();
const { data: profRes, isLoading, isError } = useGetProfessionsQuery();
const { pageIndex, setPageIndex, q, setQ, skip, take } = useServerTable({ pageSize: 10 });
const {
data: profRes,
isLoading,
isFetching,
isError,
refetch,
} = useGetProfessionsQuery({ skip, take, q: q || undefined });
const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation();
const [updateProfession, { isLoading: isUpdating }] = useUpdateProfessionMutation();
const [deleteProfession] = useDeleteProfessionMutation();
const departments = Array.isArray(deptRes) ? deptRes : (deptRes?.items ?? []);
// Server now paginates, so the previous client-side `isActive` filter is
// dropped (it would hide rows outside just this page). Restore it via a
// server-side `q` filter once the backend field name is confirmed.
const professions = profRes?.items ?? [];
const totalCount = profRes?.total ?? profRes?.count ?? professions.length;
const [editingProf, setEditingProf] = useState<Profession | null>(null);
const [showProfForm, setShowProfForm] = useState(false);
@@ -210,6 +226,37 @@ function ProfessionTab() {
return dept ? (dept.name?.[locale] ?? dept.name?.en ?? '-') : '-';
}, [departments, locale]);
const professionColumns: AdvancedColumn<Profession>[] = [
{
header: t('configuration.name'),
cell: ({ row }) => row.original.name[locale],
},
{
header: t('configuration.description'),
cell: ({ row }) => (
<Text size="sm" lineClamp={2} maw={200}>{row.original.description[locale]}</Text>
),
},
{
header: t('configuration.department'),
cell: ({ row }) => getDeptName(row.original.departmentId),
},
{
header: '',
size: 90,
cell: ({ row }) => (
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handleEditProf(row.original)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handleDeleteProf(row.original)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
),
},
];
if (isLoading) {
return <Center py="xl"><Loader /></Center>;
}
@@ -244,46 +291,19 @@ function ProfessionTab() {
/>
)}
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('configuration.name')}</Table.Th>
<Table.Th>{t('configuration.description')}</Table.Th>
<Table.Th>{t('configuration.department')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{professions.filter((p) => p.isActive).map((prof) => (
<Table.Tr key={prof.id}>
<Table.Td>{prof.name[locale]}</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={2} maw={200}>{prof.description[locale]}</Text>
</Table.Td>
<Table.Td>{getDeptName(prof.departmentId)}</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handleEditProf(prof)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handleDeleteProf(prof)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
{professions.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Text c="dimmed" ta="center" py="xl">
{t('configuration.noProfessions')}
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<AdvancedTable
columns={professionColumns}
data={professions}
tableName={t('configuration.professionsList')}
itemCount={totalCount}
pageIndex={pageIndex}
onPageChange={setPageIndex}
pageSize={10}
refresh={refetch}
onSearchChange={setQ}
isLoading={isFetching}
emptyText={t('configuration.noProfessions')}
/>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('configuration.confirmDelete')} size="sm">
<Text mb="md">

View File

@@ -28,7 +28,8 @@ export interface Profession {
}
export interface ListResponse<T> {
count: number;
count?: number;
total?: number;
items: T[];
}

View File

@@ -9,3 +9,5 @@ export * from './lib/layout/BrandAvatar';
export * from './lib/layout/ColorSchemeToggle';
export * from './lib/layout/LanguageSwitcher';
export * from './lib/layout/PageHeader';
export * from './lib/data/AdvancedTable';
export * from './lib/data/useServerTable';

View File

@@ -0,0 +1,163 @@
import { ReactNode, useEffect, useState } from 'react';
import {
Table,
TextInput,
ActionIcon,
Group,
Text,
Pagination,
Loader,
Center,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconSearch, IconRefresh } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
export interface AdvancedColumn<T> {
header: ReactNode;
/** Dot-path into the row, used when no `cell` is given (e.g. "expectation.name"). */
accessorKey?: string;
cell?: (ctx: { row: { original: T }; value: unknown }) => ReactNode;
size?: number;
align?: 'left' | 'center' | 'right';
}
interface AdvancedTableProps<T> {
columns: AdvancedColumn<T>[];
data: T[];
tableName: string;
/** Total item count on the server (drives pagination), not data.length. */
itemCount: number;
/** 0-based current page. */
pageIndex: number;
onPageChange: (pageIndex: number) => void;
pageSize?: number;
refresh?: () => void;
/** Server-side search — debounced internally. Omit to hide the search box. */
onSearchChange?: (q: string) => void;
isLoading?: boolean;
emptyText?: string;
}
function getByPath(obj: unknown, path?: string): unknown {
if (!path) return undefined;
return path.split('.').reduce<unknown>(
(acc, key) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined),
obj,
);
}
export function AdvancedTable<T extends { id?: string | number }>({
columns,
data,
tableName,
itemCount,
pageIndex,
onPageChange,
pageSize = 10,
refresh,
onSearchChange,
isLoading = false,
emptyText,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
return (
<div>
<Group justify="space-between" mb="sm">
<Text fw={600}>{tableName}</Text>
<Group gap="xs">
{onSearchChange && (
<SearchBox tableName={tableName} onSearchChange={onSearchChange} />
)}
{refresh && (
<ActionIcon variant="subtle" onClick={refresh} title={t('common.refresh', 'Refresh')}>
<IconRefresh size={16} />
</ActionIcon>
)}
</Group>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
{columns.map((col, i) => (
<Table.Th key={i} style={{ width: col.size, textAlign: col.align }}>
{col.header}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<Table.Tr>
<Table.Td colSpan={columns.length}>
<Center py="xl">
<Loader size="sm" />
</Center>
</Table.Td>
</Table.Tr>
) : data.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={columns.length}>
<Text c="dimmed" ta="center" py="xl">
{emptyText ?? t('common.noResult', 'No results')}
</Text>
</Table.Td>
</Table.Tr>
) : (
data.map((row, rowIndex) => (
<Table.Tr key={row.id ?? rowIndex}>
{columns.map((col, i) => {
const value = getByPath(row, col.accessorKey);
return (
<Table.Td key={i} style={{ textAlign: col.align }}>
{col.cell ? col.cell({ row: { original: row }, value }) : (value as ReactNode) ?? '-'}
</Table.Td>
);
})}
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
{itemCount > pageSize && (
<Group justify="flex-end" mt="md">
<Pagination
total={Math.ceil(itemCount / pageSize)}
value={pageIndex + 1}
onChange={(page) => onPageChange(page - 1)}
size="sm"
/>
</Group>
)}
</div>
);
}
function SearchBox({
tableName,
onSearchChange,
}: {
tableName: string;
onSearchChange: (q: string) => void;
}) {
const { t } = useTranslation();
const [value, setValue] = useState('');
const [debounced] = useDebouncedValue(value, 300);
useEffect(() => {
onSearchChange(debounced);
}, [debounced, onSearchChange]);
return (
<TextInput
placeholder={t('common.filterPlaceholder', { name: tableName, defaultValue: `Filter ${tableName}...` })}
leftSection={<IconSearch size={14} />}
size="sm"
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
);
}

View File

@@ -0,0 +1,29 @@
import { useState, useCallback } from 'react';
interface UseServerTableOptions {
pageSize?: number;
}
/**
* Centralizes the page-index + search-query state a server-paginated table
* needs. Changing the search query resets paging back to page 0.
*/
export function useServerTable({ pageSize = 10 }: UseServerTableOptions = {}) {
const [pageIndex, setPageIndex] = useState(0);
const [q, setQInternal] = useState('');
const setQ = useCallback((value: string) => {
setQInternal(value);
setPageIndex(0);
}, []);
return {
pageIndex,
setPageIndex,
q,
setQ,
pageSize,
skip: pageIndex * pageSize,
take: pageSize,
};
}