From aee0f70946d296a399f9c6fc7128e7a361d0fbd8 Mon Sep 17 00:00:00 2001 From: Conrad Date: Thu, 21 May 2026 11:06:54 +1200 Subject: [PATCH 01/14] created the admin page and the route to it in frontend --- client/src/main/App.tsx | 2 ++ client/src/main/Header.tsx | 3 +++ client/src/pages/Admin.tsx | 12 ++++++++++++ 3 files changed, 17 insertions(+) create mode 100644 client/src/pages/Admin.tsx diff --git a/client/src/main/App.tsx b/client/src/main/App.tsx index d20402b..4e4606e 100644 --- a/client/src/main/App.tsx +++ b/client/src/main/App.tsx @@ -16,6 +16,7 @@ import Sponsors from "../pages/Sponsors.tsx"; import Events from "../pages/Events.tsx"; import About from "../pages/About.tsx"; import SignUp from "../pages/Signup.tsx"; +import Admin from "../pages/Admin.tsx"; const App = () => { return ( @@ -32,6 +33,7 @@ const App = () => { } /> } /> } /> + } /> } /> diff --git a/client/src/main/Header.tsx b/client/src/main/Header.tsx index 5af4584..f5fe830 100644 --- a/client/src/main/Header.tsx +++ b/client/src/main/Header.tsx @@ -43,6 +43,9 @@ const Header = () => { Faq + + Admin Dashboard + {!loading && (isSignedIn ? ( diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx new file mode 100644 index 0000000..e164d4c --- /dev/null +++ b/client/src/pages/Admin.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import "../style/common.css"; + +const Admin = () => { + return ( +
+

Admin Dashboard

+
+ ) +} + +export default Admin From f452cf4d5685f75d11f97ea70afa9c2822bc95de Mon Sep 17 00:00:00 2001 From: Conrad Date: Thu, 21 May 2026 12:20:03 +1200 Subject: [PATCH 02/14] created rough structure of admin page, added admin components --- client/package-lock.json | 26 +------------------ .../AdminComponents/AdminDashboard.tsx | 17 ++++++++++++ .../AdminComponents/AdminSidebar.tsx | 12 +++++++++ client/src/pages/Admin.tsx | 8 +++--- 4 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 client/src/components/AdminComponents/AdminDashboard.tsx create mode 100644 client/src/components/AdminComponents/AdminSidebar.tsx diff --git a/client/package-lock.json b/client/package-lock.json index 599f4d8..5478636 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1451,9 +1451,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1470,9 +1467,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1489,9 +1483,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1508,9 +1499,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4817,9 +4805,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4840,9 +4825,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4863,9 +4845,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4886,9 +4865,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6949,4 +6925,4 @@ } } } -} \ No newline at end of file +} diff --git a/client/src/components/AdminComponents/AdminDashboard.tsx b/client/src/components/AdminComponents/AdminDashboard.tsx new file mode 100644 index 0000000..222e4b7 --- /dev/null +++ b/client/src/components/AdminComponents/AdminDashboard.tsx @@ -0,0 +1,17 @@ + + +export default function AdminDashboard() { + return ( +
+

Admin Dashboard

+
+
Search Bar
+
Filters
+
+
+

Content

+
+ +
+ ) +} diff --git a/client/src/components/AdminComponents/AdminSidebar.tsx b/client/src/components/AdminComponents/AdminSidebar.tsx new file mode 100644 index 0000000..46e7a84 --- /dev/null +++ b/client/src/components/AdminComponents/AdminSidebar.tsx @@ -0,0 +1,12 @@ + +export default function AdminSidebar() { + return ( +
+

Sidebar

+
Members
+
Form Responses
+
+ ) +} + + diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx index e164d4c..1de54f8 100644 --- a/client/src/pages/Admin.tsx +++ b/client/src/pages/Admin.tsx @@ -1,10 +1,12 @@ -import React from 'react' import "../style/common.css"; +import AdminSidebar from "../components/AdminComponents/AdminSidebar.tsx"; +import AdminDashboard from "../components/AdminComponents/AdminDashboard.tsx"; const Admin = () => { return ( -
-

Admin Dashboard

+
+ +
) } From c76b051d0630f09b14c646eb11e37d61224e2cad Mon Sep 17 00:00:00 2001 From: Conrad Date: Thu, 28 May 2026 16:42:46 +1200 Subject: [PATCH 03/14] fixed dependency issues and added the admin guard for the admin page --- client/package-lock.json | 16 ++++++++-------- client/src/pages/Admin.tsx | 11 +++++++---- package-lock.json | 20 ++++++++++++++++++++ package.json | 3 +++ 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index f3e603b..071696d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,8 +10,8 @@ "dependencies": { "@stripe/react-stripe-js": "^6.3.0", "@stripe/stripe-js": "^9.5.0", - "axios": "^1.16.0", "@tailwindcss/vite": "^4.3.0", + "axios": "^1.16.0", "lucide-react": "^1.11.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -5655,12 +5655,6 @@ "react": "^19.2.4" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/react-hot-toast": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", @@ -5678,6 +5672,12 @@ "react-dom": ">=16" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -7004,4 +7004,4 @@ } } } -} \ No newline at end of file +} diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx index 1de54f8..8a23162 100644 --- a/client/src/pages/Admin.tsx +++ b/client/src/pages/Admin.tsx @@ -1,13 +1,16 @@ import "../style/common.css"; import AdminSidebar from "../components/AdminComponents/AdminSidebar.tsx"; import AdminDashboard from "../components/AdminComponents/AdminDashboard.tsx"; +import { AdminRoute } from "../auth/AdminRoute.tsx"; const Admin = () => { return ( -
- - -
+ +
+ + +
+
) } diff --git a/package-lock.json b/package-lock.json index 6b45629..b1e6bb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,9 @@ "requires": true, "packages": { "": { + "dependencies": { + "stripe": "^22.2.0" + }, "devDependencies": { "concurrently": "^9.2.1", "prettier": "^3.8.3" @@ -249,6 +252,23 @@ "node": ">=8" } }, + "node_modules/stripe": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.2.0.tgz", + "integrity": "sha512-WFGpMOom9QZqso1kcnSwJsCdC1QHDlMoCOxBZRf3JraMzhkfw7dgSdD2a1CFZrqC+mzAfqeEtYILrZhWKIDruA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", diff --git a/package.json b/package.json index 7b3ee63..a3382b8 100644 --- a/package.json +++ b/package.json @@ -11,5 +11,8 @@ "devDependencies": { "concurrently": "^9.2.1", "prettier": "^3.8.3" + }, + "dependencies": { + "stripe": "^22.2.0" } } From 196d7dd6b307c5406ee2899cede96332aa09e921 Mon Sep 17 00:00:00 2001 From: Conrad Date: Thu, 28 May 2026 16:58:18 +1200 Subject: [PATCH 04/14] hide admin dashboard for users that are not admins --- client/src/main/Header.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/src/main/Header.tsx b/client/src/main/Header.tsx index a74eacf..d1679c6 100644 --- a/client/src/main/Header.tsx +++ b/client/src/main/Header.tsx @@ -13,7 +13,7 @@ const headerStyle = { }; const Header = () => { - const { user, hasAccount, loading, logout } = useAuth(); + const { user, role, hasAccount, loading, logout } = useAuth(); // User is only considered "signed in" to the club once they have a full account. // A Google-authed user mid-signup should still see the Sign In button. @@ -43,9 +43,12 @@ const Header = () => { Faq - - Admin Dashboard - + {role === "admin" ? + + Admin Dashboard + : null + } + {!loading && (isSignedIn ? ( From 9e3813a6bc17e1303d2e35287b0299e93733786b Mon Sep 17 00:00:00 2001 From: Conrad Date: Fri, 29 May 2026 21:21:25 +1200 Subject: [PATCH 05/14] fleshed out the pages further, imported tanstack table and the components for the member and responses stuff, beginning to optimise the styling --- client/package-lock.json | 34 ++ client/package.json | 3 +- client/src/api/contactApi.ts | 14 + client/src/api/usersApi.ts | 16 + .../AdminComponents/AdminDashboard.tsx | 77 +++- .../AdminComponents/AdminSidebar.tsx | 62 +++- .../components/AdminComponents/DataTable.tsx | 325 ++++++++++++++++ .../AdminComponents/MemberColumns.tsx | 178 +++++++++ .../AdminComponents/MemberDetailsModal.tsx | 351 ++++++++++++++++++ .../AdminComponents/MembersSection.tsx | 127 +++++++ .../AdminComponents/ResponseColumns.tsx | 94 +++++ .../AdminComponents/ResponseDetailsModal.tsx | 198 ++++++++++ .../AdminComponents/ResponseSection.tsx | 140 +++++++ client/src/pages/Admin.tsx | 32 +- server/src/controllers/contactController.ts | 46 +++ server/src/controllers/userController.ts | 86 +++++ server/src/routes/contactRoutes.ts | 12 +- server/src/routes/userRoutes.ts | 11 +- 18 files changed, 1776 insertions(+), 30 deletions(-) create mode 100644 client/src/api/usersApi.ts create mode 100644 client/src/components/AdminComponents/DataTable.tsx create mode 100644 client/src/components/AdminComponents/MemberColumns.tsx create mode 100644 client/src/components/AdminComponents/MemberDetailsModal.tsx create mode 100644 client/src/components/AdminComponents/MembersSection.tsx create mode 100644 client/src/components/AdminComponents/ResponseColumns.tsx create mode 100644 client/src/components/AdminComponents/ResponseDetailsModal.tsx create mode 100644 client/src/components/AdminComponents/ResponseSection.tsx diff --git a/client/package-lock.json b/client/package-lock.json index 071696d..058251a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "@stripe/react-stripe-js": "^6.3.0", "@stripe/stripe-js": "^9.5.0", "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-table": "^8.21.3", "axios": "^1.16.0", "lucide-react": "^1.11.0", "react": "^19.2.0", @@ -1609,6 +1610,39 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/client/package.json b/client/package.json index 93e937a..44c0fd0 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,7 @@ "@stripe/react-stripe-js": "^6.3.0", "@stripe/stripe-js": "^9.5.0", "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-table": "^8.21.3", "axios": "^1.16.0", "lucide-react": "^1.11.0", "react": "^19.2.0", @@ -37,4 +38,4 @@ "typescript-eslint": "^8.48.0", "vite": "^7.3.1" } -} \ No newline at end of file +} diff --git a/client/src/api/contactApi.ts b/client/src/api/contactApi.ts index af9c866..3396129 100644 --- a/client/src/api/contactApi.ts +++ b/client/src/api/contactApi.ts @@ -13,3 +13,17 @@ export async function fetchContacts() { const res = api.get("/contacts"); return res.then((response) => response.data); } + +export async function updateContact(id: string, contactData: { + name: string; + email: string; + message: string; +}) { + const res = api.put(`/contacts/${id}`, contactData); + return res.then((response) => response.data); +} + +export async function deleteContact(id: string) { + const res = api.delete(`/contacts/${id}`); + return res.then((response) => response.data); +} diff --git a/client/src/api/usersApi.ts b/client/src/api/usersApi.ts new file mode 100644 index 0000000..1d1ea78 --- /dev/null +++ b/client/src/api/usersApi.ts @@ -0,0 +1,16 @@ +import api from "./index"; + +export async function fetchMembers() { + const response = await api.get("/users"); + return response.data; +} + +export async function updateMember(id: string, member: object) { + const response = await api.put(`/users/${id}`, member); + return response.data; +} + +export async function deleteMember(id: string) { + const response = await api.delete(`/users/${id}`); + return response.data; +} diff --git a/client/src/components/AdminComponents/AdminDashboard.tsx b/client/src/components/AdminComponents/AdminDashboard.tsx index 222e4b7..6d4d6eb7 100644 --- a/client/src/components/AdminComponents/AdminDashboard.tsx +++ b/client/src/components/AdminComponents/AdminDashboard.tsx @@ -1,17 +1,68 @@ +import type { AdminSection } from "../../pages/Admin.tsx"; +import MembersSection from "./MembersSection.tsx"; +import ResponsesSection from "./ResponseSection.tsx"; +import { Inbox, UserRoundCheck } from "lucide-react"; +type AdminDashboardProps = { + activeSection: AdminSection; +}; + +export default function AdminDashboard({ activeSection }: AdminDashboardProps) { + const section = + activeSection === "members" + ? { + description: "Search membership records, check payment year, and find student details quickly.", + icon: UserRoundCheck, + label: "Members", + title: "Member Details", + } + : { + description: "Review contact form messages and reply to recent enquiries.", + icon: Inbox, + label: "Inbox", + title: "Contact Form Responses", + }; + + const Icon = section.icon; -export default function AdminDashboard() { return ( -
-

Admin Dashboard

-
-
Search Bar
-
Filters
-
-
-

Content

-
- -
- ) +
+
+
+
+ + + +
+
+

+ Admin Dashboard +

+ + {section.label} + +
+ +

+ {section.title} +

+ +

+ {section.description} +

+
+
+ + + {section.label} + +
+
+ + {activeSection === "members" && } + + {activeSection === "responses" && } +
+ ); } diff --git a/client/src/components/AdminComponents/AdminSidebar.tsx b/client/src/components/AdminComponents/AdminSidebar.tsx index 46e7a84..a1c52e4 100644 --- a/client/src/components/AdminComponents/AdminSidebar.tsx +++ b/client/src/components/AdminComponents/AdminSidebar.tsx @@ -1,12 +1,60 @@ +import type { AdminSection } from "../../pages/Admin.tsx"; +import { Inbox, UsersRound } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +type AdminSidebarProps = { + activeSection: AdminSection; + onSectionChange: (section: AdminSection) => void; +}; + +export default function AdminSidebar({ + activeSection, + onSectionChange, +}: AdminSidebarProps) { + const navItems: { icon: LucideIcon; label: string; value: AdminSection }[] = [ + { icon: UsersRound, label: "Members", value: "members" }, + { icon: Inbox, label: "Form Responses", value: "responses" }, + ]; -export default function AdminSidebar() { return ( -
-

Sidebar

-
Members
-
Form Responses
-
- ) + + ); } diff --git a/client/src/components/AdminComponents/DataTable.tsx b/client/src/components/AdminComponents/DataTable.tsx new file mode 100644 index 0000000..c2587ea --- /dev/null +++ b/client/src/components/AdminComponents/DataTable.tsx @@ -0,0 +1,325 @@ +import { useState } from "react"; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import type { Cell, ColumnDef, SortingState } from "@tanstack/react-table"; +import { + ArrowDown, + ArrowUp, + ChevronLeft, + ChevronRight, + ChevronsUpDown, + Search, + X, +} from "lucide-react"; + +type DataTableProps = { + columns: ColumnDef[]; + data: TData[]; + emptyDescription?: string; + emptyTitle: string; + error?: string | null; + getRowId?: (row: TData, index: number) => string; + isLoading?: boolean; + searchPlaceholder: string; +}; + +const pageSizeOptions = [10, 20, 50]; + +export default function DataTable({ + columns, + data, + emptyDescription, + emptyTitle, + error, + getRowId, + isLoading = false, + searchPlaceholder, +}: DataTableProps) { + const [globalFilter, setGlobalFilter] = useState(""); + const [sorting, setSorting] = useState([]); + + // TanStack Table exposes callback APIs that React Compiler cannot memoize safely. + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowId, + getSortedRowModel: getSortedRowModel(), + initialState: { + pagination: { + pageSize: pageSizeOptions[0], + }, + }, + onGlobalFilterChange: setGlobalFilter, + onSortingChange: setSorting, + state: { + globalFilter, + sorting, + }, + }); + + const visibleColumnCount = table.getVisibleLeafColumns().length; + const rows = table.getRowModel().rows; + const totalRows = table.getFilteredRowModel().rows.length; + const getColumnLabel = (cell: Cell) => { + const header = cell.column.columnDef.header; + return typeof header === "string" ? header : cell.column.id; + }; + + return ( +
+
+ + +
+ + {totalRows} {totalRows === 1 ? "record" : "records"} + + +
+
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const sorted = header.column.getIsSorted(); + const canSort = header.column.getCanSort(); + + return ( + + ); + })} + + ))} + + + {isLoading ? ( + Array.from({ length: 6 }).map((_, index) => ( + + {Array.from({ length: visibleColumnCount }).map( + (_cell, cellIndex) => ( + + ) + )} + + )) + ) : error ? ( + + + + ) : rows.length ? ( + rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {header.isPlaceholder ? null : canSort ? ( + + ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} +
+
+
+
+

+ Could not load records +

+

{error}

+
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+

+ {emptyTitle} +

+ {emptyDescription ? ( +

+ {emptyDescription} +

+ ) : null} +
+
+ +
+ {isLoading ? ( + Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+
+
+
+ )) + ) : error ? ( +
+

+ Could not load records +

+

{error}

+
+ ) : rows.length ? ( + rows.map((row) => ( +
+
+ {row.getVisibleCells().map((cell) => ( +
+
+ {getColumnLabel(cell)} +
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ))} +
+
+ )) + ) : ( +
+

+ {emptyTitle} +

+ {emptyDescription ? ( +

+ {emptyDescription} +

+ ) : null} +
+ )} +
+
+ +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {Math.max(table.getPageCount(), 1)} + +
+ + +
+
+
+ ); +} diff --git a/client/src/components/AdminComponents/MemberColumns.tsx b/client/src/components/AdminComponents/MemberColumns.tsx new file mode 100644 index 0000000..fab3fe0 --- /dev/null +++ b/client/src/components/AdminComponents/MemberColumns.tsx @@ -0,0 +1,178 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { Mail, Pencil, Phone, ShieldCheck } from "lucide-react"; + +export interface Member { + _id: string; + isAdmin: boolean; + latestMembershipYear: number | null; + email: string; + firstName: string; + lastName: string; + mobileNumber: string; + pronouns?: string; + university: string; + studentId: string; + upi: string; + yearOfStudy: number; + faculties: string[]; + createdAt?: string; +} + +export const getCurrentMembershipYear = () => { + const now = new Date(); + return now.getMonth() === 11 ? now.getFullYear() + 1 : now.getFullYear(); +}; + +const dateFormatter = new Intl.DateTimeFormat("en-NZ", { + dateStyle: "medium", +}); + +const formatDate = (value?: string) => { + if (!value) return "Not recorded"; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Not recorded"; + + return dateFormatter.format(date); +}; + +const currentMembershipYear = getCurrentMembershipYear(); + +export const getMemberColumns = ( + onViewEdit: (member: Member) => void +): ColumnDef[] => [ + { + accessorFn: (member) => `${member.firstName} ${member.lastName}`, + cell: ({ row }) => { + const member = row.original; + + return ( +
+
+ + {member.firstName} {member.lastName} + + {member.isAdmin ? ( + + + ) : null} +
+ {member.pronouns ? ( + {member.pronouns} + ) : null} +
+ ); + }, + header: "Member", + id: "name", + }, + { + accessorKey: "email", + cell: ({ row }) => ( + + ), + header: "Contact", + }, + { + accessorKey: "studentId", + cell: ({ row }) => ( +
+

+ {row.original.studentId} +

+

{row.original.upi}

+
+ ), + header: "Student", + }, + { + accessorKey: "university", + cell: ({ row }) => ( +
+

{row.original.university}

+

+ Year {row.original.yearOfStudy} +

+
+ ), + header: "Study", + }, + { + accessorFn: (member) => member.faculties.join(", "), + cell: ({ row }) => ( +
+ {row.original.faculties.map((faculty) => ( + + {faculty} + + ))} +
+ ), + header: "Faculties", + id: "faculties", + }, + { + accessorKey: "latestMembershipYear", + cell: ({ row }) => { + const year = row.original.latestMembershipYear; + const isCurrent = year === currentMembershipYear; + + return ( + + {year ?? "No year"} + + ); + }, + header: "Membership", + }, + { + accessorFn: (member) => + member.createdAt ? new Date(member.createdAt).getTime() : 0, + cell: ({ row }) => formatDate(row.original.createdAt), + header: "Joined", + id: "joined", + }, + { + cell: ({ row }) => ( + + ), + enableSorting: false, + header: "Actions", + id: "actions", + }, +]; diff --git a/client/src/components/AdminComponents/MemberDetailsModal.tsx b/client/src/components/AdminComponents/MemberDetailsModal.tsx new file mode 100644 index 0000000..254fa85 --- /dev/null +++ b/client/src/components/AdminComponents/MemberDetailsModal.tsx @@ -0,0 +1,351 @@ +import { useState } from "react"; +import { Trash2, X } from "lucide-react"; +import { deleteMember, updateMember } from "../../api/usersApi"; +import type { Member } from "./MemberColumns"; + +type MemberDetailsModalProps = { + member: Member; + onClose: () => void; + onDelete: (id: string) => void; + onSave: (member: Member) => void; +}; + +const FACULTIES = [ + "Arts", + "Business School", + "Creative Arts and Industries", + "Education and Social Work", + "Engineering", + "Law", + "Medical and Health Sciences", + "Science", +]; + +const inputClass = + "h-10 rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none transition focus:border-blue-medium focus:ring-2 focus:ring-yellow-dark/40"; + +const labelClass = "text-sm font-semibold text-slate-700"; + +const getErrorMessage = (error: unknown) => { + if (typeof error === "string") return error; + if (error instanceof Error) return error.message; + return "Something went wrong."; +}; + +export default function MemberDetailsModal({ + member, + onClose, + onDelete, + onSave, +}: MemberDetailsModalProps) { + const [deleteConfirmation, setDeleteConfirmation] = useState(""); + const [error, setError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [form, setForm] = useState({ + email: member.email, + faculties: member.faculties, + firstName: member.firstName, + isAdmin: member.isAdmin, + lastName: member.lastName, + latestMembershipYear: + member.latestMembershipYear === null ? "" : String(member.latestMembershipYear), + mobileNumber: member.mobileNumber, + pronouns: member.pronouns ?? "", + studentId: member.studentId, + university: member.university, + upi: member.upi, + yearOfStudy: String(member.yearOfStudy), + }); + + const updateField = (field: keyof typeof form, value: string | boolean) => { + setForm((current) => ({ + ...current, + [field]: value, + })); + }; + + const toggleFaculty = (faculty: string) => { + setForm((current) => ({ + ...current, + faculties: current.faculties.includes(faculty) + ? current.faculties.filter((item) => item !== faculty) + : [...current.faculties, faculty], + })); + }; + + const handleSave = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + + if (form.faculties.length === 0) { + setError("Select at least one faculty."); + return; + } + + setIsSaving(true); + + try { + const updatedMember = await updateMember(member._id, { + ...form, + latestMembershipYear: form.latestMembershipYear + ? Number(form.latestMembershipYear) + : null, + yearOfStudy: Number(form.yearOfStudy), + }); + + onSave(updatedMember); + onClose(); + } catch (requestError) { + setError(getErrorMessage(requestError)); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async () => { + if (deleteConfirmation !== "delete") return; + + setError(null); + setIsDeleting(true); + + try { + await deleteMember(member._id); + onDelete(member._id); + onClose(); + } catch (requestError) { + setError(getErrorMessage(requestError)); + } finally { + setIsDeleting(false); + } + }; + + return ( +
+
+
+
+

+ View/Edit Member +

+

+ {member.firstName} {member.lastName} +

+
+ +
+ +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ Faculties +
+ {FACULTIES.map((faculty) => ( + + ))} +
+
+ + + +
+ + + {showDeleteConfirm ? ( +
+

+ Are you sure you want to delete this member? Type{" "} + delete to confirm. +

+ setDeleteConfirmation(event.target.value)} + placeholder="Type delete" + value={deleteConfirmation} + /> + +
+ ) : null} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/client/src/components/AdminComponents/MembersSection.tsx b/client/src/components/AdminComponents/MembersSection.tsx new file mode 100644 index 0000000..e178497 --- /dev/null +++ b/client/src/components/AdminComponents/MembersSection.tsx @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { BadgeCheck, ShieldCheck, UsersRound } from "lucide-react"; +import { fetchMembers } from "../../api/usersApi"; +import DataTable from "./DataTable"; +import MemberDetailsModal from "./MemberDetailsModal"; +import { + getCurrentMembershipYear, + getMemberColumns, + type Member, +} from "./MemberColumns"; + +const getErrorMessage = (error: unknown) => { + if (typeof error === "string") return error; + if (error instanceof Error) return error.message; + return "Something went wrong."; +}; + +export default function MembersSection() { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [members, setMembers] = useState([]); + const [selectedMember, setSelectedMember] = useState(null); + + const loadMembers = useCallback(async () => { + setError(null); + setIsLoading(true); + + try { + const nextMembers = await fetchMembers(); + setMembers(nextMembers); + } catch (requestError) { + setError(getErrorMessage(requestError)); + } finally { + setIsLoading(false); + } + }, []); + + const columns = useMemo( + () => getMemberColumns((member) => setSelectedMember(member)), + [] + ); + + useEffect(() => { + void loadMembers(); + }, [loadMembers]); + + const stats = useMemo(() => { + const membershipYear = getCurrentMembershipYear(); + + return [ + { + icon: UsersRound, + label: "Total members", + value: members.length, + }, + { + icon: BadgeCheck, + label: `${membershipYear} members`, + value: members.filter( + (member) => member.latestMembershipYear === membershipYear + ).length, + }, + { + icon: ShieldCheck, + label: "Admins", + value: members.filter((member) => member.isAdmin).length, + }, + ]; + }, [members]); + + return ( +
+
+ {stats.map((stat) => { + const Icon = stat.icon; + + return ( +
+
+ + +

+ {stat.label} +

+
+

+ {stat.value} +

+
+ ); + })} +
+ + member._id} + isLoading={isLoading} + searchPlaceholder="Search members" + /> + + {selectedMember ? ( + setSelectedMember(null)} + onDelete={(id) => + setMembers((current) => current.filter((member) => member._id !== id)) + } + onSave={(updatedMember) => + setMembers((current) => + current.map((member) => + member._id === updatedMember._id ? updatedMember : member + ) + ) + } + /> + ) : null} +
+ ); +} diff --git a/client/src/components/AdminComponents/ResponseColumns.tsx b/client/src/components/AdminComponents/ResponseColumns.tsx new file mode 100644 index 0000000..b9f6589 --- /dev/null +++ b/client/src/components/AdminComponents/ResponseColumns.tsx @@ -0,0 +1,94 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { Mail, Pencil } from "lucide-react"; + +export interface ContactResponse { + _id: string; + name: string; + email: string; + message: string; + createdAt?: string; +} + +const getMessagePreview = (message: string) => { + const words = message.trim().split(/\s+/).filter(Boolean); + + if (words.length <= 18) return message; + + return `${words.slice(0, 18).join(" ")}...`; +}; + +const dateTimeFormatter = new Intl.DateTimeFormat("en-NZ", { + dateStyle: "medium", + timeStyle: "short", +}); + +const formatDateTime = (value?: string) => { + if (!value) return "Not recorded"; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Not recorded"; + + return dateTimeFormatter.format(date); +}; + +export const getResponseColumns = ( + onViewEdit: (response: ContactResponse) => void +): ColumnDef[] => [ + { + accessorKey: "name", + cell: ({ row }) => ( +
+

{row.original.name}

+ + +
+ ), + header: "Sender", + }, + { + accessorKey: "message", + cell: ({ row }) => ( +
+

+ {getMessagePreview(row.original.message)} +

+ {row.original.message.trim().split(/\s+/).filter(Boolean).length > 18 ? ( + + Longer message + + ) : null} +
+ ), + header: "Message", + }, + { + accessorFn: (response) => + response.createdAt ? new Date(response.createdAt).getTime() : 0, + cell: ({ row }) => ( + {formatDateTime(row.original.createdAt)} + ), + header: "Received", + id: "received", + }, + { + cell: ({ row }) => ( + + ), + enableSorting: false, + header: "Actions", + id: "actions", + }, +]; diff --git a/client/src/components/AdminComponents/ResponseDetailsModal.tsx b/client/src/components/AdminComponents/ResponseDetailsModal.tsx new file mode 100644 index 0000000..9a49915 --- /dev/null +++ b/client/src/components/AdminComponents/ResponseDetailsModal.tsx @@ -0,0 +1,198 @@ +import { useState } from "react"; +import { Trash2, X } from "lucide-react"; +import { deleteContact, updateContact } from "../../api/contactApi"; +import type { ContactResponse } from "./ResponseColumns"; + +type ResponseDetailsModalProps = { + onClose: () => void; + onDelete: (id: string) => void; + onSave: (response: ContactResponse) => void; + response: ContactResponse; +}; + +const inputClass = + "h-10 rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none transition focus:border-blue-medium focus:ring-2 focus:ring-yellow-dark/40"; + +const labelClass = "text-sm font-semibold text-slate-700"; + +const getErrorMessage = (error: unknown) => { + if (typeof error === "string") return error; + if (error instanceof Error) return error.message; + return "Something went wrong."; +}; + +export default function ResponseDetailsModal({ + onClose, + onDelete, + onSave, + response, +}: ResponseDetailsModalProps) { + const [deleteConfirmation, setDeleteConfirmation] = useState(""); + const [error, setError] = useState(null); + const [form, setForm] = useState({ + email: response.email, + message: response.message, + name: response.name, + }); + const [isDeleting, setIsDeleting] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const updateField = (field: keyof typeof form, value: string) => { + setForm((current) => ({ + ...current, + [field]: value, + })); + }; + + const handleSave = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setIsSaving(true); + + try { + const updatedResponse = await updateContact(response._id, form); + onSave(updatedResponse); + onClose(); + } catch (requestError) { + setError(getErrorMessage(requestError)); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async () => { + if (deleteConfirmation !== "delete") return; + + setError(null); + setIsDeleting(true); + + try { + await deleteContact(response._id); + onDelete(response._id); + onClose(); + } catch (requestError) { + setError(getErrorMessage(requestError)); + } finally { + setIsDeleting(false); + } + }; + + return ( +
+
+
+
+

+ View/Edit Response +

+

+ {response.name} +

+
+ +
+ +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ + + +
+ +