From b88363e9ff17ca02922ee6e8fea6360c4a897d46 Mon Sep 17 00:00:00 2001 From: roslyn-maloney Date: Tue, 2 Jun 2026 21:06:18 -0400 Subject: [PATCH] front end implemetation for admin settings and sidebar --- apps/app-portal/package.json | 4 +- .../app-portal/src/app/(admin)/admin/page.tsx | 29 +- .../src/app/(admin)/admin/settings/page.tsx | 41 ++- apps/app-portal/src/app/(admin)/layout.tsx | 42 ++- apps/app-portal/src/app/(landing)/page.tsx | 3 +- .../src/components/admin/AdminSidebar.tsx | 102 ++++++- .../src/components/admin/DateControl.tsx | 13 - .../src/components/admin/DateControls.tsx | 106 +++++++ .../src/components/admin/FormConfigEditor.tsx | 51 +++- .../src/components/admin/QuestionRow.tsx | 50 ++++ .../src/components/admin/QuestionsList.tsx | 200 +++++++++++++ .../components/admin/ShowDecisionToggle.tsx | 56 +++- .../app-portal/src/components/ui/calendar.tsx | 228 +++++++++++++++ apps/app-portal/src/components/ui/popover.tsx | 38 +++ apps/app-portal/src/components/ui/switch.tsx | 31 ++ apps/app-portal/src/lib/application/types.ts | 2 +- package-lock.json | 264 ++++++++++++++++-- package.json | 7 + yarn.lock | 192 ++++++++++++- 19 files changed, 1358 insertions(+), 101 deletions(-) delete mode 100644 apps/app-portal/src/components/admin/DateControl.tsx create mode 100644 apps/app-portal/src/components/admin/DateControls.tsx create mode 100644 apps/app-portal/src/components/admin/QuestionRow.tsx create mode 100644 apps/app-portal/src/components/admin/QuestionsList.tsx create mode 100644 apps/app-portal/src/components/ui/calendar.tsx create mode 100644 apps/app-portal/src/components/ui/popover.tsx create mode 100644 apps/app-portal/src/components/ui/switch.tsx diff --git a/apps/app-portal/package.json b/apps/app-portal/package.json index d54b536e..a6b236e7 100644 --- a/apps/app-portal/package.json +++ b/apps/app-portal/package.json @@ -18,14 +18,16 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@repo/ui": "*", - "@tanstack/react-table": "^8.20.5", "@repo/util": "*", + "@tanstack/react-table": "^8.20.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "lucide-react": "^1.14.0", "mongodb": "^7.2.0", "next": "^14.2.3", "react": "^18.2.0", + "react-day-picker": "^10.0.1", "react-dom": "^18.2.0", "react-hook-form": "^7.75.0", "recharts": "3.8.0", diff --git a/apps/app-portal/src/app/(admin)/admin/page.tsx b/apps/app-portal/src/app/(admin)/admin/page.tsx index 7a10e74e..ff8a0e20 100644 --- a/apps/app-portal/src/app/(admin)/admin/page.tsx +++ b/apps/app-portal/src/app/(admin)/admin/page.tsx @@ -1,13 +1,24 @@ import React from "react"; +import Link from "next/link"; + const tiles = [ { - title: "Settings", + title: "Config Portal Settings", + description: + "Manage site configuration, application preferences, and administrative options.", + link: "/admin/settings", }, { title: "Applicants", + description: + "Review applicant information, track application status, and manage submissions.", + link: "/admin/applicants", }, { title: "Stats", + description: + "View platform metrics, application trends, and key performance statistics.", + link: "/admin/stats", }, ]; @@ -20,12 +31,22 @@ export default function AdminPage() {

Welcome to the admin portal.

-
+
{tiles.map((t) => ( -
+

{t.title}

+

{t.description}

-
Open →
+ + Open +
))}
diff --git a/apps/app-portal/src/app/(admin)/admin/settings/page.tsx b/apps/app-portal/src/app/(admin)/admin/settings/page.tsx index b85845c5..fd829300 100644 --- a/apps/app-portal/src/app/(admin)/admin/settings/page.tsx +++ b/apps/app-portal/src/app/(admin)/admin/settings/page.tsx @@ -1,14 +1,47 @@ import React from "react"; import ShowDecisionToggle from "@/components/admin/ShowDecisionToggle"; -import DateControls from "@/components/admin/DateControl"; +import DateControls from "@/components/admin/DateControls"; import FormConfigEditor from "@/components/admin/FormConfigEditor"; export default function Page() { return (
- - - +

Configure Portal Settings

+ +
+
+

Dates

+
+ + + + + +
+
+ +
+

Display

+ +
+ +
+

Form Configuration

+ +
+
); } diff --git a/apps/app-portal/src/app/(admin)/layout.tsx b/apps/app-portal/src/app/(admin)/layout.tsx index ebb71369..7922c9a9 100644 --- a/apps/app-portal/src/app/(admin)/layout.tsx +++ b/apps/app-portal/src/app/(admin)/layout.tsx @@ -1,25 +1,37 @@ import React from "react"; -import Link from "next/link"; +import AdminSidebar from "@/components/admin/AdminSidebar"; +import { redirect } from "next/navigation"; + +async function requireAdmin(): Promise { + return true; +} + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const isAdmin = await requireAdmin(); + + if (!isAdmin) { + redirect("/"); + } -export default function AdminLayout() { return (
- +
+
+
+

Admin Portal

+
-
-
-

Admin Portal

+
Place user menu here
-
+ +
{children}
+
); } diff --git a/apps/app-portal/src/app/(landing)/page.tsx b/apps/app-portal/src/app/(landing)/page.tsx index 0756f626..1c9517cf 100644 --- a/apps/app-portal/src/app/(landing)/page.tsx +++ b/apps/app-portal/src/app/(landing)/page.tsx @@ -1,6 +1,7 @@ import React from "react"; +// import Layout from "../(admin)/layout"; export default function Page(): JSX.Element { - return <>poop; + return
{/* */}
; } //TODO: update to redirect authed users to /dashboard diff --git a/apps/app-portal/src/components/admin/AdminSidebar.tsx b/apps/app-portal/src/components/admin/AdminSidebar.tsx index c5508f6c..af744804 100644 --- a/apps/app-portal/src/components/admin/AdminSidebar.tsx +++ b/apps/app-portal/src/components/admin/AdminSidebar.tsx @@ -1,5 +1,105 @@ +"use client"; + import React from "react"; +import Link from "next/link"; +import HackBeanpotLogo from "../../../../../packages/ui/src/Logos/HackBeanpotLogo"; +import { usePathname } from "next/navigation"; +import useDevice from "@repo/util/hooks/useDevice"; +import { MenuIcon } from "lucide-react"; export default function AdminSidebar() { - return
AdminSidebar here
; + const pathname = usePathname(); + const { isMobile } = useDevice(); + const [open, setOpen] = React.useState(false); + + const Active = (href: string) => { + const isActive = + href === "/admin" ? pathname === "/admin" : pathname.startsWith(href); + + return isActive + ? { + backgroundColor: "#1890ff", + color: "white", + fontWeight: "bold" as const, + } + : { + color: "#808080", + }; + }; + + const NavLinks = () => ( + <> + + + + + + Admin + + + + Portal Settings + + + + Applicants + + + + Stats + + + ); + + if (isMobile) { + return ( + <> + + + {open && ( + <> +
setOpen(false)} + /> + + + + )} + + ); + } + + return ( +
+ +
+ ); } diff --git a/apps/app-portal/src/components/admin/DateControl.tsx b/apps/app-portal/src/components/admin/DateControl.tsx deleted file mode 100644 index 0becc7a5..00000000 --- a/apps/app-portal/src/components/admin/DateControl.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; - -export default function DateControls() { - return ( -
-

Application Deadline

- - - - -
- ); -} diff --git a/apps/app-portal/src/components/admin/DateControls.tsx b/apps/app-portal/src/components/admin/DateControls.tsx new file mode 100644 index 00000000..73f5dc91 --- /dev/null +++ b/apps/app-portal/src/components/admin/DateControls.tsx @@ -0,0 +1,106 @@ +"use client"; + +import * as React from "react"; +import { format } from "date-fns"; +import { Calendar } from "@/components/ui/calendar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { toast } from "sonner"; + +type Props = { + label: string; + endpoint: string; + initialValue?: string; +}; + +export default function DateControls({ label, endpoint, initialValue }: Props) { + const initialDate = initialValue ? new Date(initialValue) : undefined; + + const [date, setDate] = React.useState(initialDate); + const [time, setTime] = React.useState( + initialDate + ? `${String(initialDate.getHours()).padStart(2, "0")}:${String( + initialDate.getMinutes(), + ).padStart(2, "0")}` + : "", + ); + + const [loading, setLoading] = React.useState(false); + + async function handleSave() { + if (!date || !time) return; + + setLoading(true); + + const previousDate = date; + const previousTime = time; + + try { + const [hours, minutes] = time.split(":").map(Number); + + const combined = new Date(date); + combined.setHours(hours); + combined.setMinutes(minutes); + + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ date: combined.toISOString() }), + }); + + if (!res.ok) throw new Error("Failed to save"); + + toast.success(`${label} saved`); + } catch { + setDate(previousDate); + setTime(previousTime); + toast.error(`Failed to save ${label}`); + } finally { + setLoading(false); + } + } + + return ( +
+
{label}
+ +
+ + + + + + + + + + + setTime(e.target.value)} + /> + + +
+
+ ); +} diff --git a/apps/app-portal/src/components/admin/FormConfigEditor.tsx b/apps/app-portal/src/components/admin/FormConfigEditor.tsx index 4f56b78d..8cacf369 100644 --- a/apps/app-portal/src/components/admin/FormConfigEditor.tsx +++ b/apps/app-portal/src/components/admin/FormConfigEditor.tsx @@ -1,17 +1,52 @@ +"use client"; + import React from "react"; +import type { FormSection } from "@/lib/application/types"; +import QuestionsList from "./QuestionsList"; export default function FormConfigEditor() { + const [sections, setSections] = React.useState([ + { + id: "section-1", + title: "General Info", + questions: [ + { + id: "q1", + label: "Full Name", + type: "short_text", + required: true, + }, + { + id: "q2", + label: "Why join?", + type: "long_text", + required: true, + }, + ], + }, + ]); + + async function handleSave() { + await fetch("/api/v1/form-config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sections }), + }); + } + return (
-
-

Form Questions

- - -
+ -
- Add Question here - +
+ +
); diff --git a/apps/app-portal/src/components/admin/QuestionRow.tsx b/apps/app-portal/src/components/admin/QuestionRow.tsx new file mode 100644 index 00000000..6b4b9db2 --- /dev/null +++ b/apps/app-portal/src/components/admin/QuestionRow.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React from "react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +import type { Question } from "@/lib/application/types"; + +export default function QuestionRow({ question }: { question: Question }) { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: question.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+ ☰ +
+ +
+

{question.label}

+

+ {question.type} {question.required ? "• required" : ""} +

+
+ +
+ + +
+
+ ); +} diff --git a/apps/app-portal/src/components/admin/QuestionsList.tsx b/apps/app-portal/src/components/admin/QuestionsList.tsx new file mode 100644 index 00000000..9479b697 --- /dev/null +++ b/apps/app-portal/src/components/admin/QuestionsList.tsx @@ -0,0 +1,200 @@ +"use client"; + +import React from "react"; + +import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; + +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; + +import QuestionRow from "./QuestionRow"; + +import type { FormSection, QuestionType } from "@/lib/application/types"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; + +type NewQuestion = { + id: string; + label: string; + type: QuestionType; + required: boolean; +}; + +export default function QuestionsList({ + sections, + setSections, +}: { + sections: FormSection[]; + setSections: React.Dispatch>; +}) { + const [open, setOpen] = React.useState(false); + const [activeSectionId, setActiveSectionId] = React.useState( + null, + ); + + const [form, setForm] = React.useState({ + id: "", + label: "", + type: "short_text", + required: false, + }); + + function handleDragEnd(event: DragEndEvent, sectionId: string) { + const { active, over } = event; + + if (!over || active.id === over.id) return; + + setSections((prev) => + prev.map((section) => { + if (section.id !== sectionId) return section; + + const oldIndex = section.questions.findIndex((q) => q.id === active.id); + const newIndex = section.questions.findIndex((q) => q.id === over.id); + + return { + ...section, + questions: arrayMove(section.questions, oldIndex, newIndex), + }; + }), + ); + } + + function openDialog(sectionId: string) { + setActiveSectionId(sectionId); + setForm({ + id: "", + label: "", + type: "short_text", + required: false, + }); + setOpen(true); + } + + function handleAddQuestion() { + if (!activeSectionId) return; + + const newQuestion = { + id: + form.id || + globalThis.crypto?.randomUUID?.() || + `${Date.now()}-${Math.random().toString(36).slice(2)}`, + label: form.label, + type: form.type, + required: form.required, + }; + + setSections((prev) => + prev.map((section) => + section.id === activeSectionId + ? { + ...section, + questions: [...section.questions, newQuestion], + } + : section, + ), + ); + + setOpen(false); + } + + return ( +
+ {sections.map((section) => ( +
+
+

{section.title}

+ + +
+ + handleDragEnd(event, section.id)} + > + q.id)} + strategy={verticalListSortingStrategy} + > +
+ {section.questions.map((q) => ( + + ))} +
+
+
+
+ ))} + + + + + Add Question + + +
+ setForm({ ...form, id: e.target.value })} + /> + + setForm({ ...form, label: e.target.value })} + /> + + + +
+ setForm({ ...form, required: !!v })} + /> + Required +
+ + +
+
+
+
+ ); +} diff --git a/apps/app-portal/src/components/admin/ShowDecisionToggle.tsx b/apps/app-portal/src/components/admin/ShowDecisionToggle.tsx index 7fe12bb1..76b293e8 100644 --- a/apps/app-portal/src/components/admin/ShowDecisionToggle.tsx +++ b/apps/app-portal/src/components/admin/ShowDecisionToggle.tsx @@ -1,22 +1,52 @@ "use client"; -import { useState } from "react"; -import React from "react"; +import * as React from "react"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; export default function ShowDecisionToggle() { - const [enabled, setEnabled] = useState(false); + const [enabled, setEnabled] = React.useState(false); + const [loading, setLoading] = React.useState(false); + + async function updateSetting(nextValue: boolean) { + setLoading(true); + + const previous = enabled; + setEnabled(nextValue); + + try { + const res = await fetch("/api/v1/show-decision", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: nextValue }), + }); + + if (!res.ok) { + throw new Error("Request failed"); + } + } catch (err) { + setEnabled(previous); + console.error(err); + } finally { + setLoading(false); + } + } return ( -
-

Show Decision

- +
+ + +
); } diff --git a/apps/app-portal/src/components/ui/calendar.tsx b/apps/app-portal/src/components/ui/calendar.tsx new file mode 100644 index 00000000..a395a4ee --- /dev/null +++ b/apps/app-portal/src/components/ui/calendar.tsx @@ -0,0 +1,228 @@ +/* eslint-disable react/prop-types */ +"use client"; + +import * as React from "react"; +import { + DayPicker, + getDefaultClassNames, + type DayButton, + type Locale, +} from "react-day-picker"; + +import { cn } from "@/lib/utils"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronDownIcon, +} from "lucide-react"; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + 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} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString(locale?.code, { 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 }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous, + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none 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( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root, + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown, + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label, + ), + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", + 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-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number, + ), + day: cn( + "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day, + ), + range_start: cn( + "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", + defaultClassNames.range_start, + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", + defaultClassNames.range_end, + ), + today: cn( + "rounded-(--cell-radius) bg-muted text-foreground 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: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ); + }, + ...components, + }} + {...props} + /> + ); +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( +