Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
2298d13
Bump the production-dependencies group with 5 updates
dependabot[bot] Feb 21, 2026
3af997d
Bump the development-dependencies group with 2 updates
dependabot[bot] Feb 21, 2026
13e01fd
Bump eslint from 9.39.2 to 10.0.1
dependabot[bot] Feb 21, 2026
702ab82
Initial plan
Copilot Feb 22, 2026
7e66e84
Merge pull request #23 from b-at-neu/dependabot/npm_and_yarn/eslint-1…
b-at-neu Feb 22, 2026
91e1256
Merge pull request #20 from b-at-neu/dependabot/npm_and_yarn/producti…
b-at-neu Feb 22, 2026
509d6c6
Merge pull request #21 from b-at-neu/dependabot/npm_and_yarn/developm…
b-at-neu Feb 22, 2026
64759c5
#26 add cycle model and migrate existing data
Copilot Feb 22, 2026
6114982
#26 use getActiveCycle helper across all create paths
Copilot Feb 23, 2026
ea31045
#26 use getActiveCycle in settings data and action
Copilot Feb 23, 2026
77b9ca9
Merge branch 'dev' of https://github.com/b-at-neu/senate-path into co…
b-at-neu Feb 23, 2026
98c91ae
Merge branch 'copilot/create-cycle-model-structure' of https://github…
b-at-neu Feb 23, 2026
a1466d1
Merge pull request #33 from b-at-neu/copilot/create-cycle-model-struc…
b-at-neu Feb 23, 2026
f0a3c55
Initial plan
Copilot Feb 23, 2026
7bf57c2
Initial plan
Copilot Feb 23, 2026
9108ae8
Initial plan
Copilot Feb 23, 2026
6dde72b
#28 add cycle archive/management page at /admin/cycles
Copilot Feb 23, 2026
1b2929e
#32 implement settings per cycle with cycle management ui
Copilot Feb 23, 2026
a16b17a
#31 add active cycle auto-filtering to admin dashboard
Copilot Feb 23, 2026
1659f01
#32 remove cycle management (separate ticket)
Copilot Feb 23, 2026
3e69df1
#31 remove cycles archive page and view all cycles link
Copilot Feb 23, 2026
8116744
#28 address review: colocate components, use react hook form, fix vie…
Copilot Feb 23, 2026
e55fb1d
#32 throw error when cycle has no settings instead of redirecting
Copilot Feb 23, 2026
cd801a6
#31 remove viewing label from cycle name in admin header
Copilot Feb 23, 2026
ba91dc2
#28 address review round 2: alertdialog, error type helper, revalidat…
Copilot Feb 23, 2026
74b218e
#28 fix build error: move isActionError out of use server file into l…
Copilot Feb 23, 2026
6261077
Merge pull request #35 from b-at-neu/copilot/add-admin-cycles-page
b-at-neu Feb 23, 2026
7b53deb
Merge branch 'dev' into copilot/update-settings-per-cycle
b-at-neu Feb 23, 2026
a3351cc
Merge branch 'dev' into copilot/update-admin-dashboard-filter
b-at-neu Feb 23, 2026
d47f2e3
#32 add cycle detail page at /admin/cycles/[id] showing per-cycle set…
Copilot Feb 23, 2026
912a5a7
Initial plan
Copilot Feb 23, 2026
1d1ff6e
Merge pull request #38 from b-at-neu/copilot/update-admin-dashboard-f…
b-at-neu Feb 23, 2026
21535c4
#32 revert settings page to active cycle only, remove cycle detail page
Copilot Feb 23, 2026
873758e
#32 guard updateSettings to only update active cycle settings
Copilot Feb 23, 2026
3030799
#29 add cycles/[id] route with tabbed admin dashboard scoped to cycle
Copilot Feb 23, 2026
941a435
Merge pull request #37 from b-at-neu/copilot/update-settings-per-cycle
b-at-neu Feb 23, 2026
3dd819b
Merge branch 'dev' of https://github.com/b-at-neu/senate-path into co…
b-at-neu Feb 23, 2026
02d4c25
#29 remove duplicate getSettingsByCycleId introduced by merge
Copilot Feb 23, 2026
3973829
#29 fix server function passed as closure to client component using bind
Copilot Feb 23, 2026
60748b9
#29 address review feedback: remove set active button, read-only sett…
Copilot Feb 23, 2026
7cfbf98
#29 make tab triggers equal width with flex-1 and add green hover hig…
Copilot Feb 23, 2026
5fb020e
#29 remove green hover highlight from tab triggers
Copilot Feb 23, 2026
24885b7
#29 remove green row highlight from cycles archive table
Copilot Feb 23, 2026
811a613
Merge pull request #39 from b-at-neu/copilot/create-cycle-view-route
b-at-neu Feb 23, 2026
cf6f98a
Bump version from 1.4.1 to 1.5.0
b-at-neu Feb 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions app/admin/cycles/[id]/cycle-dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
'use client';

import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import AdminDashboard from '@/components/AdminDashboard';
import NominationsManager from '@/components/NominationsManager';
import SettingsForm from '@/app/admin/settings/settings-form';
import { EndorsementsView } from './endorsements-view';
import { Settings } from '@/lib/data/settings';
import { Endorsement, Nomination, Application } from '@prisma/client';

type NominationWithCommunity = Nomination & {
communityConstituency: { name: string } | null;
};

type ApplicationWithCount = Application & {
nominationCount: number;
endorsementCount: number;
communityConstituency: { name: string } | null;
};

type ApplicationWithNominations = Application & {
nominations: NominationWithCommunity[];
endorsements: Endorsement[];
nominationCount: number;
communityConstituency: { name: string } | null;
};

interface CycleDashboardCycle {
id: string;
name: string;
isActive: boolean;
}

interface CycleDashboardProps {
cycle: CycleDashboardCycle;
applications: ApplicationWithCount[];
getApplicationDetails: (id: string) => Promise<ApplicationWithNominations | null>;
nominations: NominationWithCommunity[];
endorsements: Endorsement[];
settings: Settings | null;
}

export function CycleDashboard({
cycle,
applications,
getApplicationDetails,
nominations,
endorsements,
settings,
}: CycleDashboardProps) {
const defaultSettings: Settings = settings ?? {
id: '',
requiredNominations: 15,
maxCommunityNominations: 7,
endorsementRequired: false,
endorsementsOpen: true,
applicationDeadline: null,
applicationsOpen: true,
nominationsOpen: true,
customMessage: null,
cycleId: cycle.id,
createdAt: new Date(),
updatedAt: new Date(),
};

return (
<div className="container max-w-[1600px] mx-auto py-3 sm:py-6 px-3 sm:px-4">
{/* Header */}
<div className="mb-6">
<Link
href="/admin/cycles"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Cycle Archive
</Link>
<div className="flex items-center gap-3">
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold">{cycle.name}</h1>
<Badge variant={cycle.isActive ? 'success' : 'secondary'}>
{cycle.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
</div>

{/* Tabs */}
<Tabs defaultValue="applications">
<TabsList className="mb-6 w-full">
<TabsTrigger value="applications">Applications</TabsTrigger>
<TabsTrigger value="nominations">Nominations</TabsTrigger>
<TabsTrigger value="endorsements">Endorsements</TabsTrigger>
{settings !== null && (
<TabsTrigger value="settings">Settings</TabsTrigger>
)}
</TabsList>

<TabsContent value="applications">
<AdminDashboard
applications={applications}
getApplicationDetails={getApplicationDetails}
settings={defaultSettings}
/>
</TabsContent>

<TabsContent value="nominations">
<NominationsManager
nominations={nominations}
settings={defaultSettings}
/>
</TabsContent>

<TabsContent value="endorsements">
<EndorsementsView endorsements={endorsements} />
</TabsContent>

{settings !== null && (
<TabsContent value="settings">
<SettingsForm settings={settings} readOnly />
</TabsContent>
)}
</Tabs>
</div>
);
}
76 changes: 76 additions & 0 deletions app/admin/cycles/[id]/endorsements-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client';

import { Endorsement } from '@prisma/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';

interface EndorsementsViewProps {
endorsements: Endorsement[];
}

export function EndorsementsView({ endorsements }: EndorsementsViewProps) {
return (
<div>
<Card>
<CardHeader>
<CardTitle>Endorsements ({endorsements.length})</CardTitle>
</CardHeader>
<CardContent>
{endorsements.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No endorsements for this cycle.
</p>
) : (
<div className="overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Applicant</TableHead>
<TableHead>Endorser</TableHead>
<TableHead>Endorser Email</TableHead>
<TableHead>Defining Traits</TableHead>
<TableHead>Leadership Qualities</TableHead>
<TableHead>Areas for Development</TableHead>
<TableHead>Submitted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{endorsements.map((endorsement) => (
<TableRow key={endorsement.id}>
<TableCell className="font-medium">
{endorsement.applicantName}
</TableCell>
<TableCell>{endorsement.endorserName}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{endorsement.endorserEmail}
</TableCell>
<TableCell className="max-w-[200px] truncate text-sm">
{endorsement.definingTraits}
</TableCell>
<TableCell className="max-w-[200px] truncate text-sm">
{endorsement.leadershipQualities}
</TableCell>
<TableCell className="max-w-[200px] truncate text-sm">
{endorsement.areasForDevelopment}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{new Date(endorsement.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
52 changes: 52 additions & 0 deletions app/admin/cycles/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { notFound, redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
import { getCycleById } from '@/lib/data/cycles';
import {
getApplicationsWithNominationCountsByCycleId,
getApplicationWithNominationsByCycleId,
} from '@/lib/data/applications';
import { getNominationsByCycleId } from '@/lib/data/nominations';
import { getEndorsementsByCycleId } from '@/lib/data/endorsements';
import { getSettingsByCycleId } from '@/lib/data/settings';
import { CycleDashboard } from './cycle-dashboard';

interface CyclePageProps {
params: Promise<{ id: string }>;
}

export default async function CyclePage({ params }: CyclePageProps) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

if (!user) {
redirect('/login');
}

const { id } = await params;

const cycle = await getCycleById(id);

if (!cycle) {
notFound();
}

const [applications, nominations, endorsements, settings] = await Promise.all([
getApplicationsWithNominationCountsByCycleId(id),
getNominationsByCycleId(id),
getEndorsementsByCycleId(id),
getSettingsByCycleId(id),
]);

return (
<CycleDashboard
cycle={cycle}
applications={applications}
getApplicationDetails={getApplicationWithNominationsByCycleId.bind(null, id)}
nominations={nominations}
endorsements={endorsements}
settings={settings}
/>
);
}
97 changes: 97 additions & 0 deletions app/admin/cycles/create-cycle-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import { createCycle } from '@/lib/actions/cycles';
import { isActionError } from '@/lib/actions/utils';

const createCycleSchema = z.object({
name: z.string().min(1, 'Cycle name is required'),
});

type CreateCycleFormData = z.infer<typeof createCycleSchema>;

interface CreateCycleModalProps {
onClose: () => void;
}

export function CreateCycleModal({ onClose }: CreateCycleModalProps) {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<CreateCycleFormData>({
resolver: zodResolver(createCycleSchema),
});

const handleCreate = (setActive: boolean) =>
handleSubmit(async (data) => {
const result = await createCycle(data.name, setActive);
if (isActionError(result)) {
setError('root', { message: result.error });
} else {
toast.success(
setActive
? `Cycle "${data.name}" created and set as active`
: `Cycle "${data.name}" created`
);
onClose();
}
});

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<Card className="w-full max-w-md mx-4">
<CardHeader>
<CardTitle>Create New Cycle</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{errors.root && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errors.root.message}</AlertDescription>
</Alert>
)}
<div>
<Label htmlFor="cycle-name">Cycle Name</Label>
<Input
id="cycle-name"
{...register('name')}
placeholder="e.g. Fall 2025"
disabled={isSubmitting}
autoFocus
/>
{errors.name && (
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
type="button"
variant="outline"
onClick={handleCreate(false)}
disabled={isSubmitting}
>
Create as Inactive
</Button>
<Button type="button" onClick={handleCreate(true)} disabled={isSubmitting}>
Create & Set Active
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
Loading