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
+
+
);
}
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) => (
+
+ ))}
+
+
+
+
+ ))}
+
+
+
+ );
+}
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 (
+