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
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 63 KiB |
@@ -1,77 +1,78 @@
|
||||
# CannaManage — Visual Tour (Sprint 4)
|
||||
|
||||
**Generated:** 2026-06-12
|
||||
**Generated:** 2026-06-13
|
||||
|
||||
---
|
||||
|
||||
## Admin Login
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| -------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## Member Portal Login
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## Club Dashboard (auth required)
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| ------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## Member Management (auth required)
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## Distribution History (auth required)
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## New Distribution (Multi-Step) (auth required)
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## Stock & Batch Management (auth required)
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## Add New Batch (auth required)
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## Compliance Reports (auth required)
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## Member Quota Overview
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## My Distribution History
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
## Profile & Settings
|
||||
|
||||
| Dark Mode | Light Mode |
|
||||
| ------------------------------------------------------------------ | -------------------------------------------------------------------- |
|
||||
| Dark Mode | Light Mode |
|
||||
|-----------|------------|
|
||||
|  |  |
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -743,5 +743,34 @@
|
||||
"s10Title": "§ 10 Schlussbestimmungen",
|
||||
"s10Content": "Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist, soweit gesetzlich zulässig, der Sitz des Anbieters. Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. Änderungen der AGB werden dem Nutzer rechtzeitig mitgeteilt."
|
||||
}
|
||||
},
|
||||
"infoBoard": {
|
||||
"title": "Schwarzes Brett",
|
||||
"description": "Neuigkeiten und Ankündigungen für alle Mitglieder",
|
||||
"createPost": "Beitrag erstellen",
|
||||
"postTitle": "Titel",
|
||||
"postTitlePlaceholder": "Titel des Beitrags...",
|
||||
"postContent": "Inhalt",
|
||||
"postContentPlaceholder": "Schreibe deinen Beitrag...",
|
||||
"category": "Kategorie",
|
||||
"categories": {
|
||||
"GENERAL": "Allgemein",
|
||||
"EVENT": "Veranstaltung",
|
||||
"RULE": "Regelung",
|
||||
"MAINTENANCE": "Wartung"
|
||||
},
|
||||
"pinPost": "Anheften",
|
||||
"archive": "Archivieren",
|
||||
"delete": "Löschen",
|
||||
"publish": "Veröffentlichen",
|
||||
"creating": "Wird erstellt...",
|
||||
"cancel": "Abbrechen",
|
||||
"allCategories": "Alle Kategorien",
|
||||
"showArchived": "Archiviert",
|
||||
"archived": "Archiviert",
|
||||
"loading": "Beiträge werden geladen...",
|
||||
"noPosts": "Noch keine Beiträge vorhanden. Erstelle den ersten Beitrag!",
|
||||
"confirmDelete": "Möchtest du diesen Beitrag wirklich löschen?",
|
||||
"unreadCount": "{count} ungelesen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,5 +688,34 @@
|
||||
"s10Title": "§ 10 Final Provisions",
|
||||
"s10Content": "The law of the Federal Republic of Germany applies. The place of jurisdiction is, to the extent legally permissible, the registered office of the provider. Should individual provisions of these terms be invalid, the validity of the remaining provisions remains unaffected. Changes to these terms will be communicated to the user in good time."
|
||||
}
|
||||
},
|
||||
"infoBoard": {
|
||||
"title": "Info Board",
|
||||
"description": "News and announcements for all members",
|
||||
"createPost": "Create Post",
|
||||
"postTitle": "Title",
|
||||
"postTitlePlaceholder": "Post title...",
|
||||
"postContent": "Content",
|
||||
"postContentPlaceholder": "Write your post...",
|
||||
"category": "Category",
|
||||
"categories": {
|
||||
"GENERAL": "General",
|
||||
"EVENT": "Event",
|
||||
"RULE": "Rule",
|
||||
"MAINTENANCE": "Maintenance"
|
||||
},
|
||||
"pinPost": "Pin",
|
||||
"archive": "Archive",
|
||||
"delete": "Delete",
|
||||
"publish": "Publish",
|
||||
"creating": "Creating...",
|
||||
"cancel": "Cancel",
|
||||
"allCategories": "All Categories",
|
||||
"showArchived": "Archived",
|
||||
"archived": "Archived",
|
||||
"loading": "Loading posts...",
|
||||
"noPosts": "No posts yet. Create the first one!",
|
||||
"confirmDelete": "Are you sure you want to delete this post?",
|
||||
"unreadCount": "{count} unread"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||