mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-08 03:36:37 +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?",
|
"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.",
|
"clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.",
|
||||||
"sidebarLogs": "Logs",
|
"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.
|
* 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 auth from "#private/routers/auth";
|
||||||
import * as license from "#private/routers/license";
|
import * as license from "#private/routers/license";
|
||||||
import * as generateLicense from "./generatedLicense";
|
import * as generateLicense from "./generatedLicense";
|
||||||
|
import * as logs from "#private/routers/auditLogs";
|
||||||
|
|
||||||
import { Router } from "express";
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction,
|
verifyUserHasAction,
|
||||||
@@ -345,3 +345,8 @@ authenticated.post(
|
|||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
license.recheckStatus
|
license.recheckStatus
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/action",
|
||||||
|
logs.queryAccessAuditLogs
|
||||||
|
)
|
||||||
@@ -1,47 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|
||||||
import AuthPageSettings, {
|
|
||||||
AuthPageSettingsRef
|
|
||||||
} from "@app/components/private/AuthPageSettings";
|
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
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 { toast } from "@app/hooks/useToast";
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useEffect } 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 { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
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 { useTranslations } from "next-intl";
|
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() {
|
export default function GeneralPage() {
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
@@ -49,6 +17,211 @@ export default function GeneralPage() {
|
|||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
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",
|
title: "sidebarLogs",
|
||||||
href: "/{orgId}/settings/logs",
|
href: "/{orgId}/settings/logs/access",
|
||||||
icon: <Logs className="h-4 w-4" />
|
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