Date picker working

This commit is contained in:
Owen
2025-10-21 20:15:43 -07:00
parent bdc3b2425b
commit 1142d6ac48
10 changed files with 555 additions and 109 deletions

View File

@@ -1901,5 +1901,6 @@
"actor": "Actor",
"timestamp": "Timestamp",
"accessLogs": "Access Logs",
"exportCsv": "Export CSV"
"exportCsv": "Export CSV",
"actorId": "Actor ID"
}

77
package-lock.json generated
View File

@@ -52,6 +52,7 @@
"cookies": "^0.9.1",
"cors": "2.8.5",
"crypto-js": "^4.2.0",
"date-fns": "4.1.0",
"drizzle-orm": "0.44.6",
"eslint": "9.37.0",
"eslint-config-next": "15.5.6",
@@ -81,6 +82,7 @@
"posthog-node": "^5.9.5",
"qrcode.react": "4.2.0",
"react": "19.2.0",
"react-day-picker": "9.11.1",
"react-dom": "19.2.0",
"react-easy-sort": "^1.8.0",
"react-hook-form": "7.65.0",
@@ -1628,6 +1630,7 @@
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -1979,6 +1982,12 @@
"kuler": "^2.0.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz",
@@ -4015,6 +4024,7 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -6844,6 +6854,7 @@
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -7049,6 +7060,7 @@
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7059,6 +7071,7 @@
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.25.0"
},
@@ -8490,6 +8503,7 @@
"integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -8576,6 +8590,7 @@
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -8669,6 +8684,7 @@
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.14.0"
}
@@ -8697,6 +8713,7 @@
"integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -8730,6 +8747,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -8740,6 +8758,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -8883,6 +8902,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
@@ -9556,6 +9576,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -10086,6 +10107,7 @@
"integrity": "sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -10199,6 +10221,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -10936,6 +10959,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debounce": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz",
@@ -11839,6 +11878,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -11935,6 +11975,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -12102,6 +12143,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -12391,6 +12433,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
@@ -17514,6 +17557,7 @@
"version": "4.0.3",
"inBundle": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18492,6 +18536,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@@ -18668,6 +18713,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -19119,15 +19165,38 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "9.11.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz",
"integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -19419,6 +19488,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
"integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -19903,6 +19973,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -21068,7 +21139,8 @@
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
"integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -21625,6 +21697,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -22130,6 +22203,7 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz",
"integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -22437,6 +22511,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -75,6 +75,7 @@
"cookies": "^0.9.1",
"cors": "2.8.5",
"crypto-js": "^4.2.0",
"date-fns": "4.1.0",
"drizzle-orm": "0.44.6",
"eslint": "9.37.0",
"eslint-config-next": "15.5.6",
@@ -104,6 +105,7 @@
"posthog-node": "^5.9.5",
"qrcode.react": "4.2.0",
"react": "19.2.0",
"react-day-picker": "9.11.1",
"react-dom": "19.2.0",
"react-easy-sort": "^1.8.0",
"react-hook-form": "7.65.0",

View File

@@ -65,6 +65,7 @@ export function querySites(timeStart: number, timeEnd: number, orgId: string) {
orgId: actionAuditLog.orgId,
action: actionAuditLog.action,
actorType: actionAuditLog.actorType,
actorId: actionAuditLog.actorId,
timestamp: actionAuditLog.timestamp,
actor: actionAuditLog.actor
})

View File

@@ -23,22 +23,31 @@ export default function GeneralPage() {
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExporting, setIsExporting] = useState(false);
// Pagination state
const [totalCount, setTotalCount] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(0);
const [pageSize, setPageSize] = useState<number>(20);
const [isLoading, setIsLoading] = 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,
date: yesterday
},
endDate: {
date: now,
date: now
}
};
};
const [dateRange, setDateRange] = useState<{ startDate: DateTimeValue; endDate: DateTimeValue }>(getDefaultDateRange());
const [dateRange, setDateRange] = useState<{
startDate: DateTimeValue;
endDate: DateTimeValue;
}>(getDefaultDateRange());
// Trigger search with default values on component mount
useEffect(() => {
@@ -51,21 +60,42 @@ export default function GeneralPage() {
endDate: DateTimeValue
) => {
setDateRange({ startDate, endDate });
queryDateTime(startDate, endDate);
setCurrentPage(0); // Reset to first page when filtering
queryDateTime(startDate, endDate, 0, pageSize);
};
// Handle page changes
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
queryDateTime(
dateRange.startDate,
dateRange.endDate,
newPage,
pageSize
);
};
// Handle page size changes
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
};
const queryDateTime = async (
startDate: DateTimeValue,
endDate: DateTimeValue
endDate: DateTimeValue,
page: number = currentPage,
size: number = pageSize
) => {
console.log("Date range changed:", { startDate, endDate });
setIsRefreshing(true);
console.log("Date range changed:", { startDate, endDate, page, size });
setIsLoading(true);
try {
// Convert the date/time values to API parameters
let params: any = {
limit: 20,
offset: 0
limit: size,
offset: page * size
};
if (startDate?.date) {
@@ -89,14 +119,20 @@ export default function GeneralPage() {
} else {
// If no time is specified, set to NOW
const now = new Date();
endDateTime.setHours(now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds());
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);
setRows(res.data.data.log || []);
setTotalCount(res.data.data.pagination?.total || 0);
console.log("Fetched logs:", res.data);
}
} catch (error) {
@@ -106,17 +142,21 @@ export default function GeneralPage() {
variant: "destructive"
});
} finally {
setIsRefreshing(false);
setIsLoading(false);
}
};
const refreshData = async () => {
console.log("Data refreshed");
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
// Refresh data with current date range and pagination
await queryDateTime(
dateRange.startDate,
dateRange.endDate,
currentPage,
pageSize
);
} catch (error) {
toast({
title: t("error"),
@@ -139,8 +179,8 @@ export default function GeneralPage() {
: undefined,
timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString()
: undefined,
},
: undefined
}
});
// Create a URL for the blob and trigger a download
@@ -148,7 +188,10 @@ export default function GeneralPage() {
const link = document.createElement("a");
link.href = url;
const epoch = Math.floor(Date.now() / 1000);
link.setAttribute("download", `access_audit_logs_${orgId}_${epoch}.csv`);
link.setAttribute(
"download",
`access_audit_logs_${orgId}_${epoch}.csv`
);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
@@ -166,21 +209,11 @@ export default function GeneralPage() {
{
accessorKey: "timestamp",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
className="hidden md:flex whitespace-nowrap"
>
{t("timestamp")}
</Button>
);
return t("timestamp");
},
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
<div className="whitespace-nowrap">
{new Date(
row.original.timestamp * 1000
).toLocaleString()}
@@ -191,48 +224,48 @@ export default function GeneralPage() {
{
accessorKey: "action",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("action")}
</Button>
);
return t("action");
},
// make the value capitalized
cell: ({ row }) => {
return (
<span className="hitespace-nowrap">
{row.original.action.charAt(0).toUpperCase() + row.original.action.slice(1)}
{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>
);
return t("actor");
},
cell: ({ row }) => {
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.actorType == "user" ? (
<User className="h-4 w-4" />
) : (
<Key className="h-4 w-4" />
)}
{row.original.actor}
</span>
);
}
},
{
accessorKey: "actorId",
header: ({ column }) => {
return t("actorId");
},
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
{row.original.actorId}
</span>
);
}
}
];
@@ -258,6 +291,13 @@ export default function GeneralPage() {
id: "timestamp",
desc: false
}}
// Server-side pagination props
totalCount={totalCount}
currentPage={currentPage}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
isLoading={isLoading}
defaultPageSize={pageSize}
/>
</>
);

View File

@@ -34,8 +34,8 @@ export default async function GeneralSettingsPage({
const navItems = [
{
title: t("access"),
href: `/{orgId}/settings/logs/access`
title: t("action"),
href: `/{orgId}/settings/logs/action`
},
{
title: t("request"),

View File

@@ -19,11 +19,19 @@ import { useTranslations } from "next-intl";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
onPageSizeChange?: (pageSize: number) => void;
onPageChange?: (pageIndex: number) => void;
totalCount?: number;
isServerPagination?: boolean;
isLoading?: boolean;
}
export function DataTablePagination<TData>({
table,
onPageSizeChange
onPageSizeChange,
onPageChange,
totalCount,
isServerPagination = false,
isLoading = false
}: DataTablePaginationProps<TData>) {
const t = useTranslations();
@@ -37,6 +45,51 @@ export function DataTablePagination<TData>({
}
};
const handlePageNavigation = (action: 'first' | 'previous' | 'next' | 'last') => {
if (isServerPagination && onPageChange) {
const currentPage = table.getState().pagination.pageIndex;
const pageCount = table.getPageCount();
let newPage: number;
switch (action) {
case 'first':
newPage = 0;
break;
case 'previous':
newPage = Math.max(0, currentPage - 1);
break;
case 'next':
newPage = Math.min(pageCount - 1, currentPage + 1);
break;
case 'last':
newPage = pageCount - 1;
break;
default:
return;
}
if (newPage !== currentPage) {
onPageChange(newPage);
}
} else {
// Use table's built-in navigation for client-side pagination
switch (action) {
case 'first':
table.setPageIndex(0);
break;
case 'previous':
table.previousPage();
break;
case 'next':
table.nextPage();
break;
case 'last':
table.setPageIndex(table.getPageCount() - 1);
break;
}
}
};
return (
<div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center space-x-2">
@@ -61,14 +114,21 @@ export function DataTablePagination<TData>({
<div className="flex items-center space-x-3 lg:space-x-8">
<div className="flex items-center justify-center text-sm font-medium">
{t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})}
{isServerPagination && totalCount !== undefined ? (
t('paginator', {
current: table.getState().pagination.pageIndex + 1,
last: Math.ceil(totalCount / table.getState().pagination.pageSize)
})
) : (
t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
onClick={() => handlePageNavigation('first')}
disabled={!table.getCanPreviousPage() || isLoading}
>
<span className="sr-only">{t('paginatorToFirst')}</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
@@ -76,8 +136,8 @@ export function DataTablePagination<TData>({
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
onClick={() => handlePageNavigation('previous')}
disabled={!table.getCanPreviousPage() || isLoading}
>
<span className="sr-only">{t('paginatorToPrevious')}</span>
<ChevronLeftIcon className="h-4 w-4" />
@@ -85,8 +145,8 @@ export function DataTablePagination<TData>({
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
onClick={() => handlePageNavigation('next')}
disabled={!table.getCanNextPage() || isLoading}
>
<span className="sr-only">{t('paginatorToNext')}</span>
<ChevronRightIcon className="h-4 w-4" />
@@ -94,10 +154,8 @@ export function DataTablePagination<TData>({
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() =>
table.setPageIndex(table.getPageCount() - 1)
}
disabled={!table.getCanNextPage()}
onClick={() => handlePageNavigation('last')}
disabled={!table.getCanNextPage() || isLoading}
>
<span className="sr-only">{t('paginatorToLast')}</span>
<DoubleArrowRightIcon className="h-4 w-4" />

View File

@@ -3,6 +3,7 @@
import { ChevronDownIcon, CalendarIcon } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { Calendar } from "@app/components/ui/calendar";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import {
@@ -60,14 +61,20 @@ export function DateTimePicker({
onChange?.(newValue);
};
const getDisplayText = () => {
const getDisplayText = () => {
if (!internalDate) return placeholder;
const dateStr = internalDate.toLocaleDateString();
if (!showTime || !internalTime) return dateStr;
return `${dateStr} ${internalTime}`;
};
// Parse time and format in local timezone
const [hours, minutes, seconds] = internalTime.split(':');
const timeDate = new Date();
timeDate.setHours(parseInt(hours, 10), parseInt(minutes, 10), parseInt(seconds || '0', 10));
const timeStr = timeDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return `${dateStr} ${timeStr}`;
};
const hasValue = internalDate || (showTime && internalTime);
@@ -93,36 +100,26 @@ export function DateTimePicker({
)}
>
{getDisplayText()}
<CalendarIcon className="h-4 w-4" />
<ChevronDownIcon 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>
{showTime ? (
<div className="flex">
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
if (!showTime) {
setOpen(false);
}
}}
className="flex-grow w-[250px]"
/>
<div className="p-3 border-l">
<div className="flex flex-col gap-3">
<Label htmlFor="time-input" className="text-sm font-medium">
Time
</Label>
@@ -132,12 +129,22 @@ export function DateTimePicker({
step="1"
value={internalTime}
onChange={handleTimeChange}
className="mt-1 bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
)}
</div>
</div>
</div>
) : (
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
setOpen(false);
}}
/>
)}
</PopoverContent>
</Popover>
</div>

View File

@@ -103,6 +103,12 @@ type DataTableProps<TData, TValue> = {
start: DateTimeValue;
end: DateTimeValue;
};
// Server-side pagination props
totalCount?: number;
currentPage?: number;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
isLoading?: boolean;
};
export function LogDataTable<TData, TValue>({
@@ -121,7 +127,12 @@ export function LogDataTable<TData, TValue>({
persistPageSize = false,
defaultPageSize = 20,
onDateRangeChange,
dateRange
dateRange,
totalCount,
currentPage = 0,
onPageChange,
onPageSizeChange: onPageSizeChangeProp,
isLoading = false
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -175,26 +186,39 @@ export function LogDataTable<TData, TValue>({
return data.filter(activeTabFilter.filterFn);
}, [data, tabs, activeTab]);
// Determine if using server-side pagination
const isServerPagination = totalCount !== undefined;
const table = useReactTable({
data: filteredData,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// Only use client-side pagination if totalCount is not provided
...(isServerPagination ? {} : { getPaginationRowModel: getPaginationRowModel() }),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter,
// Configure pagination state
...(isServerPagination ? {
manualPagination: true,
pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0,
} : {}),
initialState: {
pagination: {
pageSize: pageSize,
pageIndex: 0
pageIndex: currentPage
}
},
state: {
sorting,
columnFilters,
globalFilter
globalFilter,
pagination: {
pageSize: pageSize,
pageIndex: currentPage
}
}
});
@@ -210,6 +234,16 @@ export function LogDataTable<TData, TValue>({
}
}, [pageSize, table, persistPageSize, tableId]);
// Update table page index when currentPage prop changes (server pagination)
useEffect(() => {
if (isServerPagination) {
const currentPageIndex = table.getState().pagination.pageIndex;
if (currentPageIndex !== currentPage) {
table.setPageIndex(currentPage);
}
}
}, [currentPage, table, isServerPagination]);
const handleTabChange = (value: string) => {
setActiveTab(value);
// Reset to first page when changing tabs
@@ -225,6 +259,18 @@ export function LogDataTable<TData, TValue>({
if (persistPageSize) {
setStoredPageSize(newPageSize, tableId);
}
// For server pagination, notify parent component
if (isServerPagination && onPageSizeChangeProp) {
onPageSizeChangeProp(newPageSize);
}
};
// Handle page changes for server pagination
const handlePageChange = (newPageIndex: number) => {
if (isServerPagination && onPageChange) {
onPageChange(newPageIndex);
}
};
const handleDateRangeChange = (
@@ -276,7 +322,6 @@ export function LogDataTable<TData, TValue>({
)}
{onExport && (
<Button
variant="outline"
onClick={onExport}
disabled={isExporting}
>
@@ -342,6 +387,10 @@ export function LogDataTable<TData, TValue>({
<DataTablePagination
table={table}
onPageSizeChange={handlePageSizeChange}
onPageChange={isServerPagination ? handlePageChange : undefined}
totalCount={totalCount}
isServerPagination={isServerPagination}
isLoading={isLoading}
/>
</div>
</CardContent>

View File

@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@app/lib/cn"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }