diff --git a/client/package-lock.json b/client/package-lock.json index 4e2c7c6..058251a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,8 +10,9 @@ "dependencies": { "@stripe/react-stripe-js": "^6.3.0", "@stripe/stripe-js": "^9.5.0", - "axios": "^1.16.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", "react-dom": "^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", @@ -5655,12 +5689,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 +5706,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", diff --git a/client/package.json b/client/package.json index d91e0ea..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", diff --git a/client/src/api/contactApi.ts b/client/src/api/contactApi.ts index af9c866..012f357 100644 --- a/client/src/api/contactApi.ts +++ b/client/src/api/contactApi.ts @@ -13,3 +13,20 @@ 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 new file mode 100644 index 0000000..3716528 --- /dev/null +++ b/client/src/components/AdminComponents/AdminDashboard.tsx @@ -0,0 +1,70 @@ +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; + + return ( +
+
+
+
+ + + +
+
+

+ 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 new file mode 100644 index 0000000..65de0f2 --- /dev/null +++ b/client/src/components/AdminComponents/AdminSidebar.tsx @@ -0,0 +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" }, + ]; + + return ( + + ); +} diff --git a/client/src/components/AdminComponents/DataTable.tsx b/client/src/components/AdminComponents/DataTable.tsx new file mode 100644 index 0000000..2bfd368 --- /dev/null +++ b/client/src/components/AdminComponents/DataTable.tsx @@ -0,0 +1,387 @@ +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, + ChevronDown, + 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 = [3, 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; + }; + const getColumnWidthClass = (columnId: string) => { + switch (columnId) { + case "name": + return "w-[17%]"; + case "email": + return "w-[19%]"; + case "studentStudy": + return "w-[24%]"; + case "faculties": + return "w-[15%]"; + case "latestMembershipYear": + return "w-[13%]"; + case "actions": + return "w-[12%]"; + case "message": + return "w-[50%]"; + case "received": + return "w-[16%]"; + default: + return ""; + } + }; + const isCenteredColumn = (columnId: string) => + columnId === "actions" || columnId === "latestMembershipYear"; + + 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..d7004b8 --- /dev/null +++ b/client/src/components/AdminComponents/MemberColumns.tsx @@ -0,0 +1,188 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { Mail, Pencil, Phone, ShieldCheck } from "lucide-react"; + +export interface Member { + _id: string; + googleUid: 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 currentMembershipYear = getCurrentMembershipYear(); + +export const getMemberColumns = ( + onViewEdit: (member: Member) => void +): ColumnDef[] => [ + { + accessorFn: (member) => `${member.firstName} ${member.lastName}`, + cell: ({ row }) => { + const member = row.original; + + return ( +
+ {member.isAdmin ? ( + + + ) : null} + + {member.firstName} {member.lastName} + + {member.pronouns ? ( + + {member.pronouns} + + ) : null} +
+ ); + }, + header: "Member", + id: "name", + size: 190, + }, + { + accessorKey: "email", + cell: ({ row }) => ( + + ), + header: "Contact", + size: 220, + }, + { + accessorFn: (member) => + `${member.studentId} ${member.upi} ${member.university} ${member.yearOfStudy}`, + cell: ({ row }) => ( +
+
+ + {row.original.studentId} + + + {row.original.upi} + +
+ + {row.original.university} + + + Year {row.original.yearOfStudy} + +
+ ), + header: "Student & Study", + id: "studentStudy", + size: 230, + }, + { + accessorFn: (member) => member.faculties.join(", "), + cell: ({ row }) => ( +
+ {row.original.faculties.slice(0, 2).map((faculty) => ( + + {faculty} + + ))} + {row.original.faculties.length > 2 ? ( + + +{row.original.faculties.length - 2} + + ) : null} +
+ ), + header: "Faculties", + id: "faculties", + size: 190, + }, + { + accessorKey: "latestMembershipYear", + cell: ({ row }) => { + const year = row.original.latestMembershipYear; + const isCurrent = year === currentMembershipYear; + + return ( +
+ + {year ?? "No year"} + +
+ ); + }, + header: "Membership", + size: 135, + }, + { + cell: ({ row }) => ( + + ), + enableSorting: false, + header: "Actions", + id: "actions", + size: 110, + }, +]; diff --git a/client/src/components/AdminComponents/MemberDetailsModal.tsx b/client/src/components/AdminComponents/MemberDetailsModal.tsx new file mode 100644 index 0000000..319b1da --- /dev/null +++ b/client/src/components/AdminComponents/MemberDetailsModal.tsx @@ -0,0 +1,467 @@ +import { useRef, useState } from "react"; +import { Trash2, X } from "lucide-react"; +import { deleteMember, updateMember } from "../../api/usersApi"; +import type { Member } from "./MemberColumns"; + +type MemberDetailsModalProps = { + adminCount: number; + isCurrentUser: boolean; + 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({ + adminCount, + isCurrentUser, + member, + onClose, + onDelete, + onSave, +}: MemberDetailsModalProps) { + const formRef = useRef(null); + 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 isOnlyAdmin = member.isAdmin && adminCount <= 1; + const adminChangeBlockedReason = isCurrentUser + ? "You cannot remove admin access from your own account." + : isOnlyAdmin + ? "At least one admin account must remain." + : null; + const deleteBlockedReason = isCurrentUser + ? "You cannot delete your own admin account." + : isOnlyAdmin + ? "At least one admin account must remain." + : null; + + 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.isAdmin && adminChangeBlockedReason) { + setError(adminChangeBlockedReason); + return; + } + + 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 (deleteBlockedReason) { + setError(deleteBlockedReason); + return; + } + + 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); + } + }; + + const handleManualSave = () => { + formRef.current?.requestSubmit(); + }; + + const closeDeleteConfirm = () => { + setDeleteConfirmation(""); + setShowDeleteConfirm(false); + }; + const isDeleteDisabled = + Boolean(deleteBlockedReason) || + (showDeleteConfirm && (deleteConfirmation !== "delete" || isDeleting)); + + return ( +
+
+ + +
+
+ {error ? ( +

+ {error} +

+ ) : null} + +
+
+

+ Contact details +

+

+ Core identity and contact information. +

+
+ +
+ + + + + + + + + +
+
+ +
+
+

+ Academic details +

+

+ Student identifiers, study year, and faculties. +

+
+ +
+ + + + + + + + + +
+ +
+ Faculties +
+ {FACULTIES.map((faculty) => ( + + ))} +
+
+
+ +
+

Access

+ + {adminChangeBlockedReason ? ( +

+ {adminChangeBlockedReason} +

+ ) : null} +
+
+
+ + {showDeleteConfirm ? ( +
+
+
+

+ Delete this member? +

+

+ This cannot be undone. Type delete to enable + the delete button. +

+
+ + +
+
+ ) : null} + +
+ +
+ + {deleteBlockedReason ? ( +

+ {deleteBlockedReason} +

+ ) : null} +
+
+
+
+ ); +} diff --git a/client/src/components/AdminComponents/MembersSection.tsx b/client/src/components/AdminComponents/MembersSection.tsx new file mode 100644 index 0000000..1970c9a --- /dev/null +++ b/client/src/components/AdminComponents/MembersSection.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { BadgeCheck, ShieldCheck, UsersRound } from "lucide-react"; +import { useAuth } from "../../auth/useAuth"; +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 { user } = useAuth(); + 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 adminCount = useMemo( + () => members.filter((member) => member.isAdmin).length, + [members] + ); + + 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: adminCount, + }, + ]; + }, [adminCount, 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..08722b2 --- /dev/null +++ b/client/src/components/AdminComponents/ResponseColumns.tsx @@ -0,0 +1,106 @@ +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", + size: 230, + }, + { + accessorKey: "message", + cell: ({ row }) => ( +
+

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

+ {row.original.message.trim().split(/\s+/).filter(Boolean).length > + 18 ? ( + + Longer message + + ) : null} +
+ ), + header: "Message", + size: 520, + }, + { + accessorFn: (response) => + response.createdAt ? new Date(response.createdAt).getTime() : 0, + cell: ({ row }) => ( + + {formatDateTime(row.original.createdAt)} + + ), + header: "Received", + id: "received", + size: 180, + }, + { + cell: ({ row }) => ( + + ), + enableSorting: false, + header: "Actions", + id: "actions", + size: 110, + }, +]; diff --git a/client/src/components/AdminComponents/ResponseDetailsModal.tsx b/client/src/components/AdminComponents/ResponseDetailsModal.tsx new file mode 100644 index 0000000..83f0686 --- /dev/null +++ b/client/src/components/AdminComponents/ResponseDetailsModal.tsx @@ -0,0 +1,244 @@ +import { useRef, 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 formRef = useRef(null); + 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); + } + }; + + const handleManualSave = () => { + formRef.current?.requestSubmit(); + }; + + const closeDeleteConfirm = () => { + setDeleteConfirmation(""); + setShowDeleteConfirm(false); + }; + + return ( +
+
+ + +
+
+ {error ? ( +

+ {error} +

+ ) : null} + +
+
+

+ Sender details +

+

+ Contact information attached to this response. +

+
+ +
+ + + +
+
+ +
+
+

Message

+

+ Full contact form text. +

+
+ +