feat(admin): reorganize housekeeping and strengthen shared workflows
This commit is contained in:
1 parent
35f66d879f
commit
a070004e7b
35 files changed
+1667
-462
No files matched your search
@@ -12,8 +12,10 @@ import {
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -38,8 +40,8 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers";
|
||||
import { tablePageNumbers } from "@/lib/admin-table-state";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
/**
|
||||
@@ -68,29 +70,47 @@ interface DataTableProps<T extends { id: number | string }> {
|
||||
export function DataTable<T extends { id: number | string }>({
|
||||
data,
|
||||
columns,
|
||||
searchPlaceholder = "Search...",
|
||||
searchPlaceholder,
|
||||
bulkActions,
|
||||
onBulkAction,
|
||||
exportUrl,
|
||||
presets,
|
||||
}: DataTableProps<T>) {
|
||||
const t = useTranslations("pages.admin.table");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [searchValue, setSearchValue] = useState(
|
||||
searchParams.get("search") || "",
|
||||
);
|
||||
|
||||
const scope = `${pathname}?${searchParams.toString()}:${data.rows.map((row) => row.id).join(",")}`;
|
||||
const [selection, setSelection] = useState<{
|
||||
scope: string;
|
||||
ids: Set<number | string>;
|
||||
}>({ scope, ids: new Set() });
|
||||
const selectedIds =
|
||||
selection.scope === scope ? selection.ids : new Set<number | string>();
|
||||
function setSelectedIds(
|
||||
value:
|
||||
| Set<number | string>
|
||||
| ((prev: Set<number | string>) => Set<number | string>),
|
||||
) {
|
||||
setSelection({
|
||||
scope,
|
||||
ids: typeof value === "function" ? value(selectedIds) : value,
|
||||
});
|
||||
}
|
||||
const urlSearch = searchParams.get("search") ?? "";
|
||||
const [draft, setDraft] = useState({ base: urlSearch, value: urlSearch });
|
||||
if (draft.base !== urlSearch) setDraft({ base: urlSearch, value: urlSearch });
|
||||
const searchValue = draft.base === urlSearch ? draft.value : urlSearch;
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const bulkLock = useRef(false);
|
||||
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
|
||||
const currentSort = searchParams.get("sort") || "";
|
||||
const currentOrder = searchParams.get("order") || "desc";
|
||||
|
||||
const debouncedSearch = useDebounce(searchValue, 300);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Collect active filters from URL
|
||||
const filterColumns = columns.filter(
|
||||
(col) => col.filterOptions && col.filterOptions.length > 0,
|
||||
@@ -110,27 +130,31 @@ export function DataTable<T extends { id: number | string }>({
|
||||
else params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
updateParams({ search: debouncedSearch, page: "1" });
|
||||
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (searchValue === urlSearch) return;
|
||||
const timer = setTimeout(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (searchValue) params.set("search", searchValue);
|
||||
else params.delete("search");
|
||||
params.set("page", "1");
|
||||
startTransition(() =>
|
||||
router.replace(`?${params.toString()}`, { scroll: false }),
|
||||
);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchValue, urlSearch, searchParams, router]);
|
||||
function handleSearch(value: string) {
|
||||
setSearchValue(value);
|
||||
setDraft({ base: urlSearch, value });
|
||||
}
|
||||
|
||||
function handleSort(key: string) {
|
||||
const newOrder =
|
||||
currentSort === key && currentOrder === "asc" ? "desc" : "asc";
|
||||
updateParams({ sort: key, order: newOrder });
|
||||
updateParams({ sort: key, order: newOrder, page: "1" });
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
@@ -173,10 +197,33 @@ export function DataTable<T extends { id: number | string }>({
|
||||
}
|
||||
|
||||
async function handleBulkAction(action: string) {
|
||||
if (selectedIds.size === 0 || !onBulkAction) return;
|
||||
await onBulkAction(action, Array.from(selectedIds));
|
||||
setSelectedIds(new Set());
|
||||
router.refresh();
|
||||
if (
|
||||
selectedIds.size === 0 ||
|
||||
!onBulkAction ||
|
||||
bulkLock.current ||
|
||||
isPending
|
||||
)
|
||||
return;
|
||||
const definition = bulkActions?.find((item) => item.value === action);
|
||||
if (
|
||||
definition?.variant === "destructive" &&
|
||||
!window.confirm(
|
||||
t("confirmBulk", { action: definition.label, count: selectedIds.size }),
|
||||
)
|
||||
)
|
||||
return;
|
||||
bulkLock.current = true;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
await onBulkAction(action, Array.from(selectedIds));
|
||||
setSelectedIds(new Set());
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("bulkError"));
|
||||
} finally {
|
||||
bulkLock.current = false;
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function getSortIcon(key: string) {
|
||||
@@ -206,7 +253,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 min-w-0" aria-busy={isPending || bulkBusy}>
|
||||
{/* Preset filter chips */}
|
||||
{presets && presets.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
@@ -236,9 +283,11 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
placeholder={searchPlaceholder ?? t("search")}
|
||||
aria-label={searchPlaceholder ?? t("search")}
|
||||
value={searchValue}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
style={{ paddingLeft: 36 }}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
@@ -249,7 +298,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
{t("filters")}
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -263,7 +312,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">Filters</h4>
|
||||
<h4 className="text-sm font-medium">{t("filters")}</h4>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -271,7 +320,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
Clear all
|
||||
{t("clearAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -291,7 +340,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All</SelectItem>
|
||||
<SelectItem value="__all__">{t("all")}</SelectItem>
|
||||
{col.filterOptions?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
@@ -320,6 +369,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
{col.label}: {opt?.label || val}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("removeFilter", { name: col.label })}
|
||||
onClick={() => handleFilterChange(col, "__all__")}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-accent"
|
||||
>
|
||||
@@ -336,12 +386,12 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Columns
|
||||
{t("columns")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48" align="start">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium mb-2">Toggle columns</h4>
|
||||
<h4 className="text-sm font-medium mb-2">{t("columns")}</h4>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
@@ -349,6 +399,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
>
|
||||
<Checkbox
|
||||
checked={!hiddenColumns.has(col.key)}
|
||||
aria-label={col.label}
|
||||
onCheckedChange={(checked) => {
|
||||
setHiddenColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -375,7 +426,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<SelectContent>
|
||||
{PER_PAGE_OPTIONS.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} / page
|
||||
{t("perPage", { count: n })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -393,13 +444,14 @@ export function DataTable<T extends { id: number | string }>({
|
||||
{bulkActions && selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedIds.size} selected
|
||||
{t("selected", { count: selectedIds.size })}
|
||||
</span>
|
||||
{bulkActions.map((action) => (
|
||||
<Button
|
||||
key={action.value}
|
||||
variant={action.variant || "default"}
|
||||
size="sm"
|
||||
disabled={bulkBusy || isPending}
|
||||
onClick={() => handleBulkAction(action.value)}
|
||||
>
|
||||
{action.label}
|
||||
@@ -409,7 +461,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
)}
|
||||
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
{data.total} results
|
||||
{t("results", { count: data.total })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -425,6 +477,8 @@ export function DataTable<T extends { id: number | string }>({
|
||||
selectedIds.size === data.rows.length &&
|
||||
data.rows.length > 0
|
||||
}
|
||||
aria-label={t("selectAll")}
|
||||
disabled={isPending || bulkBusy}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
@@ -459,7 +513,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No results found.
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -473,6 +527,8 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(row.id)}
|
||||
aria-label={t("selectRow", { id: String(row.id) })}
|
||||
disabled={isPending || bulkBusy}
|
||||
onCheckedChange={() => toggleSelect(row.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -504,31 +560,25 @@ export function DataTable<T extends { id: number | string }>({
|
||||
{data.lastPage > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {data.page} of {data.lastPage}
|
||||
{t("pageOf", { page: data.page, last: data.lastPage })}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 flex-wrap justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={data.page <= 1}
|
||||
aria-label={t("previous")}
|
||||
disabled={data.page <= 1 || isPending}
|
||||
onClick={() => handlePageChange(data.page - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{Array.from({ length: Math.min(data.lastPage, 5) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
if (data.lastPage <= 7) {
|
||||
pageNum = i + 1;
|
||||
} else if (data.page <= 4) {
|
||||
pageNum = i + 1;
|
||||
} else if (data.page >= data.lastPage - 3) {
|
||||
pageNum = data.lastPage - 6 + i;
|
||||
} else {
|
||||
pageNum = data.page - 3 + i;
|
||||
}
|
||||
{tablePageNumbers(data.page, data.lastPage).map((pageNum) => {
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
aria-label={t("page", { page: pageNum })}
|
||||
aria-current={pageNum === data.page ? "page" : undefined}
|
||||
disabled={isPending}
|
||||
variant={pageNum === data.page ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
@@ -540,7 +590,8 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={data.page >= data.lastPage}
|
||||
aria-label={t("next")}
|
||||
disabled={data.page >= data.lastPage || isPending}
|
||||
onClick={() => handlePageChange(data.page + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
|
||||
Reference in new issue
Block a user