diff --git a/apps/backoffice/src/app/features/configuration/api/configuration-api.ts b/apps/backoffice/src/app/features/configuration/api/configuration-api.ts index 678e78cfb..77db3da9c 100644 --- a/apps/backoffice/src/app/features/configuration/api/configuration-api.ts +++ b/apps/backoffice/src/app/features/configuration/api/configuration-api.ts @@ -14,8 +14,11 @@ const configurationApi = baseApi.injectEndpoints({ providesTags: ['Api'], }), - getProfessions: builder.query, void>({ - query: () => '/professions', + getProfessions: builder.query< + ListResponse, + { skip?: number; take?: number; q?: string } | void + >({ + query: (arg) => ({ url: '/professions', params: arg ?? {} }), providesTags: ['Api'], }), createProfession: builder.mutation({ diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx index 08361d711..d9319ebaf 100644 --- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx @@ -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(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[] = [ + { + header: t('configuration.name'), + cell: ({ row }) => row.original.name[locale], + }, + { + header: t('configuration.description'), + cell: ({ row }) => ( + {row.original.description[locale]} + ), + }, + { + header: t('configuration.department'), + cell: ({ row }) => getDeptName(row.original.departmentId), + }, + { + header: '', + size: 90, + cell: ({ row }) => ( + + handleEditProf(row.original)}> + + + handleDeleteProf(row.original)}> + + + + ), + }, + ]; + if (isLoading) { return
; } @@ -244,46 +291,19 @@ function ProfessionTab() { /> )} - - - - {t('configuration.name')} - {t('configuration.description')} - {t('configuration.department')} - - - - - {professions.filter((p) => p.isActive).map((prof) => ( - - {prof.name[locale]} - - {prof.description[locale]} - - {getDeptName(prof.departmentId)} - - - handleEditProf(prof)}> - - - handleDeleteProf(prof)}> - - - - - - ))} - {professions.length === 0 && ( - - - - {t('configuration.noProfessions')} - - - - )} - -
+ diff --git a/apps/backoffice/src/app/features/configuration/types/configuration.ts b/apps/backoffice/src/app/features/configuration/types/configuration.ts index 3e1c4af94..ddde32ae3 100644 --- a/apps/backoffice/src/app/features/configuration/types/configuration.ts +++ b/apps/backoffice/src/app/features/configuration/types/configuration.ts @@ -28,7 +28,8 @@ export interface Profession { } export interface ListResponse { - count: number; + count?: number; + total?: number; items: T[]; } diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index d8fabdd58..fa10321ca 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -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'; diff --git a/libs/ui/src/lib/data/AdvancedTable.tsx b/libs/ui/src/lib/data/AdvancedTable.tsx new file mode 100644 index 000000000..c82e817d4 --- /dev/null +++ b/libs/ui/src/lib/data/AdvancedTable.tsx @@ -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 { + 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 { + columns: AdvancedColumn[]; + 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( + (acc, key) => (acc && typeof acc === 'object' ? (acc as Record)[key] : undefined), + obj, + ); +} + +export function AdvancedTable({ + columns, + data, + tableName, + itemCount, + pageIndex, + onPageChange, + pageSize = 10, + refresh, + onSearchChange, + isLoading = false, + emptyText, +}: AdvancedTableProps) { + const { t } = useTranslation(); + + return ( +
+ + {tableName} + + {onSearchChange && ( + + )} + {refresh && ( + + + + )} + + + + + + + {columns.map((col, i) => ( + + {col.header} + + ))} + + + + {isLoading ? ( + + +
+ +
+
+
+ ) : data.length === 0 ? ( + + + + {emptyText ?? t('common.noResult', 'No results')} + + + + ) : ( + data.map((row, rowIndex) => ( + + {columns.map((col, i) => { + const value = getByPath(row, col.accessorKey); + return ( + + {col.cell ? col.cell({ row: { original: row }, value }) : (value as ReactNode) ?? '-'} + + ); + })} + + )) + )} +
+
+ + {itemCount > pageSize && ( + + onPageChange(page - 1)} + size="sm" + /> + + )} +
+ ); +} + +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 ( + } + size="sm" + value={value} + onChange={(e) => setValue(e.currentTarget.value)} + /> + ); +} diff --git a/libs/ui/src/lib/data/useServerTable.ts b/libs/ui/src/lib/data/useServerTable.ts new file mode 100644 index 000000000..910842056 --- /dev/null +++ b/libs/ui/src/lib/data/useServerTable.ts @@ -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, + }; +}