summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/information/information-button.tsx182
-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
-rw-r--r--db/schema/notice.ts12
-rw-r--r--lib/evaluation-submit/evaluation-form.tsx21
-rw-r--r--lib/notice/repository.ts23
-rw-r--r--lib/notice/service.ts35
-rw-r--r--lib/notice/storage-utils.ts213
-rw-r--r--lib/notice/validations.ts44
11 files changed, 1276 insertions, 23 deletions
diff --git a/components/information/information-button.tsx b/components/information/information-button.tsx
index e9163093..e03fffd9 100644
--- a/components/information/information-button.tsx
+++ b/components/information/information-button.tsx
@@ -9,6 +9,7 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
+ DialogDescription,
DialogTrigger,
} from "@/components/ui/dialog"
import { Info, Download, Edit, Loader2,Eye, EyeIcon } from "lucide-react"
@@ -18,6 +19,13 @@ import { UpdateInformationDialog } from "@/lib/information/table/update-informat
import { NoticeViewDialog } from "@/components/notice/notice-view-dialog"
// import { PDFTronViewerDialog } from "@/components/document-viewer/pdftron-viewer-dialog" // 주석 처리 - 브라우저 내장 뷰어 사용
import type { PageInformation, InformationAttachment } from "@/db/schema/information"
+import { isNoticeDontShowValid, setNoticeDontShow } from "@/lib/notice/storage-utils"
+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"
type PageInformationWithUpdatedBy = PageInformation & {
updatedByName?: string | null
@@ -39,10 +47,11 @@ interface InformationButtonProps {
type NoticeWithAuthor = Notice & {
authorName: string | null
authorEmail: string | null
+ isPopup?: boolean
}
-export function InformationButton({
- pagePath,
+export function InformationButton({
+ pagePath,
className,
variant = "ghost",
size = "icon"
@@ -58,6 +67,11 @@ export function InformationButton({
const [dataLoaded, setDataLoaded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [retryCount, setRetryCount] = useState(0)
+
+ // 강제 모달 관련 상태
+ const [forceModalNotice, setForceModalNotice] = useState<NoticeWithAuthor | null>(null)
+ const [isForceModalOpen, setIsForceModalOpen] = useState(false)
+ const [forceModalDontShow, setForceModalDontShow] = useState(false)
// const [viewerDialogOpen, setViewerDialogOpen] = useState(false) // 주석 처리 - 브라우저 내장 뷰어 사용
// const [selectedFile, setSelectedFile] = useState<InformationAttachment | null>(null) // 주석 처리 - 브라우저 내장 뷰어 사용
@@ -85,11 +99,14 @@ export function InformationButton({
// 순차적으로 데이터 조회 (프로덕션 안정성)
const infoResult = await getPageInformationDirect(normalizedPath)
const noticesResult = await getPageNotices(normalizedPath)
-
+
setInformation(infoResult)
setNotices(noticesResult)
setDataLoaded(true)
setRetryCount(0) // 성공시 재시도 횟수 리셋
+
+ // 강제 모달을 띄워야 할 공지사항 확인
+ checkForceModalNotices(noticesResult)
// 권한 확인 - 세션이 확실히 있을 때만
if (session?.user?.id && infoResult) {
@@ -119,9 +136,9 @@ export function InformationButton({
}
}, [pagePath, session?.user?.id, dataLoaded, retryCount])
- // 세션이 준비되면 자동으로 데이터 로드
+ // 세션이 준비되면 자동으로 데이터 로드 (버튼 클릭 시)
React.useEffect(() => {
- if (isOpen && !dataLoaded && session !== undefined) {
+ if (!dataLoaded && session !== undefined) {
loadData()
}
}, [isOpen, dataLoaded, session])
@@ -136,6 +153,59 @@ export function InformationButton({
}
}, [retryCount])
+ // 강제 모달을 띄워야 할 공지사항 확인 함수
+ const checkForceModalNotices = React.useCallback((noticesList: NoticeWithAuthor[]) => {
+ // 여기서 유효기간 필터까지 처리
+ if (!noticesList || noticesList.length === 0) return
+
+ const now = new Date()
+
+ for (const notice of noticesList) {
+ // 팝업 공지사항이 아니면 건너뛰기
+ if (!notice.isPopup) continue
+
+ // 유효기간 필터링: startAt과 endAt이 모두 null이거나, 현재가 범위 내에 있어야
+ const validStart = !notice.startAt || new Date(notice.startAt) <= now
+ const validEnd = !notice.endAt || new Date(notice.endAt) >= now
+ if (!(validStart && validEnd)) continue
+
+ // '다시 보지 않기' 설정 확인 (영구 설정만 확인)
+ const dontShowNever = isNoticeDontShowValid({
+ noticeId: notice.id,
+ duration: 'never'
+ })
+
+ // '다시 보지 않기' 설정이 없고, 현재 유효한 팝업 공지사항이면 강제 모달 표시
+ if (!dontShowNever) {
+ setForceModalNotice(notice)
+ setIsForceModalOpen(true)
+ setForceModalDontShow(false)
+ break // 첫 번째 강제 모달 대상만 처리
+ }
+ }
+ }, [])
+
+ // 강제 모달 닫기 핸들러
+ const handleForceModalClose = React.useCallback(() => {
+ if (forceModalDontShow && forceModalNotice) {
+ // '다시 보지 않기' 체크했으면 설정 저장 (영구로 설정)
+ setNoticeDontShow({
+ noticeId: forceModalNotice.id,
+ duration: 'never'
+ })
+ toast.success("설정이 저장되었습니다.")
+ }
+
+ setIsForceModalOpen(false)
+ setForceModalNotice(null)
+ setForceModalDontShow(false)
+ }, [forceModalDontShow, forceModalNotice])
+
+ // 강제 모달에서 '다시 보지 않기' 체크박스 변경 핸들러
+ const handleForceModalDontShowChange = React.useCallback((checked: boolean) => {
+ setForceModalDontShow(checked)
+ }, [])
+
// 다이얼로그 열기
const handleDialogOpen = (open: boolean) => {
setIsOpen(open)
@@ -408,6 +478,108 @@ export function InformationButton({
/>
)}
+ {/* 강제 모달 공지사항 다이얼로그 */}
+ <Dialog open={isForceModalOpen} onOpenChange={handleForceModalClose}>
+ <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">
+ 필독
+ </Badge>
+ </div>
+ <DialogDescription>
+ 중요한 공지사항을 확인해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {forceModalNotice && (
+ <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">
+ {forceModalNotice.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" />
+ {forceModalNotice.authorName || "알 수 없음"}
+ </div>
+ <div className="flex items-center gap-1">
+ <Calendar className="h-4 w-4" />
+ {formatDate(forceModalNotice.createdAt, "KR")}
+ </div>
+ {forceModalNotice.pagePath && (
+ <Badge variant="secondary" className="text-xs">
+ {forceModalNotice.pagePath}
+ </Badge>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 공지사항 내용 */}
+ <ScrollArea className="h-[300px] w-full">
+ <div
+ className="prose prose-sm max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: forceModalNotice.content || ""
+ }}
+ />
+ </ScrollArea>
+
+ {/* 유효기간 정보 */}
+ {(forceModalNotice.startAt || forceModalNotice.endAt) && (
+ <>
+ <Separator />
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Clock className="h-4 w-4" />
+ <span>유효기간:</span>
+ {forceModalNotice.startAt && (
+ <span>{formatDate(forceModalNotice.startAt, "KR")}</span>
+ )}
+ {forceModalNotice.startAt && forceModalNotice.endAt && <span> ~ </span>}
+ {forceModalNotice.endAt && (
+ <span>{formatDate(forceModalNotice.endAt, "KR")}</span>
+ )}
+ </div>
+ </>
+ )}
+
+ {/* '다시 보지 않기' 설정 */}
+ <div className="flex items-center space-x-2 p-4 bg-muted/30 rounded-lg">
+ <Checkbox
+ id="forceModalDontShow"
+ checked={forceModalDontShow}
+ onCheckedChange={handleForceModalDontShowChange}
+ />
+ <label
+ htmlFor="forceModalDontShow"
+ 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-end pt-4">
+ <Button onClick={handleForceModalClose}>
+ 확인
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+
{/* PDFTron 뷰어 다이얼로그 - 주석 처리 (브라우저 내장 뷰어 사용) */}
{/* <PDFTronViewerDialog
open={viewerDialogOpen}
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>
+ )
+}
diff --git a/db/schema/notice.ts b/db/schema/notice.ts
index c7cfee93..45715349 100644
--- a/db/schema/notice.ts
+++ b/db/schema/notice.ts
@@ -18,7 +18,17 @@ export const notice = pgTable("notice", {
// 활성화 여부
isActive: boolean("is_active").default(true).notNull(), // 활성화 여부
-
+
+ // 팝업 설정
+ isPopup: boolean("is_popup").default(false), // 팝업 여부
+
+ // 유효기간 설정
+ startAt: timestamp("start_at"), // 게시 시작 일시
+ endAt: timestamp("end_at"), // 게시 종료 일시
+
+ // '다시 보지 않기' 설정
+ dontShowDuration: varchar("dont_show_duration", { length: 50 }), // 'day' 또는 null/'never'
+
// 메타데이터
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
diff --git a/lib/evaluation-submit/evaluation-form.tsx b/lib/evaluation-submit/evaluation-form.tsx
index d51a0369..1f27b312 100644
--- a/lib/evaluation-submit/evaluation-form.tsx
+++ b/lib/evaluation-submit/evaluation-form.tsx
@@ -48,7 +48,7 @@ import {
import { DEPARTMENT_CODE_LABELS, divisionMap, vendortypeMap } from "@/types/evaluation"
import { EvaluationFormData } from "@/types/evaluation-form"
// 파일 다운로드 유틸리티 import
-import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download"
+// import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download"
interface EvaluationFormProps {
formData: EvaluationFormData
@@ -123,7 +123,9 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
// 첨부파일 다운로드 핸들러 - downloadFile 사용
const handleDownloadAttachment = async (attachment: AttachmentInfo) => {
try {
- await downloadFile(
+ const { downloadFile } = await import('@/lib/file-download')
+
+ const result = await downloadFile(
attachment.publicPath,
attachment.originalFileName,
{
@@ -134,10 +136,11 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
console.error("파일 다운로드 실패:", error)
},
onSuccess: (fileName, fileSize) => {
- console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : '')
+ // console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : '')
}
}
)
+ console.log("파일 다운로드 결과:", result)
} catch (error) {
console.error("다운로드 처리 중 오류:", error)
}
@@ -519,9 +522,9 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
<div className="text-muted-foreground">
총 <span className="font-medium text-foreground">{formData.attachmentStats.totalFiles}</span>개 파일
</div>
- <div className="text-muted-foreground">
+ {/* <div className="text-muted-foreground">
크기: <span className="font-medium text-foreground">{formatFileSize(formData.attachmentStats.totalSize)}</span>
- </div>
+ </div> */}
<div className="text-muted-foreground">
첨부 질문: <span className="font-medium text-foreground">{formData.attachmentStats.questionsWithAttachments}</span>개
</div>
@@ -731,18 +734,18 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
{questionAttachments.length > 0 && (
<div className="space-y-1">
{questionAttachments.map((attachment) => {
- const fileInfo = getFileInfo(attachment.originalFileName)
+ // const fileInfo = getFileInfo(attachment.originalFileName)
return (
<div key={attachment.id} className="flex items-center justify-between p-2 bg-muted rounded-md">
<div className="flex items-center gap-2 flex-1">
- <span className="text-sm">{fileInfo.icon}</span>
+ {/* <span className="text-sm">{fileInfo.icon}</span> */}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
{attachment.originalFileName}
</div>
- <div className="text-xs text-muted-foreground">
+ {/* <div className="text-xs text-muted-foreground">
{formatFileSize(attachment.fileSize)}
- </div>
+ </div> */}
</div>
</div>
<div className="flex items-center gap-1">
diff --git a/lib/notice/repository.ts b/lib/notice/repository.ts
index fb941ac9..897eb5b0 100644
--- a/lib/notice/repository.ts
+++ b/lib/notice/repository.ts
@@ -2,8 +2,10 @@ import { desc, eq, and, sql } from "drizzle-orm"
import db from "@/db/db"
import { notice, users, type Notice, type NewNotice } from "@/db/schema"
-// 페이지 경로별 공지사항 조회 (활성화된 것만, 작성자 정보 포함)
+// 페이지 경로별 공지사항 조회 (활성화된 것만, 작성자 정보 포함, 유효기간 내 공지사항만)
export async function getNoticesByPagePath(pagePath: string): Promise<Array<Notice & { authorName: string | null; authorEmail: string | null }>> {
+ const currentTime = new Date()
+
const result = await db
.select({
id: notice.id,
@@ -12,6 +14,10 @@ export async function getNoticesByPagePath(pagePath: string): Promise<Array<Noti
content: notice.content,
authorId: notice.authorId,
isActive: notice.isActive,
+ isPopup: notice.isPopup,
+ startAt: notice.startAt,
+ endAt: notice.endAt,
+ dontShowDuration: notice.dontShowDuration,
createdAt: notice.createdAt,
updatedAt: notice.updatedAt,
authorName: users.name,
@@ -21,7 +27,10 @@ export async function getNoticesByPagePath(pagePath: string): Promise<Array<Noti
.leftJoin(users, eq(notice.authorId, users.id))
.where(and(
eq(notice.pagePath, pagePath),
- eq(notice.isActive, true)
+ eq(notice.isActive, true),
+ // // 유효기간 필터링: startAt과 endAt이 모두 null이거나 현재 시간이 범위 내에 있어야 함
+ // sql`(${notice.startAt} IS NULL OR ${notice.startAt} <= ${currentTime})`,
+ // sql`(${notice.endAt} IS NULL OR ${notice.endAt} >= ${currentTime})`
))
.orderBy(desc(notice.createdAt))
@@ -32,7 +41,11 @@ export async function getNoticesByPagePath(pagePath: string): Promise<Array<Noti
export async function insertNotice(data: NewNotice): Promise<Notice> {
const result = await db
.insert(notice)
- .values(data)
+ .values({
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
.returning()
return result[0]
@@ -77,6 +90,10 @@ export async function getNoticeById(id: number): Promise<(Notice & { authorName:
content: notice.content,
authorId: notice.authorId,
isActive: notice.isActive,
+ isPopup: notice.isPopup,
+ startAt: notice.startAt,
+ endAt: notice.endAt,
+ dontShowDuration: notice.dontShowDuration,
createdAt: notice.createdAt,
updatedAt: notice.updatedAt,
authorName: users.name,
diff --git a/lib/notice/service.ts b/lib/notice/service.ts
index 9c05b98f..12f2ed2e 100644
--- a/lib/notice/service.ts
+++ b/lib/notice/service.ts
@@ -21,6 +21,23 @@ import {
import type { Notice } from "@/db/schema/notice"
+// 페이지별 공지사항 조회 (강제 모달용)
+export async function getPageNoticesForModal(pagePath: string): Promise<Array<Notice & { authorName: string | null; authorEmail: string | null }>> {
+ try {
+ console.log('🔍 Notice Service - 모달용 조회 시작:', { pagePath })
+ const result = await getNoticesByPagePath(pagePath)
+ console.log('📊 Notice Service - 모달용 조회 결과:', {
+ pagePath,
+ noticesCount: result.length,
+ notices: result.map(n => ({ id: n.id, title: n.title, pagePath: n.pagePath }))
+ })
+ return result
+ } catch (error) {
+ console.error(`Failed to get notices for modal on page ${pagePath}:`, error)
+ return []
+ }
+}
+
// 간단한 공지사항 목록 조회 (페이지네이션 없이 전체 조회)
export async function getNoticeLists(): Promise<{ data: Array<Notice & { authorName: string | null; authorEmail: string | null }> }> {
try {
@@ -33,6 +50,10 @@ export async function getNoticeLists(): Promise<{ data: Array<Notice & { authorN
content: notice.content,
authorId: notice.authorId,
isActive: notice.isActive,
+ isPopup: notice.isPopup,
+ startAt: notice.startAt,
+ endAt: notice.endAt,
+ dontShowDuration: notice.dontShowDuration,
createdAt: notice.createdAt,
updatedAt: notice.updatedAt,
authorName: users.name,
@@ -163,7 +184,19 @@ export async function deleteMultipleNotices(ids: number[]) {
// ID로 공지사항 조회
export async function getNoticeDetail(id: number): Promise<(Notice & { authorName: string | null; authorEmail: string | null }) | null> {
try {
- return await getNoticeById(id)
+ const result = await getNoticeById(id)
+ // 유효기간 검증 추가 (현재 시간이 유효기간 내에 있는지 확인)
+ if (result) {
+ const currentTime = new Date()
+ const isValid = (!result.startAt || result.startAt <= currentTime) &&
+ (!result.endAt || result.endAt >= currentTime)
+
+ if (!isValid) {
+ console.log(`Notice ${id} is not in valid time range`)
+ return null
+ }
+ }
+ return result
} catch (error) {
console.error(`Failed to get notice detail for id ${id}:`, error)
return null
diff --git a/lib/notice/storage-utils.ts b/lib/notice/storage-utils.ts
new file mode 100644
index 00000000..41a512d8
--- /dev/null
+++ b/lib/notice/storage-utils.ts
@@ -0,0 +1,213 @@
+/**
+ * 공지사항 로컬 스토리지 유틸리티 함수들
+ * '다시 보지 않기' 기능을 위한 저장 및 확인 로직을 제공합니다.
+ */
+
+export interface NoticeStorageOptions {
+ noticeId: number
+ duration: 'day' | 'never'
+}
+
+/**
+ * 공지사항 '다시 보지 않기' 설정을 로컬 스토리지에 저장합니다.
+ * @param options - 공지사항 ID와 기간 설정
+ */
+export function setNoticeDontShow(options: NoticeStorageOptions): void {
+ const { noticeId, duration } = options
+
+ if (typeof window === 'undefined') {
+ console.warn('setNoticeDontShow: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.')
+ return
+ }
+
+ try {
+ const key = `notice-${noticeId}-${duration}`
+ let value: string
+
+ if (duration === 'day') {
+ // 오늘 자정까지의 타임스탬프 계산
+ const today = new Date()
+ const tomorrow = new Date(today)
+ tomorrow.setDate(tomorrow.getDate() + 1)
+ tomorrow.setHours(0, 0, 0, 0) // 자정으로 설정
+
+ value = tomorrow.getTime().toString()
+ } else if (duration === 'never') {
+ // 영구적으로 저장 (매우 먼 미래의 날짜)
+ const neverDate = new Date('2099-12-31T23:59:59.999Z')
+ value = neverDate.getTime().toString()
+ } else {
+ throw new Error(`지원하지 않는 기간 설정입니다: ${duration}`)
+ }
+
+ localStorage.setItem(key, value)
+ console.log(`공지사항 ${noticeId}의 '다시 보지 않기' 설정을 저장했습니다:`, { key, value, duration })
+ } catch (error) {
+ console.error('공지사항 설정 저장 중 오류 발생:', error)
+ }
+}
+
+/**
+ * 공지사항의 '다시 보지 않기' 설정이 유효한지 확인합니다.
+ * @param options - 공지사항 ID와 기간 설정
+ * @returns 설정이 유효하면 true, 만료되었거나 설정되지 않았으면 false
+ */
+export function isNoticeDontShowValid(options: NoticeStorageOptions): boolean {
+ const { noticeId, duration } = options
+
+ if (typeof window === 'undefined') {
+ console.warn('isNoticeDontShowValid: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.')
+ return false
+ }
+
+ try {
+ const key = `notice-${noticeId}-${duration}`
+ const storedValue = localStorage.getItem(key)
+
+ if (!storedValue) {
+ return false // 설정이 없음
+ }
+
+ const expirationTime = parseInt(storedValue, 10)
+ const currentTime = Date.now()
+
+ if (isNaN(expirationTime)) {
+ console.warn(`잘못된 만료 시간 값입니다: ${storedValue}`)
+ localStorage.removeItem(key) // 잘못된 값은 삭제
+ return false
+ }
+
+ const isValid = currentTime < expirationTime
+
+ if (!isValid) {
+ // 만료된 설정은 자동으로 삭제
+ localStorage.removeItem(key)
+ console.log(`공지사항 ${noticeId}의 만료된 설정을 삭제했습니다.`)
+ }
+
+ return isValid
+ } catch (error) {
+ console.error('공지사항 설정 확인 중 오류 발생:', error)
+ return false
+ }
+}
+
+/**
+ * 특정 공지사항의 모든 '다시 보지 않기' 설정을 초기화합니다.
+ * @param noticeId - 공지사항 ID
+ */
+export function clearNoticeDontShowSettings(noticeId: number): void {
+ if (typeof window === 'undefined') {
+ console.warn('clearNoticeDontShowSettings: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.')
+ return
+ }
+
+ try {
+ const keysToRemove: string[] = []
+
+ // 모든 가능한 키 패턴을 확인
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i)
+ if (key && key.startsWith(`notice-${noticeId}-`)) {
+ keysToRemove.push(key)
+ }
+ }
+
+ // 모든 관련 키 삭제
+ keysToRemove.forEach(key => localStorage.removeItem(key))
+
+ if (keysToRemove.length > 0) {
+ console.log(`공지사항 ${noticeId}의 모든 '다시 보지 않기' 설정을 초기화했습니다:`, keysToRemove)
+ }
+ } catch (error) {
+ console.error('공지사항 설정 초기화 중 오류 발생:', error)
+ }
+}
+
+/**
+ * 모든 공지사항의 '다시 보지 않기' 설정을 초기화합니다.
+ * 주의: 이 함수는 모든 공지사항 설정을 삭제하므로 신중하게 사용해야 합니다.
+ */
+export function clearAllNoticeDontShowSettings(): void {
+ if (typeof window === 'undefined') {
+ console.warn('clearAllNoticeDontShowSettings: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.')
+ return
+ }
+
+ try {
+ const keysToRemove: string[] = []
+
+ // 모든 가능한 키 패턴을 확인
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i)
+ if (key && key.startsWith('notice-')) {
+ keysToRemove.push(key)
+ }
+ }
+
+ // 모든 관련 키 삭제
+ keysToRemove.forEach(key => localStorage.removeItem(key))
+
+ if (keysToRemove.length > 0) {
+ console.log(`모든 공지사항의 '다시 보지 않기' 설정을 초기화했습니다: ${keysToRemove.length}개`)
+ }
+ } catch (error) {
+ console.error('모든 공지사항 설정 초기화 중 오류 발생:', error)
+ }
+}
+
+/**
+ * 현재 로컬 스토리지에서 공지사항과 관련된 모든 키를 조회합니다.
+ * 디버깅 목적으로 사용됩니다.
+ */
+export function getAllNoticeStorageKeys(): string[] {
+ if (typeof window === 'undefined') {
+ console.warn('getAllNoticeStorageKeys: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.')
+ return []
+ }
+
+ try {
+ const keys: string[] = []
+
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i)
+ if (key && key.startsWith('notice-')) {
+ keys.push(key)
+ }
+ }
+
+ return keys
+ } catch (error) {
+ console.error('공지사항 키 조회 중 오류 발생:', error)
+ return []
+ }
+}
+
+/**
+ * 특정 공지사항의 현재 설정 상태를 조회합니다.
+ * 디버깅 목적으로 사용됩니다.
+ */
+export function getNoticeStorageInfo(noticeId: number): Array<{ key: string; value: string | null; duration: string }> {
+ if (typeof window === 'undefined') {
+ console.warn('getNoticeStorageInfo: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.')
+ return []
+ }
+
+ try {
+ const info: Array<{ key: string; value: string | null; duration: string }> = []
+
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i)
+ if (key && key.startsWith(`notice-${noticeId}-`)) {
+ const value = localStorage.getItem(key)
+ const duration = key.split('-').pop() || 'unknown'
+ info.push({ key, value, duration })
+ }
+ }
+
+ return info
+ } catch (error) {
+ console.error('공지사항 정보 조회 중 오류 발생:', error)
+ return []
+ }
+}
diff --git a/lib/notice/validations.ts b/lib/notice/validations.ts
index 146f8e09..6fca1bea 100644
--- a/lib/notice/validations.ts
+++ b/lib/notice/validations.ts
@@ -7,6 +7,28 @@ export const createNoticeSchema = z.object({
content: z.string().min(1, "내용을 입력해주세요"),
authorId: z.number().min(1, "작성자를 선택해주세요"),
isActive: z.boolean().default(true),
+ isPopup: z.boolean().default(false),
+ startAt: z.date().optional(),
+ endAt: z.date().optional(),
+ dontShowDuration: z.string().transform((val) => val as 'day' | 'never').optional().default('never'),
+}).refine((data) => {
+ // 팝업인 경우 시작일시와 종료일시는 필수
+ if (data.isPopup) {
+ return data.startAt && data.endAt;
+ }
+ return true;
+}, {
+ message: "팝업 공지사항은 시작일시와 종료일시를 모두 설정해야 합니다.",
+ path: ["startAt"],
+}).refine((data) => {
+ // 팝업인 경우에만 시작일시와 종료일시가 모두 설정된 경우 종료일이 시작일보다 과거인지 확인
+ if (data.isPopup && data.startAt && data.endAt) {
+ return data.endAt >= data.startAt;
+ }
+ return true;
+}, {
+ message: "종료일시는 시작일시보다 과거일 수 없습니다.",
+ path: ["endAt"],
})
// 공지사항 수정 스키마
@@ -16,6 +38,28 @@ export const updateNoticeSchema = z.object({
title: z.string().min(1, "제목을 입력해주세요"),
content: z.string().min(1, "내용을 입력해주세요"),
isActive: z.boolean().default(true),
+ isPopup: z.boolean().default(false),
+ startAt: z.date().optional(),
+ endAt: z.date().optional(),
+ dontShowDuration: z.string().transform((val) => val as 'day' | 'never').optional().default('never'),
+}).refine((data) => {
+ // 팝업인 경우 시작일시와 종료일시는 필수
+ if (data.isPopup) {
+ return data.startAt && data.endAt;
+ }
+ return true;
+}, {
+ message: "팝업 공지사항은 시작일시와 종료일시를 모두 설정해야 합니다.",
+ path: ["startAt"],
+}).refine((data) => {
+ // 팝업인 경우에만 시작일시와 종료일시가 모두 설정된 경우 종료일이 시작일보다 과거인지 확인
+ if (data.isPopup && data.startAt && data.endAt) {
+ return data.endAt >= data.startAt;
+ }
+ return true;
+}, {
+ message: "종료일시는 시작일시보다 과거일 수 없습니다.",
+ path: ["endAt"],
})
// 페이지 경로별 공지사항 조회 스키마