feat: add admin content module pages

This commit is contained in:
simoleo89
2026-07-12 15:27:07 +02:00
parent f422bb4a0b
commit 2606092337
24 changed files with 2892 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
import { notFound, redirect } from 'next/navigation'
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { PollForm } from '../poll-form'
import { PollQuestionsManager } from './poll-questions'
import { PollResultsView } from './poll-results'
export default async function PollDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>
}) {
const { id } = await params
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.POLLS_EDIT, session.user.rank)) {
redirect('/admin/polls')
}
const pollId = parseInt(id, 10)
if (Number.isNaN(pollId)) notFound()
const poll = await prisma.websitePoll.findUnique({
where: { id: pollId },
include: {
questions: {
orderBy: { sortOrder: 'asc' },
include: {
votes: true,
},
},
},
})
if (!poll) notFound()
const totalVoters = new Set(poll.questions.flatMap((q) => q.votes.map((v) => v.userId))).size
return (
<div className="space-y-6">
<div>
<AdminBreadcrumb
items={[{ label: 'Polls', href: '/admin/polls' }, { label: poll.title }]}
/>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{poll.title}</h1>
<Badge
variant={
poll.status === 'active'
? 'default'
: poll.status === 'closed'
? 'outline'
: 'secondary'
}
>
{poll.status}
</Badge>
</div>
{poll.description && <p className="text-muted-foreground">{poll.description}</p>}
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Voters</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalVoters}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Questions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{poll.questions.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Votes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{poll.questions.reduce((sum, q) => sum + q.votes.length, 0)}
</div>
</CardContent>
</Card>
</div>
{/* Edit form */}
<PollForm
defaultValues={{
id: poll.id,
title: poll.title,
description: poll.description,
status: poll.status as 'draft' | 'active' | 'closed',
showResults: poll.showResults,
multipleChoice: poll.multipleChoice,
startsAt: poll.startsAt,
endsAt: poll.endsAt,
}}
/>
{/* Questions */}
<PollQuestionsManager
pollId={poll.id}
questions={poll.questions.map((q) => ({
id: q.id,
question: q.question,
type: q.type,
sortOrder: q.sortOrder,
options: q.options,
}))}
/>
{/* Results */}
{poll.questions.length > 0 && (
<PollResultsView
questions={poll.questions.map((q) => ({
id: q.id,
question: q.question,
options: q.options,
votes: q.votes.map((v) => v.answer),
}))}
/>
)}
</div>
)
}
+199
View File
@@ -0,0 +1,199 @@
'use client'
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { addPollQuestion, deletePollQuestion, updatePollQuestion } from '@/actions/polls'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useServerAction } from '@/hooks/use-server-action'
interface Question {
id: number
question: string
type: string
sortOrder: number
options: string
}
interface Props {
pollId: number
questions: Question[]
}
export function PollQuestionsManager({ pollId, questions }: Props) {
const { run, isPending } = useServerAction()
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState({
question: '',
type: 'single',
sortOrder: questions.length,
options: '',
})
function resetForm() {
setForm({ question: '', type: 'single', sortOrder: questions.length, options: '' })
setEditingId(null)
setShowForm(false)
}
function handleEdit(q: Question) {
setForm({ question: q.question, type: q.type, sortOrder: q.sortOrder, options: q.options })
setEditingId(q.id)
setShowForm(true)
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (editingId) {
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
run(() => updatePollQuestion({ id: editingId, ...form } as any), {
successMessage: 'Question updated!',
})
} else {
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
run(() => addPollQuestion({ pollId, ...form } as any), {
successMessage: 'Question added!',
})
}
resetForm()
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Questions ({questions.length})</CardTitle>
<Button
size="sm"
onClick={() => {
resetForm()
setShowForm(!showForm)
}}
>
<Plus className="h-4 w-4 mr-1" /> Add Question
</Button>
</CardHeader>
<CardContent className="space-y-4">
{showForm && (
<form onSubmit={handleSubmit} className="border rounded-lg p-4 space-y-3">
<div className="space-y-1">
<Label>Question</Label>
<Input
value={form.question}
onChange={(e) => setForm({ ...form, question: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Type</Label>
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="single">Single Choice</SelectItem>
<SelectItem value="multiple">Multiple Choice</SelectItem>
<SelectItem value="text">Free Text</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Sort Order</Label>
<Input
type="number"
value={form.sortOrder}
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
/>
</div>
</div>
<div className="space-y-1">
<Label>Options (one per line)</Label>
<textarea
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm min-h-[80px]"
value={form.options}
onChange={(e) => setForm({ ...form, options: e.target.value })}
placeholder={'Option A\nOption B\nOption C'}
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" size="sm" onClick={resetForm}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={isPending}>
{editingId ? 'Update' : 'Add'}
</Button>
</div>
</form>
)}
{questions.length === 0 && !showForm && (
<p className="text-sm text-muted-foreground text-center py-4">
No questions yet. Add your first question above.
</p>
)}
<div className="space-y-2">
{questions.map((q, i) => (
<div key={q.id} className="flex items-start gap-3 border rounded-lg px-4 py-3">
<GripVertical className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground">Q{i + 1}</span>
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">{q.type}</span>
</div>
<p className="text-sm font-medium">{q.question}</p>
{q.options && (
<div className="mt-1 text-xs text-muted-foreground">
{q.options
.split('\n')
.filter(Boolean)
.map((opt) => (
<span
key={opt}
className="inline-block mr-2 bg-muted px-1.5 py-0.5 rounded mb-1"
>
{opt}
</span>
))}
</div>
)}
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(q)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
run(() => deletePollQuestion({ id: q.id }), {
successMessage: 'Question deleted',
})
}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}
+74
View File
@@ -0,0 +1,74 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface QuestionResult {
id: number
question: string
options: string
votes: string[] // array of answer strings
}
interface Props {
questions: QuestionResult[]
}
export function PollResultsView({ questions }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Results</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{questions.map((q) => {
const options = q.options.split('\n').filter(Boolean)
const totalVotes = q.votes.length
// Count votes per option
const voteCounts = new Map<string, number>()
for (const opt of options) voteCounts.set(opt, 0)
for (const vote of q.votes) {
voteCounts.set(vote, (voteCounts.get(vote) ?? 0) + 1)
}
return (
<div key={q.id}>
<h4 className="text-sm font-medium mb-3">{q.question}</h4>
{options.length > 0 ? (
<div className="space-y-2">
{options.map((opt) => {
const count = voteCounts.get(opt) ?? 0
const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0
return (
<div key={opt}>
<div className="flex justify-between text-xs mb-1">
<span>{opt}</span>
<span className="text-muted-foreground">
{count} votes ({pct}%)
</span>
</div>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
) : (
<div className="text-sm text-muted-foreground">
{totalVotes} free-text responses
</div>
)}
<p className="text-xs text-muted-foreground mt-2">
{totalVotes} total vote{totalVotes !== 1 ? 's' : ''}
</p>
</div>
)
})}
</CardContent>
</Card>
)
}