Add column filtering to admin panel

This commit is contained in:
2025-05-11 19:26:33 +02:00
parent 1907adbd96
commit 959c191bf4
5 changed files with 189 additions and 59 deletions

View File

@@ -16,13 +16,19 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState, useEffect, useMemo } from "react";
import { ArrowDown, ArrowUp } from "lucide-react";
export type Column = { export type Column = {
key: string; key: string;
header: string; header: string;
render?: (item: any) => ReactNode; render?: (item: any) => ReactNode;
sortable?: boolean;
searchable?: boolean;
}; };
type DataTableProps = { type DataTableProps = {
@@ -68,6 +74,9 @@ export const DataTable = ({
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<any | null>(null); const [editingItem, setEditingItem] = useState<any | null>(null);
const [itemToDelete, setItemToDelete] = useState<string | null>(null); const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [orderingMode, setOrderingMode] = useState(false);
const [sortConfig, setSortConfig] = useState<{ key: string, direction: 'asc' | 'desc' } | null>(null);
const handleAdd = (data: any) => { const handleAdd = (data: any) => {
onAdd(data); onAdd(data);
@@ -88,40 +97,159 @@ export const DataTable = ({
} }
}; };
const handleSort = (key: string) => {
setSortConfig((prevSortConfig) => {
if (!prevSortConfig || prevSortConfig.key !== key) {
return { key, direction: 'asc' };
}
if (prevSortConfig.direction === 'asc') {
return { key, direction: 'desc' };
}
return null;
});
};
useEffect(() => {
if (orderingMode) {
setSortConfig(null);
}
}, [orderingMode]);
const getNestedValue = (obj: any, path: string) => {
if (!obj) return '';
const column = columns.find(c => c.key === path);
if (column && column.render) {
const rendered = column.render(obj);
if (typeof rendered !== 'object') {
return rendered;
}
}
const keys = path.split('.');
let value = obj;
for (const key of keys) {
if (value === null || value === undefined) return '';
value = value[key];
}
return value === null || value === undefined ? '' : value;
};
const filteredAndSortedData = useMemo(() => {
let result = data;
if (searchTerm) {
const lowerCaseSearchTerm = searchTerm.toLowerCase();
result = data.filter((item) => {
return columns.some((column) => {
if (!column.searchable) return false;
const value = getNestedValue(item, column.key);
return String(value || '').toLowerCase().includes(lowerCaseSearchTerm);
});
});
}
if (sortConfig && !orderingMode) {
result = [...result].sort((a, b) => {
const aValue = getNestedValue(a, sortConfig.key);
const bValue = getNestedValue(b, sortConfig.key);
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
}
const aString = String(aValue || '');
const bString = String(bValue || '');
return sortConfig.direction === 'asc'
? aString.localeCompare(bString)
: bString.localeCompare(aString);
});
} else if (orderingMode) {
result = [...result].sort((a, b) => a.order - b.order);
}
return result;
}, [data, searchTerm, columns, sortConfig, orderingMode]);
return ( return (
<Card className="mt-4"> <Card className="mt-4">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}> <div className="flex space-x-2">
<DialogTrigger asChild> <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<Button>{addButtonText}</Button> <DialogTrigger asChild>
</DialogTrigger> <Button>{addButtonText}</Button>
<DialogContent> </DialogTrigger>
<DialogHeader> <DialogContent>
<DialogTitle>{addDialogTitle}</DialogTitle> <DialogHeader>
</DialogHeader> <DialogTitle>{addDialogTitle}</DialogTitle>
<FormComponent </DialogHeader>
onSubmit={handleAdd} <FormComponent
onCancel={() => setIsAddDialogOpen(false)} onSubmit={handleAdd}
/> onCancel={() => setIsAddDialogOpen(false)}
</DialogContent> />
</Dialog> </DialogContent>
</Dialog>
</div>
</CardHeader> </CardHeader>
<div className="px-6 pb-2 flex flex-col sm:flex-row justify-between gap-4">
<div className="flex items-center space-x-2">
{showOrderButtons && onChangeOrder && (
<div className="flex items-center space-x-2">
<Switch
id="ordering-mode"
checked={orderingMode}
onCheckedChange={setOrderingMode}
/>
<Label htmlFor="ordering-mode" className="text-sm font-medium">
{t("common.changeOrder")}
</Label>
</div>
)}
</div>
<Input
className="max-w-xs"
placeholder={t("common.search")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={orderingMode}
/>
</div>
<CardContent> <CardContent>
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
{columns.map((column) => ( {columns.map((column) => (
<TableHead key={column.key}>{column.header}</TableHead> <TableHead
key={column.key}
className={column.sortable && !orderingMode ? "cursor-pointer hover:bg-muted" : ""}
onClick={() => column.sortable && !orderingMode && handleSort(column.key)}
>
<div className="flex items-center select-none">
{column.header}
{sortConfig && sortConfig.key === column.key && (
<span className="ml-1">
{sortConfig.direction === 'asc' ? '▲' : '▼'}
</span>
)}
</div>
</TableHead>
))} ))}
{(onEdit || onDelete || onChangeOrder) && ( {(onEdit || onDelete || (onChangeOrder && orderingMode)) && (
<TableHead>{t("common.actions")}</TableHead> <TableHead className="select-none">{t("common.actions")}</TableHead>
)} )}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.map((item, index) => ( {filteredAndSortedData.map((item, index) => (
<TableRow key={item[idField]}> <TableRow key={item[idField]}>
{columns.map((column) => ( {columns.map((column) => (
<TableCell key={`${item[idField]}-${column.key}`}> <TableCell key={`${item[idField]}-${column.key}`}>
@@ -157,7 +285,7 @@ export const DataTable = ({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )}
{showOrderButtons && onChangeOrder && ( {showOrderButtons && onChangeOrder && orderingMode && (
<> <>
<Button <Button
variant="outline" variant="outline"
@@ -165,20 +293,7 @@ export const DataTable = ({
onClick={() => onChangeOrder(item[idField], "up")} onClick={() => onChangeOrder(item[idField], "up")}
disabled={item.order === 1} disabled={item.order === 1}
> >
<svg <ArrowUp />
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="m18 15-6-6-6 6" />
</svg>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -186,20 +301,7 @@ export const DataTable = ({
onClick={() => onChangeOrder(item[idField], "down")} onClick={() => onChangeOrder(item[idField], "down")}
disabled={item.order === Math.max(...data.map(i => i.order))} disabled={item.order === Math.max(...data.map(i => i.order))}
> >
<svg <ArrowDown />
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="m6 9 6 6 6-6" />
</svg>
</Button> </Button>
</> </>
)} )}

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "select-none inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -61,7 +61,9 @@
"updated": "Updated successfully", "updated": "Updated successfully",
"deleting": "Deleting...", "deleting": "Deleting...",
"deleted": "Deleted successfully", "deleted": "Deleted successfully",
"select": "Select" "select": "Select",
"search": "Search",
"changeOrder": "Change order mode"
}, },
"welcome": "Welcome", "welcome": "Welcome",
"login": { "login": {

View File

@@ -61,7 +61,9 @@
"updated": "Zaktualizowano pomyślnie", "updated": "Zaktualizowano pomyślnie",
"deleting": "Usuwanie...", "deleting": "Usuwanie...",
"deleted": "Usunięto pomyślnie", "deleted": "Usunięto pomyślnie",
"select": "Wybierz" "select": "Wybierz",
"search": "Szukaj",
"changeOrder": "Tryb zmiany kolejności"
}, },
"welcome": "Witaj", "welcome": "Witaj",
"login": { "login": {

View File

@@ -1,5 +1,4 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -206,12 +205,22 @@ function SkillsRoute() {
{ {
key: "name.pl", key: "name.pl",
header: t("admin.skills.namePl"), header: t("admin.skills.namePl"),
render: (item) => item.name.pl render: (item) => item.name.pl,
sortable: true,
searchable: true
}, },
{ {
key: "name.en", key: "name.en",
header: t("admin.skills.nameEn"), header: t("admin.skills.nameEn"),
render: (item) => item.name.en render: (item) => item.name.en,
sortable: true,
searchable: true
},
{
key: "order",
header: t("admin.skills.order"),
render: (item) => item.order,
sortable: true
} }
]; ];
@@ -219,17 +228,23 @@ function SkillsRoute() {
{ {
key: "name.pl", key: "name.pl",
header: t("admin.skills.namePl"), header: t("admin.skills.namePl"),
render: (item) => item.name.pl render: (item) => item.name.pl,
sortable: true,
searchable: true
}, },
{ {
key: "name.en", key: "name.en",
header: t("admin.skills.nameEn"), header: t("admin.skills.nameEn"),
render: (item) => item.name.en render: (item) => item.name.en,
sortable: true,
searchable: true
}, },
{ {
key: "category", key: "category",
header: t("admin.skills.category"), header: t("admin.skills.category"),
render: (item) => item.category ? `${item.category.pl} / ${item.category.en}` : "-" render: (item) => item.category ? `${item.category.pl} / ${item.category.en}` : "-",
sortable: true,
searchable: true
}, },
{ {
key: "icon", key: "icon",
@@ -237,12 +252,21 @@ function SkillsRoute() {
render: (item) => { render: (item) => {
if (!item.iconName) return "-"; if (!item.iconName) return "-";
return item.iconProvider ? `${item.iconName} (${item.iconProvider})` : item.iconName; return item.iconProvider ? `${item.iconName} (${item.iconProvider})` : item.iconName;
} },
sortable: true,
searchable: true
}, },
{ {
key: "isActive", key: "isActive",
header: t("admin.skills.active"), header: t("admin.skills.active"),
render: (item) => item.isActive ? t("common.yes") : t("common.no") render: (item) => item.isActive ? t("common.yes") : t("common.no"),
sortable: true
},
{
key: "order",
header: t("admin.skills.order"),
render: (item) => item.order,
sortable: true
} }
]; ];