diff --git a/app/admin/cycles/[id]/cycle-dashboard.tsx b/app/admin/cycles/[id]/cycle-dashboard.tsx new file mode 100644 index 0000000..e0c4782 --- /dev/null +++ b/app/admin/cycles/[id]/cycle-dashboard.tsx @@ -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; + 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 ( +
+ {/* Header */} +
+ + + Back to Cycle Archive + +
+

{cycle.name}

+ + {cycle.isActive ? 'Active' : 'Inactive'} + +
+
+ + {/* Tabs */} + + + Applications + Nominations + Endorsements + {settings !== null && ( + Settings + )} + + + + + + + + + + + + + + + {settings !== null && ( + + + + )} + +
+ ); +} diff --git a/app/admin/cycles/[id]/endorsements-view.tsx b/app/admin/cycles/[id]/endorsements-view.tsx new file mode 100644 index 0000000..1e9977d --- /dev/null +++ b/app/admin/cycles/[id]/endorsements-view.tsx @@ -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 ( +
+ + + Endorsements ({endorsements.length}) + + + {endorsements.length === 0 ? ( +

+ No endorsements for this cycle. +

+ ) : ( +
+ + + + Applicant + Endorser + Endorser Email + Defining Traits + Leadership Qualities + Areas for Development + Submitted + + + + {endorsements.map((endorsement) => ( + + + {endorsement.applicantName} + + {endorsement.endorserName} + + {endorsement.endorserEmail} + + + {endorsement.definingTraits} + + + {endorsement.leadershipQualities} + + + {endorsement.areasForDevelopment} + + + {new Date(endorsement.createdAt).toLocaleDateString()} + + + ))} + +
+
+ )} +
+
+
+ ); +} diff --git a/app/admin/cycles/[id]/page.tsx b/app/admin/cycles/[id]/page.tsx new file mode 100644 index 0000000..d598da8 --- /dev/null +++ b/app/admin/cycles/[id]/page.tsx @@ -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 ( + + ); +} diff --git a/app/admin/cycles/create-cycle-modal.tsx b/app/admin/cycles/create-cycle-modal.tsx new file mode 100644 index 0000000..939cdfb --- /dev/null +++ b/app/admin/cycles/create-cycle-modal.tsx @@ -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; + +interface CreateCycleModalProps { + onClose: () => void; +} + +export function CreateCycleModal({ onClose }: CreateCycleModalProps) { + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + 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 ( +
+ + + Create New Cycle + + + {errors.root && ( + + + {errors.root.message} + + )} +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+
+ + + +
+
+
+
+ ); +} diff --git a/app/admin/cycles/cycles-manager.tsx b/app/admin/cycles/cycles-manager.tsx new file mode 100644 index 0000000..941ac06 --- /dev/null +++ b/app/admin/cycles/cycles-manager.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Plus, Trash2, ExternalLink } from 'lucide-react'; +import { toast } from 'sonner'; +import Link from 'next/link'; +import { deleteCycle } from '@/lib/actions/cycles'; +import { isActionError } from '@/lib/actions/utils'; +import { CreateCycleModal } from './create-cycle-modal'; +import { SetActiveModal } from './set-active-modal'; + +export interface CycleWithCounts { + id: string; + name: string; + isActive: boolean; + createdAt: Date; + _count: { + applications: number; + nominations: number; + endorsements: number; + }; +} + +interface CyclesManagerProps { + cycles: CycleWithCounts[]; +} + +export function CyclesManager({ cycles }: CyclesManagerProps) { + const [showCreateModal, setShowCreateModal] = useState(false); + const [activeCycleToSet, setActiveCycleToSet] = useState(null); + + const handleDelete = async (cycle: CycleWithCounts) => { + if (!confirm(`Are you sure you want to delete cycle "${cycle.name}"?`)) return; + + const result = await deleteCycle(cycle.id); + if (isActionError(result)) { + toast.error(result.error); + } else { + toast.success(`Cycle "${cycle.name}" deleted`); + } + }; + + const isEmpty = (cycle: CycleWithCounts) => + cycle._count.applications === 0 && + cycle._count.nominations === 0 && + cycle._count.endorsements === 0; + + return ( +
+
+ +
+ + {showCreateModal && setShowCreateModal(false)} />} + + {activeCycleToSet && ( + setActiveCycleToSet(null)} /> + )} + + + + All Cycles ({cycles.length}) + + + {cycles.length === 0 ? ( +

+ No cycles found. Create one above to get started. +

+ ) : ( + + + + Name + Status + Applications + Nominations + Endorsements + Created + Actions + + + + {cycles.map((cycle) => ( + + {cycle.name} + + + {cycle.isActive ? 'Active' : 'Inactive'} + + + {cycle._count.applications} + {cycle._count.nominations} + {cycle._count.endorsements} + + {new Date(cycle.createdAt).toLocaleDateString()} + + +
+ {!cycle.isActive && ( + + + + )} + {!cycle.isActive && ( + + )} + {!cycle.isActive && ( + + )} +
+
+
+ ))} +
+
+ )} +
+
+
+ ); +} + diff --git a/app/admin/cycles/page.tsx b/app/admin/cycles/page.tsx new file mode 100644 index 0000000..7e821f8 --- /dev/null +++ b/app/admin/cycles/page.tsx @@ -0,0 +1,29 @@ +import { getCyclesWithCounts } from '@/lib/data/cycles'; +import { CyclesManager } from './cycles-manager'; +import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; + +export default async function CyclesPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect('/login'); + } + + const cycles = await getCyclesWithCounts(); + + return ( +
+
+

Cycle Management

+

+ View all cycles, create new cycles, and set which cycle is active +

+
+ +
+ ); +} diff --git a/app/admin/cycles/set-active-modal.tsx b/app/admin/cycles/set-active-modal.tsx new file mode 100644 index 0000000..98b23c5 --- /dev/null +++ b/app/admin/cycles/set-active-modal.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { setActiveCycle } from '@/lib/actions/cycles'; +import { isActionError } from '@/lib/actions/utils'; +import type { CycleWithCounts } from './cycles-manager'; + +interface SetActiveModalProps { + cycle: CycleWithCounts; + onClose: () => void; +} + +export function SetActiveModal({ cycle, onClose }: SetActiveModalProps) { + const handleConfirm = async () => { + const result = await setActiveCycle(cycle.id); + if (isActionError(result)) { + toast.error(result.error); + } else { + toast.success(`"${cycle.name}" is now the active cycle`); + onClose(); + } + }; + + return ( + !open && onClose()}> + + + Set Active Cycle + + This will make {cycle.name} the active cycle. All new applications + will go to this cycle. Continue? + + + + Cancel + Confirm + + + + ); +} + diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 4c1cb5f..aa6f24e 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -3,6 +3,7 @@ import { getApplicationWithNominations, } from '@/lib/data/applications'; import { getSettings } from '@/lib/data/settings'; +import { getActiveCycle } from '@/lib/data/cycles'; import AdminDashboard from '@/components/AdminDashboard'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; @@ -20,15 +21,23 @@ export default async function AdminPage() { redirect('/login'); } - const applications = await getApplicationsWithNominationCounts(); - const settings = await getSettings(); + const [applications, settings, activeCycle] = await Promise.all([ + getApplicationsWithNominationCounts(), + getSettings(), + getActiveCycle(), + ]); return (
-

- Admin Dashboard -

+
+

+ Admin Dashboard +

+

+ {activeCycle.name} +

+
+ + +
data.maxCommunityNominations <= data.requiredNominations, - { - message: 'Max community nominations cannot exceed total required nominations', +const settingsSchema = z + .object({ + requiredNominations: z.number().min(1).max(100), + maxCommunityNominations: z.number().min(0).max(100), + endorsementRequired: z.boolean(), + endorsementsOpen: z.boolean(), + applicationDeadline: z.string().optional(), + applicationsOpen: z.boolean(), + nominationsOpen: z.boolean(), + customMessage: z.string().optional(), + }) + .refine((data) => data.maxCommunityNominations <= data.requiredNominations, { + message: + 'Max community nominations cannot exceed total required nominations', path: ['maxCommunityNominations'], - } -).refine( - (data) => !(data.endorsementRequired && !data.endorsementsOpen), - { + }) + .refine((data) => !(data.endorsementRequired && !data.endorsementsOpen), { message: 'Endorsements cannot be both required and closed', path: ['endorsementsOpen'], - } -); + }); type SettingsFormData = z.infer; interface SettingsFormProps { settings: Settings; + readOnly?: boolean; } -export default function SettingsForm({ settings }: SettingsFormProps) { +export default function SettingsForm({ + settings, + readOnly = false, +}: SettingsFormProps) { const router = useRouter(); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -116,6 +118,15 @@ export default function SettingsForm({ settings }: SettingsFormProps) { return (
+ {readOnly && ( + + + + This is an inactive cycle. Settings cannot be modified. + + + )} + {error && ( @@ -140,7 +151,7 @@ export default function SettingsForm({ settings }: SettingsFormProps) { min="1" max="100" {...register('requiredNominations', { valueAsNumber: true })} - disabled={isSubmitting} + disabled={isSubmitting || readOnly} /> {errors.requiredNominations && (

@@ -161,8 +172,10 @@ export default function SettingsForm({ settings }: SettingsFormProps) { type="number" min="0" max="100" - {...register('maxCommunityNominations', { valueAsNumber: true })} - disabled={isSubmitting} + {...register('maxCommunityNominations', { + valueAsNumber: true, + })} + disabled={isSubmitting || readOnly} /> {errors.maxCommunityNominations && (

@@ -170,7 +183,8 @@ export default function SettingsForm({ settings }: SettingsFormProps) {

)}

- Maximum number of nominations that can come from community constituencies (cannot exceed total required nominations) + Maximum number of nominations that can come from community + constituencies (cannot exceed total required nominations)

@@ -190,7 +204,7 @@ export default function SettingsForm({ settings }: SettingsFormProps) { }); } }} - disabled={isSubmitting} + disabled={isSubmitting || readOnly} />
@@ -216,7 +231,9 @@ export default function SettingsForm({ settings }: SettingsFormProps) { - Important: Forms will NOT automatically close when the deadline passes. You must manually toggle the checkboxes below to close forms. + Important: Forms will NOT automatically close + when the deadline passes. You must manually toggle the checkboxes + below to close forms. @@ -226,7 +243,7 @@ export default function SettingsForm({ settings }: SettingsFormProps) { id="applicationDeadline" type="datetime-local" {...register('applicationDeadline')} - disabled={isSubmitting} + disabled={isSubmitting || readOnly} /> {errors.applicationDeadline && (

@@ -234,7 +251,9 @@ export default function SettingsForm({ settings }: SettingsFormProps) {

)}

- Deadline for applications to be submitted (leave empty for no deadline). Note: This is informational only and does not automatically close forms. + Deadline for applications to be submitted (leave empty for no + deadline). Note: This is informational only and does not + automatically close forms.

@@ -247,7 +266,7 @@ export default function SettingsForm({ settings }: SettingsFormProps) { shouldDirty: true, }) } - disabled={isSubmitting} + disabled={isSubmitting || readOnly} />
@@ -271,7 +291,7 @@ export default function SettingsForm({ settings }: SettingsFormProps) { shouldDirty: true, }) } - disabled={isSubmitting} + disabled={isSubmitting || readOnly} />
@@ -301,7 +322,7 @@ export default function SettingsForm({ settings }: SettingsFormProps) { }); } }} - disabled={isSubmitting} + disabled={isSubmitting || readOnly} />