feat(sprint7): Phase 2 — Info Board (Schwarzes Brett)

Backend:
- V13 Flyway migration: info_board_posts, post_attachments, post_read_status tables
- InfoBoardPost entity with category enum (EVENT, RULE, GENERAL, MAINTENANCE)
- PostAttachment entity (table created, upload deferred to later)
- PostReadStatus entity with composite key (post_id, member_id)
- InfoBoardPostRepository with paginated queries + unread count
- InfoBoardService: CRUD, pin/archive, mark-as-read, notification dispatch
- InfoBoardController: admin CRUD + portal read/unread endpoints
- Integration with NotificationService and AuditService

Frontend:
- info-board.ts service with React Query hooks for all endpoints
- Admin Info Board page at /info-board with create dialog, filters, pin/archive/delete
- Navigation: added 'Schwarzes Brett' to admin sidebar
- i18n: added infoBoard.* keys to de.json and en.json
- Fixed pre-existing prettier issues in notification-compose.ts
- Fixed BufferSource type issue in push-subscription.ts
This commit is contained in:
Patrick Plate
2026-06-13 19:41:20 +02:00
parent 706a6e257b
commit 4aa27cd4f9
53 changed files with 2724 additions and 28 deletions
@@ -0,0 +1,304 @@
"use client"
import { useState } from "react"
import {
useArchivePostMutation,
useCreatePostMutation,
useDeletePostMutation,
useInfoBoardPostsQuery,
useTogglePinMutation,
} from "@/services/info-board"
import { useTranslations } from "next-intl"
import {
Archive,
BookOpen,
Calendar,
Filter,
Megaphone,
Pin,
Plus,
Trash2,
Wrench,
} from "lucide-react"
import type { InfoBoardCategory, InfoBoardPost } from "@/services/info-board"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
const categoryIcons: Record<InfoBoardCategory, React.ReactNode> = {
EVENT: <Calendar className="h-4 w-4" />,
RULE: <BookOpen className="h-4 w-4" />,
GENERAL: <Megaphone className="h-4 w-4" />,
MAINTENANCE: <Wrench className="h-4 w-4" />,
}
const categoryColors: Record<InfoBoardCategory, string> = {
EVENT: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
RULE: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
GENERAL: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
MAINTENANCE:
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
}
// Mock club ID for development
const MOCK_CLUB_ID = "00000000-0000-0000-0000-000000000001"
export default function InfoBoardPage() {
const t = useTranslations("infoBoard")
const [filterCategory, setFilterCategory] = useState<
InfoBoardCategory | "ALL"
>("ALL")
const [includeArchived, setIncludeArchived] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
// Form state
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const [category, setCategory] = useState<InfoBoardCategory>("GENERAL")
const [pinned, setPinned] = useState(false)
const { data, isLoading } = useInfoBoardPostsQuery(MOCK_CLUB_ID, {
category: filterCategory === "ALL" ? undefined : filterCategory,
includeArchived,
})
const createMutation = useCreatePostMutation()
const deleteMutation = useDeletePostMutation()
const archiveMutation = useArchivePostMutation()
const togglePinMutation = useTogglePinMutation()
const handleCreate = () => {
if (!title.trim() || !content.trim()) return
createMutation.mutate(
{ clubId: MOCK_CLUB_ID, title, content, category, pinned },
{
onSuccess: () => {
setDialogOpen(false)
setTitle("")
setContent("")
setCategory("GENERAL")
setPinned(false)
},
}
)
}
const posts: InfoBoardPost[] = data?.posts ?? []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground">{t("description")}</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
{t("createPost")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{t("createPost")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="title">{t("postTitle")}</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("postTitlePlaceholder")}
maxLength={200}
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">{t("postContent")}</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("postContentPlaceholder")}
rows={6}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t("category")}</Label>
<Select
value={category}
onChange={(e) =>
setCategory(e.target.value as InfoBoardCategory)
}
>
<option value="GENERAL">{t("categories.GENERAL")}</option>
<option value="EVENT">{t("categories.EVENT")}</option>
<option value="RULE">{t("categories.RULE")}</option>
<option value="MAINTENANCE">
{t("categories.MAINTENANCE")}
</option>
</Select>
</div>
<div className="flex items-end space-x-2">
<Button
type="button"
variant={pinned ? "default" : "outline"}
size="sm"
onClick={() => setPinned(!pinned)}
>
<Pin className="mr-1 h-4 w-4" />
{t("pinPost")}
</Button>
</div>
</div>
<div className="flex justify-end space-x-2 pt-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
{t("cancel")}
</Button>
<Button
onClick={handleCreate}
disabled={createMutation.isPending}
>
{createMutation.isPending ? t("creating") : t("publish")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<Filter className="h-4 w-4 text-muted-foreground" />
<Select
value={filterCategory}
onChange={(e) =>
setFilterCategory(e.target.value as InfoBoardCategory | "ALL")
}
className="w-[180px]"
>
<option value="ALL">{t("allCategories")}</option>
<option value="GENERAL">{t("categories.GENERAL")}</option>
<option value="EVENT">{t("categories.EVENT")}</option>
<option value="RULE">{t("categories.RULE")}</option>
<option value="MAINTENANCE">{t("categories.MAINTENANCE")}</option>
</Select>
<Button
variant={includeArchived ? "default" : "outline"}
size="sm"
onClick={() => setIncludeArchived(!includeArchived)}
>
<Archive className="mr-1 h-4 w-4" />
{t("showArchived")}
</Button>
</div>
{/* Posts List */}
{isLoading ? (
<div className="text-muted-foreground py-12 text-center">
{t("loading")}
</div>
) : posts.length === 0 ? (
<Card>
<CardContent className="text-muted-foreground py-12 text-center">
{t("noPosts")}
</CardContent>
</Card>
) : (
<div className="space-y-4">
{posts.map((post) => (
<Card key={post.id} className={post.archived ? "opacity-60" : ""}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{post.pinned && (
<Pin className="h-4 w-4 fill-amber-500 text-amber-500" />
)}
<CardTitle className="text-lg">{post.title}</CardTitle>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => togglePinMutation.mutate(post.id)}
title={t("pinPost")}
>
<Pin
className={`h-4 w-4 ${post.pinned ? "fill-current" : ""}`}
/>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => archiveMutation.mutate(post.id)}
title={t("archive")}
>
<Archive className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (confirm(t("confirmDelete"))) {
deleteMutation.mutate(post.id)
}
}}
title={t("delete")}
>
<Trash2 className="text-destructive h-4 w-4" />
</Button>
</div>
</div>
<div className="mt-1 flex items-center gap-2">
<Badge
className={categoryColors[post.category]}
variant="secondary"
>
{categoryIcons[post.category]}
<span className="ml-1">
{t(`categories.${post.category}`)}
</span>
</Badge>
{post.archived && (
<Badge variant="outline">{t("archived")}</Badge>
)}
<span className="text-muted-foreground text-xs">
{new Date(post.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</CardHeader>
<CardContent>
<div
className="prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}
@@ -34,6 +34,11 @@ export const navigationsData: NavigationType[] = [
href: "/reports",
iconName: "FileText",
},
{
title: "Schwarzes Brett",
href: "/info-board",
iconName: "Megaphone",
},
{
title: "Personal",
href: "/settings/staff",
@@ -45,7 +45,7 @@ export async function subscribeToPush(): Promise<PushSubscription | null> {
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
})
// Send subscription to backend
@@ -0,0 +1,190 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export type InfoBoardCategory = "EVENT" | "RULE" | "GENERAL" | "MAINTENANCE"
export interface InfoBoardPost {
id: string
clubId: string
title: string
content: string
category: InfoBoardCategory
pinned: boolean
archived: boolean
authorId: string
createdAt: string
updatedAt: string
}
export interface InfoBoardPostsResponse {
posts: InfoBoardPost[]
totalElements: number
totalPages: number
page: number
}
export interface CreatePostRequest {
clubId: string
title: string
content: string
category: InfoBoardCategory
pinned?: boolean
}
export interface UpdatePostRequest {
title?: string
content?: string
category?: InfoBoardCategory
pinned?: boolean
}
// --- Query Hooks ---
export function useInfoBoardPostsQuery(
clubId: string | undefined,
options?: {
category?: InfoBoardCategory
includeArchived?: boolean
page?: number
size?: number
}
) {
return useQuery({
queryKey: ["info-board", clubId, options],
queryFn: () => {
const params = new URLSearchParams()
if (clubId) params.set("clubId", clubId)
if (options?.category) params.set("category", options.category)
if (options?.includeArchived) params.set("includeArchived", "true")
params.set("page", String(options?.page ?? 0))
params.set("size", String(options?.size ?? 20))
return apiClient<InfoBoardPostsResponse>(`/info-board?${params}`)
},
enabled: !!clubId,
})
}
export function useInfoBoardPostQuery(id: string | undefined) {
return useQuery({
queryKey: ["info-board", id],
queryFn: () => apiClient<InfoBoardPost>(`/info-board/${id}`),
enabled: !!id,
})
}
export function usePortalInfoBoardQuery(
clubId: string | undefined,
options?: { category?: InfoBoardCategory; page?: number }
) {
return useQuery({
queryKey: ["portal-info-board", clubId, options],
queryFn: () => {
const params = new URLSearchParams()
if (clubId) params.set("clubId", clubId)
if (options?.category) params.set("category", options.category)
params.set("page", String(options?.page ?? 0))
return apiClient<InfoBoardPostsResponse>(`/portal/info-board?${params}`)
},
enabled: !!clubId,
})
}
export function useUnreadCountQuery(
clubId: string | undefined,
memberId: string | undefined
) {
return useQuery({
queryKey: ["info-board-unread", clubId, memberId],
queryFn: () => {
const params = new URLSearchParams()
if (clubId) params.set("clubId", clubId)
if (memberId) params.set("memberId", memberId)
return apiClient<{ unreadCount: number }>(
`/portal/info-board/unread-count?${params}`
)
},
enabled: !!clubId && !!memberId,
})
}
// --- Mutation Hooks ---
export function useCreatePostMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreatePostRequest) =>
apiClient<InfoBoardPost>("/info-board", { method: "POST", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["info-board"] })
},
})
}
export function useUpdatePostMutation(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdatePostRequest) =>
apiClient<InfoBoardPost>(`/info-board/${id}`, {
method: "PUT",
body: data,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["info-board"] })
},
})
}
export function useDeletePostMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiClient<{ deleted: boolean }>(`/info-board/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["info-board"] })
},
})
}
export function useArchivePostMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiClient<InfoBoardPost>(`/info-board/${id}/archive`, { method: "POST" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["info-board"] })
},
})
}
export function useTogglePinMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiClient<InfoBoardPost>(`/info-board/${id}/pin`, { method: "POST" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["info-board"] })
},
})
}
export function useMarkAsReadMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ postId, memberId }: { postId: string; memberId: string }) =>
apiClient<{ read: boolean }>(
`/portal/info-board/${postId}/read?memberId=${memberId}`,
{
method: "POST",
}
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["info-board-unread"] })
queryClient.invalidateQueries({ queryKey: ["portal-info-board"] })
},
})
}
@@ -72,7 +72,9 @@ export async function registerDevice(
})
}
export async function getDevices(): Promise<{ devices: DeviceTokenResponse[] }> {
export async function getDevices(): Promise<{
devices: DeviceTokenResponse[]
}> {
return apiClient<{ devices: DeviceTokenResponse[] }>("/notifications/devices")
}