mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-08 05:56:38 +00:00
Basic log table there
This commit is contained in:
@@ -1893,5 +1893,12 @@
|
||||
"deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?",
|
||||
"clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.",
|
||||
"sidebarLogs": "Logs",
|
||||
"request": "Request"
|
||||
"request": "Request",
|
||||
"logs": "Logs",
|
||||
"logsSettingsDescription": "Monitor logs collected from this orginization",
|
||||
"searchLogs": "Search logs...",
|
||||
"action": "Action",
|
||||
"actor": "Actor",
|
||||
"timestamp": "Timestamp",
|
||||
"accessLogs": "Access Logs"
|
||||
}
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./queryActionAuditLog";
|
||||
@@ -21,8 +21,8 @@ import * as domain from "#private/routers/domain";
|
||||
import * as auth from "#private/routers/auth";
|
||||
import * as license from "#private/routers/license";
|
||||
import * as generateLicense from "./generatedLicense";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
|
||||
import { Router } from "express";
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction,
|
||||
@@ -345,3 +345,8 @@ authenticated.post(
|
||||
verifyUserIsServerAdmin,
|
||||
license.recheckStatus
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/action",
|
||||
logs.queryAccessAuditLogs
|
||||
)
|
||||
@@ -1,47 +1,15 @@
|
||||
"use client";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import AuthPageSettings, {
|
||||
AuthPageSettingsRef
|
||||
} from "@app/components/private/AuthPageSettings";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { Key, User } from "lucide-react";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
@@ -49,6 +17,211 @@ export default function GeneralPage() {
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { orgId } = useParams();
|
||||
|
||||
return <p>access</p>;
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
const getDefaultDateRange = () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: yesterday,
|
||||
},
|
||||
endDate: {
|
||||
date: now,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{ startDate: DateTimeValue; endDate: DateTimeValue }>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(defaultRange.startDate, defaultRange.endDate);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
queryDateTime(startDate, endDate);
|
||||
};
|
||||
|
||||
const queryDateTime = async (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate });
|
||||
setIsRefreshing(true);
|
||||
|
||||
try {
|
||||
// Convert the date/time values to API parameters
|
||||
let params: any = {
|
||||
limit: 20,
|
||||
offset: 0
|
||||
};
|
||||
|
||||
if (startDate?.date) {
|
||||
const startDateTime = new Date(startDate.date);
|
||||
if (startDate.time) {
|
||||
const [hours, minutes, seconds] = startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||
}
|
||||
params.timeStart = startDateTime.toISOString();
|
||||
}
|
||||
|
||||
if (endDate?.date) {
|
||||
const endDateTime = new Date(endDate.date);
|
||||
if (endDate.time) {
|
||||
const [hours, minutes, seconds] = endDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||
} else {
|
||||
// If no time is specified, set to NOW
|
||||
const now = new Date();
|
||||
endDateTime.setHours(now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds());
|
||||
}
|
||||
params.timeEnd = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
const res = await api.get(`/org/${orgId}/logs/action`, { params });
|
||||
if (res.status === 200) {
|
||||
setRows(res.data.data.log);
|
||||
console.log("Fetched logs:", res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("Failed to filter logs"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
className="hidden md:flex whitespace-nowrap"
|
||||
>
|
||||
{t("timestamp")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(
|
||||
row.original.timestamp * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("action")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
// make the value capitalized
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="hitespace-nowrap">
|
||||
{row.original.action.charAt(0).toUpperCase() + row.original.action.slice(1)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("actor")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorType == "user" ? <User className="h-4 w-4" /> : <Key className="h-4 w-4" />}
|
||||
{row.original.actor}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
persistPageSize="access-logs-table"
|
||||
title={t("accessLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="action"
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
end: dateRange.endDate
|
||||
}}
|
||||
defaultSort={{
|
||||
id: "timestamp",
|
||||
desc: false
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ export const orgNavSections = (
|
||||
: []),
|
||||
{
|
||||
title: "sidebarLogs",
|
||||
href: "/{orgId}/settings/logs",
|
||||
href: "/{orgId}/settings/logs/access",
|
||||
icon: <Logs className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
|
||||
209
src/components/DateTimePicker.tsx
Normal file
209
src/components/DateTimePicker.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon, CalendarIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
|
||||
export interface DateTimeValue {
|
||||
date?: Date;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
export interface DateTimePickerProps {
|
||||
label?: string;
|
||||
value?: DateTimeValue;
|
||||
onChange?: (value: DateTimeValue) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showTime?: boolean;
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select date & time",
|
||||
className,
|
||||
disabled = false,
|
||||
showTime = true,
|
||||
}: DateTimePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [internalDate, setInternalDate] = useState<Date | undefined>(value?.date);
|
||||
const [internalTime, setInternalTime] = useState<string>(value?.time || "");
|
||||
|
||||
// Sync internal state with external value prop
|
||||
useEffect(() => {
|
||||
setInternalDate(value?.date);
|
||||
setInternalTime(value?.time || "");
|
||||
}, [value?.date, value?.time]);
|
||||
|
||||
const handleDateChange = (date: Date | undefined) => {
|
||||
setInternalDate(date);
|
||||
const newValue = { date, time: internalTime };
|
||||
setOpen(false);
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
const handleTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const time = event.target.value;
|
||||
setInternalTime(time);
|
||||
const newValue = { date: internalDate, time };
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (!internalDate) return placeholder;
|
||||
|
||||
const dateStr = internalDate.toLocaleDateString();
|
||||
if (!showTime || !internalTime) return dateStr;
|
||||
|
||||
return `${dateStr} ${internalTime}`;
|
||||
};
|
||||
|
||||
const hasValue = internalDate || (showTime && internalTime);
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-4", className)}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{label && (
|
||||
<Label htmlFor="date-picker" className="px-1">
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
id="date-picker"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
showTime ? "w-48" : "w-32",
|
||||
!hasValue && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{getDisplayText()}
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
<div className="p-3">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="date-input" className="text-sm font-medium">
|
||||
Date
|
||||
</Label>
|
||||
<Input
|
||||
id="date-input"
|
||||
type="date"
|
||||
value={internalDate ?
|
||||
`${internalDate.getFullYear()}-${String(internalDate.getMonth() + 1).padStart(2, '0')}-${String(internalDate.getDate()).padStart(2, '0')}`
|
||||
: ''}
|
||||
onChange={(e) => {
|
||||
let dateValue = undefined;
|
||||
if (e.target.value) {
|
||||
// Create date in local timezone to avoid offset issues
|
||||
const parts = e.target.value.split('-');
|
||||
dateValue = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
|
||||
}
|
||||
handleDateChange(dateValue);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{showTime && (
|
||||
<div>
|
||||
<Label htmlFor="time-input" className="text-sm font-medium">
|
||||
Time
|
||||
</Label>
|
||||
<Input
|
||||
id="time-input"
|
||||
type="time"
|
||||
step="1"
|
||||
value={internalTime}
|
||||
onChange={handleTimeChange}
|
||||
className="mt-1 bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
startLabel?: string;
|
||||
endLabel?: string;
|
||||
startValue?: DateTimeValue;
|
||||
endValue?: DateTimeValue;
|
||||
onStartChange?: (value: DateTimeValue) => void;
|
||||
onEndChange?: (value: DateTimeValue) => void;
|
||||
onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showTime?: boolean;
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
// startLabel = "From",
|
||||
// endLabel = "To",
|
||||
startValue,
|
||||
endValue,
|
||||
onStartChange,
|
||||
onEndChange,
|
||||
onRangeChange,
|
||||
className,
|
||||
disabled = false,
|
||||
showTime = true,
|
||||
}: DateRangePickerProps) {
|
||||
const handleStartChange = (value: DateTimeValue) => {
|
||||
onStartChange?.(value);
|
||||
if (onRangeChange && endValue) {
|
||||
onRangeChange(value, endValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndChange = (value: DateTimeValue) => {
|
||||
onEndChange?.(value);
|
||||
if (onRangeChange && startValue) {
|
||||
onRangeChange(startValue, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-4", className)}>
|
||||
<DateTimePicker
|
||||
// label={startLabel}
|
||||
value={startValue}
|
||||
onChange={handleStartChange}
|
||||
placeholder="Start date & time"
|
||||
disabled={disabled}
|
||||
showTime={showTime}
|
||||
/>
|
||||
<DateTimePicker
|
||||
// label={endLabel}
|
||||
value={endValue}
|
||||
onChange={handleEndChange}
|
||||
placeholder="End date & time"
|
||||
disabled={disabled}
|
||||
showTime={showTime}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
367
src/components/LogDataTable.tsx
Normal file
367
src/components/LogDataTable.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Plus, Search, RefreshCw, Filter, X } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DateRangePicker, DateTimeValue } from "@app/components/DateTimePicker";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
PAGE_SIZE: "datatable-page-size",
|
||||
getTablePageSize: (tableId?: string) =>
|
||||
tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE
|
||||
};
|
||||
|
||||
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
|
||||
if (typeof window === "undefined") return defaultSize;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
// Validate that it's a reasonable page size
|
||||
if (parsed > 0 && parsed <= 1000) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read page size from localStorage:", error);
|
||||
}
|
||||
return defaultSize;
|
||||
};
|
||||
|
||||
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
localStorage.setItem(key, pageSize.toString());
|
||||
} catch (error) {
|
||||
console.warn("Failed to save page size to localStorage:", error);
|
||||
}
|
||||
};
|
||||
|
||||
type TabFilter = {
|
||||
id: string;
|
||||
label: string;
|
||||
filterFn: (row: any) => boolean;
|
||||
};
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
title?: string;
|
||||
addButtonText?: string;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
searchColumn?: string;
|
||||
defaultSort?: {
|
||||
id: string;
|
||||
desc: boolean;
|
||||
};
|
||||
tabs?: TabFilter[];
|
||||
defaultTab?: string;
|
||||
persistPageSize?: boolean | string;
|
||||
defaultPageSize?: number;
|
||||
onDateRangeChange?: (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => void;
|
||||
dateRange?: {
|
||||
start: DateTimeValue;
|
||||
end: DateTimeValue;
|
||||
};
|
||||
};
|
||||
|
||||
export function LogDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
title,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
searchPlaceholder = "Search...",
|
||||
searchColumn = "name",
|
||||
defaultSort,
|
||||
tabs,
|
||||
defaultTab,
|
||||
persistPageSize = false,
|
||||
defaultPageSize = 20,
|
||||
onDateRangeChange,
|
||||
dateRange
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
// Determine table identifier for storage
|
||||
const tableId =
|
||||
typeof persistPageSize === "string" ? persistPageSize : undefined;
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useState<number>(() => {
|
||||
if (persistPageSize) {
|
||||
return getStoredPageSize(tableId, defaultPageSize);
|
||||
}
|
||||
return defaultPageSize;
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
defaultSort ? [defaultSort] : []
|
||||
);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState<any>([]);
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
defaultTab || tabs?.[0]?.id || ""
|
||||
);
|
||||
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [startDate, setStartDate] = useState<DateTimeValue>(
|
||||
dateRange?.start || {}
|
||||
);
|
||||
const [endDate, setEndDate] = useState<DateTimeValue>(dateRange?.end || {});
|
||||
|
||||
// Sync internal date state with external dateRange prop
|
||||
useEffect(() => {
|
||||
if (dateRange?.start) {
|
||||
setStartDate(dateRange.start);
|
||||
}
|
||||
if (dateRange?.end) {
|
||||
setEndDate(dateRange.end);
|
||||
}
|
||||
}, [dateRange?.start, dateRange?.end]);
|
||||
|
||||
// Apply tab filter to data
|
||||
const filteredData = useMemo(() => {
|
||||
if (!tabs || activeTab === "") {
|
||||
return data;
|
||||
}
|
||||
|
||||
const activeTabFilter = tabs.find((tab) => tab.id === activeTab);
|
||||
if (!activeTabFilter) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.filter(activeTabFilter.filterFn);
|
||||
}, [data, tabs, activeTab]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: pageSize,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const currentPageSize = table.getState().pagination.pageSize;
|
||||
if (currentPageSize !== pageSize) {
|
||||
table.setPageSize(pageSize);
|
||||
|
||||
// Persist to localStorage if enabled
|
||||
if (persistPageSize) {
|
||||
setStoredPageSize(pageSize, tableId);
|
||||
}
|
||||
}
|
||||
}, [pageSize, table, persistPageSize, tableId]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
// Reset to first page when changing tabs
|
||||
table.setPageIndex(0);
|
||||
};
|
||||
|
||||
// Enhanced pagination component that updates our local state
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
table.setPageSize(newPageSize);
|
||||
|
||||
// Persist immediately when changed
|
||||
if (persistPageSize) {
|
||||
setStoredPageSize(newPageSize, tableId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (
|
||||
start: DateTimeValue,
|
||||
end: DateTimeValue
|
||||
) => {
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
onDateRangeChange?.(start, end);
|
||||
};
|
||||
|
||||
const clearDateFilter = () => {
|
||||
const emptyStart = {};
|
||||
const emptyEnd = {};
|
||||
setStartDate(emptyStart);
|
||||
setEndDate(emptyEnd);
|
||||
onDateRangeChange?.(emptyStart, emptyEnd);
|
||||
setShowDatePicker(false);
|
||||
};
|
||||
|
||||
const hasDateFilter = startDate?.date || endDate?.date;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
table.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
>
|
||||
{tab.label} (
|
||||
{data.filter(tab.filterFn).length})
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<DateRangePicker
|
||||
startValue={startDate}
|
||||
endValue={endDate}
|
||||
onRangeChange={handleDateRangeChange}
|
||||
className="flex-wrap gap-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() && "selected"
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user