frontend fully integreated with the backend

This commit is contained in:
Kalana B. Thilakarathna
2026-08-25 23:47:33 +05:30
parent 1bee5f91c6
commit 83d3272784
151 changed files with 10724 additions and 573 deletions
@@ -1,6 +1,6 @@
'use client';
import React, { useState, useMemo, useRef, useCallback } from 'react';
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import Link from 'next/link';
import { HugeiconsIcon } from '@hugeicons/react';
import {
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { RowActionButton, RowActions } from '@/components/shared/row-actions';
import { TableToolbar } from './table-toolbar';
import { TablePagination } from './table-pagination';
@@ -27,7 +28,7 @@ import { exportTable } from './hooks/use-table-export';
import { getNestedValue, resolveUrlTemplate } from './utils/nested-value';
import type {
TableConfig, ColumnDef, FieldConfig, SortState, FilterState, PageState,
ServerSideParams, ExportFormat,
ServerSideParams, ExportFormat, RowActionItem,
} from './types';
interface DataTableProps<T extends object> {
@@ -48,6 +49,29 @@ function normalizeField(f: FieldConfig | string): FieldConfig {
return typeof f === 'string' ? { key: f } : f;
}
function formatTextValue(raw: unknown): string {
if (raw == null) return '';
if (typeof raw === 'string') return raw;
if (typeof raw === 'number' || typeof raw === 'bigint') return String(raw);
if (typeof raw === 'boolean') return raw ? 'Yes' : 'No';
if (Array.isArray(raw)) {
if (raw.length === 0) return '';
if (raw.every(item => item == null || ['string', 'number', 'bigint', 'boolean'].includes(typeof item))) {
return raw.map(item => formatTextValue(item)).filter(Boolean).join(', ');
}
return `${raw.length} item${raw.length === 1 ? '' : 's'}`;
}
if (typeof raw === 'object') {
const record = raw as Record<string, unknown>;
const namedValue = record.name ?? record.title ?? record.label ?? record.id;
return namedValue == null ? '' : formatTextValue(namedValue);
}
return String(raw);
}
function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState }) {
if (sortState.column !== colKey) {
return (
@@ -62,6 +86,36 @@ function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState
: <HugeiconsIcon icon={ArrowDown01Icon} className="size-3" />;
}
function DataTableRowActions<T extends object>({
row,
actions,
}: {
row: T;
actions: RowActionItem<T>[];
}) {
const visibleActions = actions.filter(action => action.visible ? action.visible(row) : true);
if (visibleActions.length === 0) return null;
return (
<RowActions>
{visibleActions.map((action) => (
<RowActionButton
key={`${action.action}-${action.label}`}
action={action.action}
label={action.label}
tone={action.tone}
disabled={action.disabled?.(row)}
onClick={(e) => {
e.stopPropagation();
action.onClick(row);
}}
/>
))}
</RowActions>
);
}
function MobileCard<T extends object>({
row,
columns,
@@ -73,7 +127,7 @@ function MobileCard<T extends object>({
mobileCardCfg?: TableConfig<T>['mobileCard'];
onClick?: (row: T) => void;
}) {
const actionCol = columns.find(c => !c.title && c.cellRender);
const actionCol = columns.find(c => c.key === '__row_actions' || (!c.title && c.cellRender));
const dataColumns = columns.filter(c => !!c.title);
const primaryCol = dataColumns[0];
@@ -153,11 +207,34 @@ function CellContent<T extends object>({
const fields = col.fields.map(normalizeField);
const layout = col.layout ?? 'vertical';
if (fields.length === 1 && (fields[0].renderer ?? 'text') === 'text') {
const field = fields[0];
const raw = getNestedValue(row, field.key);
const text = field.transform
? field.transform(raw, row)
: `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`;
return (
<div
className={cn(
'max-w-full',
field.truncate ? 'truncate' : 'whitespace-normal break-words',
field.primary ? 'font-medium text-foreground' : 'text-muted-foreground text-xs',
'text-sm',
field.class,
)}
style={{ color: field.primary ? 'var(--foreground)' : 'var(--muted-foreground)' }}
>
{text || '—'}
</div>
);
}
const rendered = fields.map((field, i) => {
const raw = getNestedValue(row, field.key);
const text = field.transform
? field.transform(raw, row)
: `${field.prefix ?? ''}${raw ?? ''}${field.suffix ?? ''}`;
: `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`;
const renderer = field.renderer ?? 'text';
@@ -190,17 +267,19 @@ function CellContent<T extends object>({
}
return (
<span
<div
key={field.key}
className={cn(
'truncate block',
field.primary ? 'font-medium' : 'text-muted-foreground text-xs',
'max-w-full',
field.truncate ? 'truncate' : 'whitespace-normal break-words',
field.primary ? 'font-medium text-foreground' : 'text-muted-foreground text-xs',
field.class,
i === 0 && 'text-sm',
)}
style={{ color: field.primary || i === 0 ? 'var(--foreground)' : 'var(--muted-foreground)' }}
>
{text}
</span>
</div>
);
});
@@ -232,6 +311,7 @@ export function DataTable<T extends object>({
onRowClick,
onServerSideChange,
}: DataTableProps<T>) {
const safeData = useMemo(() => (Array.isArray(data) ? data : []), [data]);
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
const [filterState, setFilterState] = useState<FilterState>({ global: '', columns: {} });
const [pageState, setPageState] = useState<PageState>({
@@ -257,11 +337,51 @@ export function DataTable<T extends object>({
return String(row[config.rowKey as keyof T]);
}
const normalizedColumns = useMemo(() =>
config.columns.map(col => ({
const normalizedColumns = useMemo(() => {
const baseColumns = config.columns.map(col => ({
...col,
fields: col.fields.map(normalizeField) as FieldConfig[],
})), [config.columns]);
}));
if (!config.rowActions) {
return baseColumns;
}
const actionsColumn: ColumnDef<T> = {
key: '__row_actions',
title: config.rowActions.title ?? 'Action',
fields: [],
width: config.rowActions.width ?? 96,
minWidth: config.rowActions.minWidth ?? 96,
resizable: false,
hideable: false,
align: config.rowActions.align ?? 'right',
exportable: false,
cellRender: (row: T) => (
<DataTableRowActions row={row} actions={config.rowActions!.actions(row)} />
),
};
return [
...baseColumns,
actionsColumn,
];
}, [config.columns, config.rowActions]);
useEffect(() => {
setColumnOrder((prev) => {
const nextKeys = normalizedColumns.map((col) => col.key);
const kept = prev.filter((key) => nextKeys.includes(key));
const missing = nextKeys.filter((key) => !kept.includes(key));
const next = [...kept, ...missing];
if (next.length === prev.length && next.every((key, index) => key === prev[index])) {
return prev;
}
return next;
});
}, [normalizedColumns]);
const orderedColumns = useMemo(() =>
columnOrder
@@ -271,23 +391,23 @@ export function DataTable<T extends object>({
);
const visibleColumns = useMemo(() =>
orderedColumns.filter(c => !hiddenColumns.has(c.key)),
orderedColumns.filter(c => c.hideable === false || !hiddenColumns.has(c.key)),
[orderedColumns, hiddenColumns]
);
const filteredData = useMemo(() =>
config.serverSide ? data : filterData(data, filterState, normalizedColumns as ColumnDef<T>[]),
[data, filterState, normalizedColumns, config.serverSide]
config.serverSide ? safeData : filterData(safeData, filterState, normalizedColumns as ColumnDef<T>[]),
[safeData, filterState, normalizedColumns, config.serverSide]
);
const sortedData = useMemo(() =>
config.serverSide ? data : sortData(filteredData, sortState, normalizedColumns as ColumnDef<T>[]),
config.serverSide ? safeData : sortData(filteredData, sortState, normalizedColumns as ColumnDef<T>[]),
[filteredData, sortState, normalizedColumns, config.serverSide]
);
const totalRecords = useMemo(() =>
config.serverSide ? (totalCount ?? data.length) : filteredData.length,
[config.serverSide, totalCount, data.length, filteredData.length]
config.serverSide ? (totalCount ?? safeData.length) : filteredData.length,
[config.serverSide, totalCount, safeData.length, filteredData.length]
);
const rowHeightPx = useMemo(() => {
@@ -353,7 +473,7 @@ export function DataTable<T extends object>({
const next = new Set(selectedKeys);
next.has(key) ? next.delete(key) : next.add(key);
setSelectedKeys(next);
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
onSelectionChange?.(safeData.filter(r => next.has(rowKey(r))));
}
function toggleAll() {
@@ -364,7 +484,7 @@ export function DataTable<T extends object>({
selectablePageData.forEach(r => next.add(rowKey(r)));
}
setSelectedKeys(next);
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
onSelectionChange?.(safeData.filter(r => next.has(rowKey(r))));
}
function clearSelection() {
@@ -382,6 +502,9 @@ export function DataTable<T extends object>({
}
function toggleColumnVisibility(key: string) {
const column = orderedColumns.find(col => col.key === key);
if (!column || column.hideable === false) return;
setHiddenColumns(prev => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
@@ -46,6 +46,7 @@ export function TableToolbar<T>({
onClearSelection,
}: TableToolbarProps<T>) {
const inputRef = useRef<HTMLInputElement>(null);
const toggleableColumns = columns.filter(col => col.hideable !== false);
return (
<div className="flex items-center gap-2 p-3 border-b">
@@ -91,7 +92,7 @@ export function TableToolbar<T>({
Columns
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{columns.map(col => (
{toggleableColumns.map(col => (
<DropdownMenuCheckboxItem
key={col.key}
checked={!hiddenColumns.has(col.key)}
@@ -3,6 +3,13 @@ export type ColumnLayout = 'vertical' | 'horizontal';
export type SortDirection = 'asc' | 'desc' | null;
export type ExportFormat = 'csv' | 'excel' | 'pdf';
export type ColumnAlign = 'left' | 'center' | 'right';
export type RowActionKind =
| 'edit'
| 'activate'
| 'deactivate'
| 'confirm'
| 'close'
| 'cancel';
export interface BadgeConfig {
colorMap?: Record<string, string>;
@@ -49,6 +56,7 @@ export interface ColumnDef<T = object> {
minWidth?: number;
resizable?: boolean;
visible?: boolean;
hideable?: boolean;
align?: ColumnAlign;
group?: string;
exportable?: boolean;
@@ -75,6 +83,23 @@ export interface MobileCardConfig<T = object> {
detailHref?: (row: T) => string;
}
export interface RowActionItem<T = object> {
action: RowActionKind;
label: string;
onClick: (row: T) => void;
visible?: (row: T) => boolean;
disabled?: (row: T) => boolean;
tone?: 'default' | 'destructive';
}
export interface RowActionsConfig<T = object> {
title?: string;
width?: number;
minWidth?: number;
align?: ColumnAlign;
actions: (row: T) => RowActionItem<T>[];
}
export interface TableConfig<T = object> {
columns: ColumnDef<T>[];
rowKey?: keyof T | ((row: T) => string);
@@ -93,6 +118,7 @@ export interface TableConfig<T = object> {
isRowSelectable?: (row: T) => boolean;
expandRender?: (row: T) => React.ReactNode;
mobileCard?: MobileCardConfig<T>;
rowActions?: RowActionsConfig<T>;
}
export interface SortState {