summaryrefslogtreecommitdiff
path: root/components/notice
diff options
context:
space:
mode:
Diffstat (limited to 'components/notice')
-rw-r--r--components/notice/notice-client.tsx47
-rw-r--r--components/notice/notice-create-dialog.tsx203
-rw-r--r--components/notice/notice-edit-sheet.tsx212
-rw-r--r--components/notice/notice-modal-manager.tsx307
4 files changed, 765 insertions, 4 deletions
diff --git a/components/notice/notice-client.tsx b/components/notice/notice-client.tsx
index 1eb6d75f..ae8ccebc 100644
--- a/components/notice/notice-client.tsx
+++ b/components/notice/notice-client.tsx
@@ -46,6 +46,7 @@ import { NoticeViewDialog } from "./notice-view-dialog"
type NoticeWithAuthor = Notice & {
authorName: string | null
authorEmail: string | null
+ isPopup?: boolean
}
interface NoticeClientProps {
@@ -150,10 +151,13 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr
// 검색 필터
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase()
- filtered = filtered.filter(notice =>
+ filtered = filtered.filter(notice =>
notice.title.toLowerCase().includes(query) ||
notice.pagePath.toLowerCase().includes(query) ||
- notice.content.toLowerCase().includes(query)
+ notice.content.toLowerCase().includes(query) ||
+ (notice.authorName && notice.authorName.toLowerCase().includes(query)) ||
+ (notice.isPopup !== undefined && notice.isPopup ? '팝업' : '일반').toLowerCase().includes(query) ||
+ (notice.dontShowDuration && notice.dontShowDuration.toLowerCase().includes(query))
)
}
@@ -291,6 +295,9 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr
)}
</button>
</TableHead>
+ <TableHead>팝업</TableHead>
+ <TableHead>게시기간</TableHead>
+ {/* <TableHead>다시보지않기</TableHead> */}
<TableHead>작성자</TableHead>
<TableHead>상태</TableHead>
<TableHead>
@@ -314,13 +321,13 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr
<TableBody>
{loading ? (
<TableRow>
- <TableCell colSpan={6} className="text-center py-8">
+ <TableCell colSpan={9} className="text-center py-8">
로딩 중...
</TableCell>
</TableRow>
) : filteredAndSortedNotices.length === 0 ? (
<TableRow>
- <TableCell colSpan={6} className="text-center py-8 text-gray-500">
+ <TableCell colSpan={9} className="text-center py-8 text-gray-500">
{searchQuery.trim() ? "검색 결과가 없습니다." : "공지사항이 없습니다."}
</TableCell>
</TableRow>
@@ -349,6 +356,38 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr
</div>
</TableCell>
<TableCell>
+ <Badge variant={notice.isPopup ? "default" : "secondary"}>
+ {notice.isPopup ? "팝업" : "일반"}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm">
+ {notice.startAt && notice.endAt ? (
+ <div className="space-y-1">
+ <div className="text-xs text-muted-foreground">
+ 시작: {formatDate(notice.startAt, "KR")}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 종료: {formatDate(notice.endAt, "KR")}
+ </div>
+ </div>
+ ) : (
+ <span className="text-xs text-muted-foreground">-</span>
+ )}
+ </div>
+ </TableCell>
+ {/* <TableCell>
+ <div className="text-sm">
+ {notice.dontShowDuration ? (
+ <Badge variant="outline">
+ {notice.dontShowDuration === 'day' ? '하루' : '영구'}
+ </Badge>
+ ) : (
+ <span className="text-xs text-muted-foreground">-</span>
+ )}
+ </div>
+ </TableCell> */}
+ <TableCell>
<div className="flex flex-col">
<span className="font-medium text-sm">
{notice.authorName || "알 수 없음"}
diff --git a/components/notice/notice-create-dialog.tsx b/components/notice/notice-create-dialog.tsx
index 98c66c99..9b7183a6 100644
--- a/components/notice/notice-create-dialog.tsx
+++ b/components/notice/notice-create-dialog.tsx
@@ -31,6 +31,11 @@ import { cn } from "@/lib/utils"
import TiptapEditor from "@/components/qna/tiptap-editor"
import { createNotice } from "@/lib/notice/service"
import { createNoticeSchema, type CreateNoticeSchema } from "@/lib/notice/validations"
+import { Calendar } from "@/components/ui/calendar"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
interface NoticeCreateDialogProps {
open: boolean
@@ -75,6 +80,10 @@ export function NoticeCreateDialog({
content: "",
authorId: currentUserId,
isActive: true,
+ isPopup: false,
+ startAt: undefined,
+ endAt: undefined,
+ dontShowDuration: "never", // 기본값을 영구로 설정
},
})
@@ -87,6 +96,10 @@ export function NoticeCreateDialog({
content: "",
authorId: currentUserId,
isActive: true,
+ isPopup: false,
+ startAt: undefined,
+ endAt: undefined,
+ dontShowDuration: "never", // 기본값을 영구로 설정
})
}
}, [open, currentUserId, form])
@@ -230,6 +243,28 @@ export function NoticeCreateDialog({
/>
</div>
+ {/* 팝업 여부 */}
+ <FormField
+ control={form.control}
+ name="isPopup"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">팝업 공지사항</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 팝업으로 표시할 공지사항인 경우 체크하세요.
+ </div>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
<FormField
control={form.control}
name="title"
@@ -244,6 +279,174 @@ export function NoticeCreateDialog({
)}
/>
+ {/* 유효기간 설정 (팝업인 경우에만 표시) */}
+ {form.watch('isPopup') && (
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="startAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>게시 시작 일시 *</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP HH:mm", { locale: ko })
+ ) : (
+ <span>시작 일시 선택</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={(date) => {
+ if (date) {
+ // 시간을 보존하거나 설정
+ const dateTime = new Date(date)
+ if (field.value) {
+ dateTime.setHours(field.value.getHours())
+ dateTime.setMinutes(field.value.getMinutes())
+ } else {
+ dateTime.setHours(0, 0, 0, 0)
+ }
+ field.onChange(dateTime)
+ }
+ }}
+ disabled={(date) =>
+ date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ <div className="p-3 border-t">
+ <Input
+ type="time"
+ value={field.value ? format(field.value, "HH:mm") : ""}
+ onChange={(e) => {
+ if (field.value && e.target.value) {
+ const [hours, minutes] = e.target.value.split(':')
+ const newDate = new Date(field.value)
+ newDate.setHours(parseInt(hours), parseInt(minutes))
+ field.onChange(newDate)
+ }
+ }}
+ />
+ </div>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="endAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>게시 종료 일시 *</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP HH:mm", { locale: ko })
+ ) : (
+ <span>종료 일시 선택</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={(date) => {
+ if (date) {
+ const dateTime = new Date(date)
+ if (field.value) {
+ dateTime.setHours(field.value.getHours())
+ dateTime.setMinutes(field.value.getMinutes())
+ } else {
+ dateTime.setHours(23, 59, 59, 999)
+ }
+ field.onChange(dateTime)
+ }
+ }}
+ disabled={(date) =>
+ date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ <div className="p-3 border-t">
+ <Input
+ type="time"
+ value={field.value ? format(field.value, "HH:mm") : ""}
+ onChange={(e) => {
+ if (field.value && e.target.value) {
+ const [hours, minutes] = e.target.value.split(':')
+ const newDate = new Date(field.value)
+ newDate.setHours(parseInt(hours), parseInt(minutes))
+ field.onChange(newDate)
+ }
+ }}
+ />
+ </div>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ )}
+
+ {/* 다시 보지 않기 설정 (팝업인 경우에만 표시) - 임시 주석처리 */}
+ {/* {form.watch('isPopup') && (
+ <FormField
+ control={form.control}
+ name="dontShowDuration"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>'다시 보지 않기' 기간 설정 *</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="기간을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="day">하루 동안 표시하지 않음</SelectItem>
+ <SelectItem value="never">영구적으로 표시하지 않음</SelectItem>
+ </SelectContent>
+ </Select>
+ <div className="text-sm text-muted-foreground">
+ 사용자가 '다시 보지 않기'를 체크하면 설정한 기간 동안 해당 공지사항이 표시되지 않습니다.
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )} */}
+
<FormField
control={form.control}
name="content"
diff --git a/components/notice/notice-edit-sheet.tsx b/components/notice/notice-edit-sheet.tsx
index b87714b3..ab7a22cb 100644
--- a/components/notice/notice-edit-sheet.tsx
+++ b/components/notice/notice-edit-sheet.tsx
@@ -38,6 +38,12 @@ import { updateNoticeSchema, type UpdateNoticeSchema } from "@/lib/notice/valida
import type { Notice } from "@/db/schema/notice"
import { updateNoticeData } from "@/lib/notice/service"
import TiptapEditor from "@/components/qna/tiptap-editor"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+import { cn } from "@/lib/utils"
type NoticeWithAuthor = Notice & {
authorName: string | null
@@ -88,6 +94,10 @@ export function UpdateNoticeSheet({
title: "",
content: "",
isActive: true,
+ isPopup: false,
+ startAt: undefined,
+ endAt: undefined,
+ dontShowDuration: "never", // 기본값을 영구로 설정
},
})
@@ -100,6 +110,10 @@ export function UpdateNoticeSheet({
title: notice.title,
content: notice.content,
isActive: notice.isActive,
+ isPopup: notice.isPopup || false,
+ startAt: notice.startAt || undefined,
+ endAt: notice.endAt || undefined,
+ dontShowDuration: notice.dontShowDuration as 'day' | 'never' || "never", // 기본값을 영구로 설정
})
}
}, [notice, form])
@@ -223,6 +237,204 @@ export function UpdateNoticeSheet({
)}
/>
+ {/* 팝업 여부 */}
+ <FormField
+ control={form.control}
+ name="isPopup"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">팝업 공지사항</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 팝업으로 표시할 공지사항인 경우 체크하세요.
+ </div>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* 유효기간 설정 (팝업인 경우에만 표시) */}
+ {form.watch('isPopup') && (
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="startAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>게시 시작 일시</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ disabled={isUpdatePending}
+ >
+ {field.value ? (
+ format(field.value, "PPP HH:mm", { locale: ko })
+ ) : (
+ <span>시작 일시 선택</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={(date) => {
+ if (date) {
+ const dateTime = new Date(date)
+ if (field.value) {
+ dateTime.setHours(field.value.getHours())
+ dateTime.setMinutes(field.value.getMinutes())
+ } else {
+ dateTime.setHours(0, 0, 0, 0)
+ }
+ field.onChange(dateTime)
+ }
+ }}
+ disabled={(date) =>
+ date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ <div className="p-3 border-t">
+ <Input
+ type="time"
+ value={field.value ? format(field.value, "HH:mm") : ""}
+ onChange={(e) => {
+ if (field.value && e.target.value) {
+ const [hours, minutes] = e.target.value.split(':')
+ const newDate = new Date(field.value)
+ newDate.setHours(parseInt(hours), parseInt(minutes))
+ field.onChange(newDate)
+ }
+ }}
+ disabled={isUpdatePending}
+ />
+ </div>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="endAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>게시 종료 일시</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ disabled={isUpdatePending}
+ >
+ {field.value ? (
+ format(field.value, "PPP HH:mm", { locale: ko })
+ ) : (
+ <span>종료 일시 선택</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={(date) => {
+ if (date) {
+ const dateTime = new Date(date)
+ if (field.value) {
+ dateTime.setHours(field.value.getHours())
+ dateTime.setMinutes(field.value.getMinutes())
+ } else {
+ dateTime.setHours(23, 59, 59, 999)
+ }
+ field.onChange(dateTime)
+ }
+ }}
+ disabled={(date) =>
+ date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ <div className="p-3 border-t">
+ <Input
+ type="time"
+ value={field.value ? format(field.value, "HH:mm") : ""}
+ onChange={(e) => {
+ if (field.value && e.target.value) {
+ const [hours, minutes] = e.target.value.split(':')
+ const newDate = new Date(field.value)
+ newDate.setHours(parseInt(hours), parseInt(minutes))
+ field.onChange(newDate)
+ }
+ }}
+ disabled={isUpdatePending}
+ />
+ </div>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ )}
+
+ {/* 다시 보지 않기 설정 (팝업인 경우에만 표시) */}
+ {/* {form.watch('isPopup') && (
+ <FormField
+ control={form.control}
+ name="dontShowDuration"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>'다시 보지 않기' 기간 설정</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={isUpdatePending}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="기간을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="day">하루 동안 표시하지 않음</SelectItem>
+ <SelectItem value="never">영구적으로 표시하지 않음</SelectItem>
+ </SelectContent>
+ </Select>
+ <div className="text-sm text-muted-foreground">
+ 사용자가 '다시 보지 않기'를 체크하면 설정한 기간 동안 해당 공지사항이 표시되지 않습니다.
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )} */}
+
{/* 내용 입력 (리치텍스트 에디터) */}
<FormField
control={form.control}
diff --git a/components/notice/notice-modal-manager.tsx b/components/notice/notice-modal-manager.tsx
new file mode 100644
index 00000000..fd075efe
--- /dev/null
+++ b/components/notice/notice-modal-manager.tsx
@@ -0,0 +1,307 @@
+"use client"
+
+import React, { useState, useEffect, useTransition, useCallback } from "react"
+// usePathname 제거
+import { useTranslation } from "@/i18n/client"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { AlertCircle, Calendar, Clock, User } from "lucide-react"
+import { toast } from "sonner"
+import { getPageNoticesForModal } from "@/lib/notice/service"
+import {
+ setNoticeDontShow,
+ isNoticeDontShowValid,
+} from "@/lib/notice/storage-utils"
+import type { Notice } from "@/db/schema/notice"
+import { formatDate } from "@/lib/utils"
+
+type NoticeWithAuthor = Notice & {
+ authorName: string | null
+ authorEmail: string | null
+}
+
+interface NoticeModalState {
+ notices: NoticeWithAuthor[]
+ currentIndex: number
+ isOpen: boolean
+ dontShowToday: boolean
+ dontShowSettings: Record<number, boolean>
+}
+
+interface NoticeModalManagerProps {
+ pagePath: string // 💡 필수 prop으로 변경
+ autoOpen?: boolean
+}
+
+export function NoticeModalManager({ pagePath, autoOpen = true }: NoticeModalManagerProps) {
+ const { t } = useTranslation('ko', 'menu')
+
+ // 💡 usePathname() 제거
+
+ const [state, setState] = useState<NoticeModalState>({
+ notices: [],
+ currentIndex: 0,
+ isOpen: false,
+ dontShowToday: false,
+ dontShowSettings: {}
+ })
+
+ const [isPending, startTransition] = useTransition()
+ const [isLoading, setIsLoading] = useState(true)
+
+ // 💡 클라이언트 마운트 여부 확인 (하이드레이션 불일치 방지)
+ const [isMounted, setIsMounted] = useState(false)
+
+ // 현재 표시할 공지사항
+ const currentNotice = state.notices[state.currentIndex]
+
+ // 💡 [수정] useCallback 대신 useEffect 내에서 직접 로직 처리 (훅 순서 안정화)
+ useEffect(() => {
+ // 1. 클라이언트 마운트 확인
+ setIsMounted(true)
+
+ if (!pagePath || !autoOpen) {
+ setIsLoading(false) // 마운트 됐는데 실행 조건이 안 되면 로딩 끝내기
+ return
+ }
+
+ // 2. 데이터 페치 로직을 startTransition 내에서 실행 (단일 호출 보장)
+ setIsLoading(true)
+
+ startTransition(async () => {
+ try {
+ const allNotices = await getPageNoticesForModal(pagePath)
+
+ if (allNotices.length === 0) {
+ setState(prev => ({ ...prev, notices: [], isOpen: false }))
+ return
+ }
+
+ // 로컬 스토리지 설정 확인 후 필터링
+ const filteredNotices = allNotices.filter(notice => {
+ const dontShowDay = isNoticeDontShowValid({ noticeId: notice.id, duration: 'day' })
+ const dontShowNever = isNoticeDontShowValid({ noticeId: notice.id, duration: 'never' })
+ return !dontShowDay && !dontShowNever
+ })
+
+ if (filteredNotices.length === 0) {
+ setState(prev => ({ ...prev, notices: [], isOpen: false }))
+ } else {
+ const dontShowSettings: Record<number, boolean> = {}
+ filteredNotices.forEach(notice => {
+ dontShowSettings[notice.id] = false
+ })
+
+ setState(prev => ({
+ ...prev,
+ notices: filteredNotices,
+ currentIndex: 0,
+ isOpen: autoOpen,
+ dontShowSettings
+ }))
+ }
+ } catch (error) {
+ console.error("공지사항 조회 중 오류 발생:", error)
+ toast.error("공지사항을 불러오는 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ })
+ }, [pagePath, autoOpen]) // startTransition은 React에서 제공하는 함수이므로 의존성에서 제외
+
+ // 💡 [수정] '다시 보지 않기' 체크박스 변경 핸들러
+ const handleDontShowChange = useCallback((noticeId: number, checked: boolean) => {
+ setState(prev => ({
+ ...prev,
+ dontShowSettings: {
+ ...prev.dontShowSettings,
+ [noticeId]: checked
+ }
+ }))
+ }, [])
+
+ // 💡 [수정] 다음 공지사항으로 이동
+ const handleNext = useCallback(() => {
+ if (state.currentIndex < state.notices.length - 1) {
+ setState(prev => ({
+ ...prev,
+ currentIndex: prev.currentIndex + 1
+ }))
+ }
+ }, [state.currentIndex, state.notices.length])
+
+ // 💡 [수정] 이전 공지사항으로 이동
+ const handlePrevious = useCallback(() => {
+ if (state.currentIndex > 0) {
+ setState(prev => ({
+ ...prev,
+ currentIndex: prev.currentIndex - 1
+ }))
+ }
+ }, [state.currentIndex])
+
+ // 💡 [수정] 공지사항 닫기 (설정 저장 포함)
+ const handleClose = useCallback(() => {
+ // 체크된 '다시 보지 않기' 설정 저장
+ Object.entries(state.dontShowSettings).forEach(([noticeId, checked]) => {
+ if (checked) {
+ setNoticeDontShow({
+ noticeId: parseInt(noticeId),
+ duration: 'day'
+ })
+ }
+ })
+
+ setState(prev => ({
+ ...prev,
+ isOpen: false,
+ notices: [],
+ currentIndex: 0,
+ dontShowSettings: {}
+ }))
+
+ if (Object.values(state.dontShowSettings).some(Boolean)) {
+ toast.success("설정이 저장되었습니다.")
+ }
+ }, [state.dontShowSettings])
+
+ // 💡 3. 하이드레이션 불일치 방지:
+ // 서버 렌더링 시나 클라이언트 마운트 직후에는 null을 반환하여 서버의 DOM과 일치시킵니다.
+ if (!isMounted) {
+ return null
+ }
+
+ // 4. 이후 원래의 조건부 렌더링 로직 유지
+ if (isLoading || state.notices.length === 0 || !currentNotice) {
+ return null // 데이터 로드 중이거나 공지사항이 없거나 currentNotice가 없으면 null
+ }
+
+ return (
+ <Dialog open={state.isOpen} onOpenChange={handleClose}>
+ <DialogContent className="max-w-2xl max-h-[80vh]">
+ <DialogHeader>
+ <div className="flex items-center gap-2">
+ <AlertCircle className="h-5 w-5 text-blue-500" />
+ <DialogTitle className="text-xl">
+ 공지사항
+ </DialogTitle>
+ <Badge variant="outline">
+ {state.currentIndex + 1} / {state.notices.length}
+ </Badge>
+ </div>
+ <DialogDescription>
+ 중요한 공지사항을 확인해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 공지사항 정보 헤더 */}
+ <div className="bg-muted/50 rounded-lg p-4">
+ <div className="flex items-start justify-between">
+ <div className="flex-1">
+ <h3 className="font-semibold text-lg mb-2">
+ {currentNotice?.title}
+ </h3>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <div className="flex items-center gap-1">
+ <User className="h-4 w-4" />
+ {currentNotice?.authorName || "알 수 없음"}
+ </div>
+ <div className="flex items-center gap-1">
+ <Calendar className="h-4 w-4" />
+ {formatDate(currentNotice?.createdAt || new Date(), "KR")}
+ </div>
+ {currentNotice?.pagePath && (
+ <Badge variant="secondary" className="text-xs">
+ {currentNotice.pagePath}
+ </Badge>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 공지사항 내용 */}
+ <ScrollArea className="h-[300px] w-full">
+ <div
+ className="prose prose-sm max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: currentNotice?.content || ""
+ }}
+ />
+ </ScrollArea>
+
+ {/* 유효기간 정보 */}
+ {(currentNotice?.startAt || currentNotice?.endAt) && (
+ <>
+ <Separator />
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Clock className="h-4 w-4" />
+ <span>유효기간:</span>
+ {currentNotice.startAt && (
+ <span>시작 {formatDate(currentNotice.startAt, "KR")}</span>
+ )}
+ {currentNotice.startAt && currentNotice.endAt && <span> ~ </span>}
+ {currentNotice.endAt && (
+ <span>종료 {formatDate(currentNotice.endAt, "KR")}</span>
+ )}
+ </div>
+ </>
+ )}
+
+ {/* '다시 보지 않기' 설정 */}
+ <div className="flex items-center space-x-2 p-4 bg-muted/30 rounded-lg">
+ <Checkbox
+ id="dontShowToday"
+ checked={state.dontShowSettings[currentNotice?.id || 0] || false}
+ onCheckedChange={(checked) =>
+ handleDontShowChange(currentNotice?.id || 0, checked as boolean)
+ }
+ />
+ <label
+ htmlFor="dontShowToday"
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ 오늘은 더 이상 이 공지사항을 표시하지 않음
+ </label>
+ </div>
+ </div>
+
+ {/* 하단 버튼들 */}
+ <div className="flex items-center justify-between pt-4">
+ <div className="flex items-center gap-2">
+ {state.currentIndex > 0 && (
+ <Button variant="outline" onClick={handlePrevious}>
+ 이전
+ </Button>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2">
+ {state.currentIndex < state.notices.length - 1 ? (
+ <Button onClick={handleNext}>
+ 다음 ({state.notices.length - state.currentIndex - 1}개 남음)
+ </Button>
+ ) : (
+ <Button onClick={handleClose}>
+ 확인
+ </Button>
+ )}
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}