diff --git a/messages/en-US.json b/messages/en-US.json index e6790e0d..521c4a39 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1901,5 +1901,6 @@ "actor": "Actor", "timestamp": "Timestamp", "accessLogs": "Access Logs", - "exportCsv": "Export CSV" + "exportCsv": "Export CSV", + "actorId": "Actor ID" } diff --git a/package-lock.json b/package-lock.json index f03f5a87..3457a122 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index 44985a2f..ce222a85 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 6139ce4e..379266ae 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -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 }) diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 2fe8acd7..a681ec64 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -23,22 +23,31 @@ export default function GeneralPage() { const [isRefreshing, setIsRefreshing] = useState(false); const [isExporting, setIsExporting] = useState(false); + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [pageSize, setPageSize] = useState(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 ( - - ); + return t("timestamp"); }, cell: ({ row }) => { return ( -
+
{new Date( row.original.timestamp * 1000 ).toLocaleString()} @@ -191,48 +224,48 @@ export default function GeneralPage() { { accessorKey: "action", header: ({ column }) => { - return ( - - ); + return t("action"); }, // make the value capitalized cell: ({ row }) => { return ( - {row.original.action.charAt(0).toUpperCase() + row.original.action.slice(1)} + {row.original.action.charAt(0).toUpperCase() + + row.original.action.slice(1)} ); - }, + } }, { accessorKey: "actor", header: ({ column }) => { - return ( - - ); + return t("actor"); }, - cell: ({ row }) => { + cell: ({ row }) => { return ( - {row.original.actorType == "user" ? : } + {row.original.actorType == "user" ? ( + + ) : ( + + )} {row.original.actor} ); } + }, + { + accessorKey: "actorId", + header: ({ column }) => { + return t("actorId"); + }, + cell: ({ row }) => { + return ( + + {row.original.actorId} + + ); + } } ]; @@ -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} /> ); diff --git a/src/app/[orgId]/settings/logs/layout.tsx b/src/app/[orgId]/settings/logs/layout.tsx index 01e0d248..a5cee4a2 100644 --- a/src/app/[orgId]/settings/logs/layout.tsx +++ b/src/app/[orgId]/settings/logs/layout.tsx @@ -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"), diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index af0a0fe6..a07354f7 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -19,11 +19,19 @@ import { useTranslations } from "next-intl"; interface DataTablePaginationProps { table: Table; onPageSizeChange?: (pageSize: number) => void; + onPageChange?: (pageIndex: number) => void; + totalCount?: number; + isServerPagination?: boolean; + isLoading?: boolean; } export function DataTablePagination({ table, - onPageSizeChange + onPageSizeChange, + onPageChange, + totalCount, + isServerPagination = false, + isLoading = false }: DataTablePaginationProps) { const t = useTranslations(); @@ -37,6 +45,51 @@ export function DataTablePagination({ } }; + 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 (
@@ -61,14 +114,21 @@ export function DataTablePagination({
- {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()}) + )}
-
-
-
- - { - 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" - /> -
- {showTime && ( -
+ {showTime ? ( +
+ { + handleDateChange(date); + if (!showTime) { + setOpen(false); + } + }} + className="flex-grow w-[250px]" + /> +
+
@@ -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" />
- )} +
-
+ ) : ( + { + handleDateChange(date); + setOpen(false); + }} + /> + )}
diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 5b9e0514..3de4dc19 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -103,6 +103,12 @@ type DataTableProps = { 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({ @@ -121,7 +127,12 @@ export function LogDataTable({ persistPageSize = false, defaultPageSize = 20, onDateRangeChange, - dateRange + dateRange, + totalCount, + currentPage = 0, + onPageChange, + onPageSizeChange: onPageSizeChangeProp, + isLoading = false }: DataTableProps) { const t = useTranslations(); @@ -175,26 +186,39 @@ export function LogDataTable({ 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({ } }, [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({ 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({ )} {onExport && (
diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 00000000..a48a0f7c --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -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 & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + 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 ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( +