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:
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user