From 795b4915069c44f500a91638e16ded67b9e16618 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 1 Jul 2025 11:46:33 +0000 Subject: (최겸) 정보시스템 공지사항 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/additional-info/join-form.tsx | 5 +- .../additional-info/tech-vendor-info-form.tsx | 7 +- .../document-lists/vendor-doc-list-client.tsx | 7 +- components/documents/vendor-docs.client.tsx | 7 +- components/information/information-button.tsx | 306 +++++++------- components/information/information-client.tsx | 340 ++++++++++++++++ components/items-tech/item-tech-container.tsx | 7 +- components/notice/notice-client.tsx | 438 +++++++++++++++++++++ components/notice/notice-create-dialog.tsx | 216 ++++++++++ components/notice/notice-edit-sheet.tsx | 246 ++++++++++++ components/notice/notice-view-dialog.tsx | 56 +++ lib/notice/repository.ts | 244 ++++++++++++ lib/notice/service.ts | 324 +++++++++++++++ lib/notice/validations.ts | 80 ++++ 14 files changed, 2121 insertions(+), 162 deletions(-) create mode 100644 components/information/information-client.tsx create mode 100644 components/notice/notice-client.tsx create mode 100644 components/notice/notice-create-dialog.tsx create mode 100644 components/notice/notice-edit-sheet.tsx create mode 100644 components/notice/notice-view-dialog.tsx create mode 100644 lib/notice/repository.ts create mode 100644 lib/notice/service.ts create mode 100644 lib/notice/validations.ts diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx index 4a9a3379..b6cb0d9c 100644 --- a/components/additional-info/join-form.tsx +++ b/components/additional-info/join-form.tsx @@ -79,7 +79,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" - +import { InformationButton } from "@/components/information/information-button" i18nIsoCountries.registerLocale(enLocale) i18nIsoCountries.registerLocale(koLocale) @@ -535,11 +535,14 @@ const handleDownloadAllFiles = async () => {
+

{t("infoForm.title", { defaultValue: "Update Vendor Information", })}

+ +

{t("infoForm.description", { defaultValue: diff --git a/components/additional-info/tech-vendor-info-form.tsx b/components/additional-info/tech-vendor-info-form.tsx index 8e6f7eaf..55d01d21 100644 --- a/components/additional-info/tech-vendor-info-form.tsx +++ b/components/additional-info/tech-vendor-info-form.tsx @@ -30,7 +30,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" - +import { InformationButton } from "@/components/information/information-button" // 타입 정의 interface TechVendorContact { id: number @@ -251,7 +251,10 @@ export function TechVendorInfoForm() {

-

기술영업 벤더 정보

+
+

기술영업 벤더 정보

+ +

기술영업 벤더 정보를 확인하고 업데이트할 수 있습니다.

{attachments.length > 0 && ( diff --git a/components/document-lists/vendor-doc-list-client.tsx b/components/document-lists/vendor-doc-list-client.tsx index d914b6f0..2bd7d996 100644 --- a/components/document-lists/vendor-doc-list-client.tsx +++ b/components/document-lists/vendor-doc-list-client.tsx @@ -4,7 +4,7 @@ import { useRouter, useParams } from "next/navigation" import DocumentContainer from "@/components/documents/document-container" import { ProjectInfo, ProjectSwitcher } from "@/components/documents/project-swicher" - +import { InformationButton } from "@/components/information/information-button" interface VendorDocumentsClientProps { projects: ProjectInfo[] children: React.ReactNode @@ -60,7 +60,10 @@ export default function VendorDocumentListClient({
{/* 왼쪽: 타이틀 & 설명 */}
-

Vendor Document List

+
+

Vendor Document List

+ +

{projectType === "ship" ? "삼성중공업 문서시스템으로부터 목록을 가져오고 문서 파일을 등록하여 삼성중공업으로 전달할 수 있습니다." diff --git a/components/documents/vendor-docs.client.tsx b/components/documents/vendor-docs.client.tsx index 9bb7988c..ebc30b83 100644 --- a/components/documents/vendor-docs.client.tsx +++ b/components/documents/vendor-docs.client.tsx @@ -5,7 +5,7 @@ import { useRouter, useParams } from "next/navigation" import DocumentContainer from "@/components/documents/document-container" import { ProjectInfo, ProjectSwitcher } from "./project-swicher" - +import { InformationButton } from "@/components/information/information-button" interface VendorDocumentsClientProps { projects: ProjectInfo[] children: React.ReactNode @@ -55,7 +55,10 @@ export default function VendorDocumentsClient({

{/* 왼쪽: 타이틀 & 설명 */}
-

Vendor Documents

+
+

Vendor Documents

+ +

문서리스트를 확인하고 리스트에 맞게 문서를 업로드하고 관리할 수 있으며 삼성중공업으로 전달할 수 있습니다. diff --git a/components/information/information-button.tsx b/components/information/information-button.tsx index da0de548..38e8cb12 100644 --- a/components/information/information-button.tsx +++ b/components/information/information-button.tsx @@ -1,137 +1,129 @@ "use client" -import React, { useState, useEffect } from "react" -import { Info, Download, Edit } from "lucide-react" +import * as React from "react" +import { useState } from "react" import { Button } from "@/components/ui/button" + import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" +import { Info, Download, Edit } from "lucide-react" import { getCachedPageInformation, getCachedEditPermission } from "@/lib/information/service" +import { getCachedPageNotices } from "@/lib/notice/service" import { UpdateInformationDialog } from "@/lib/information/table/update-information-dialog" +import { NoticeViewDialog } from "@/components/notice/notice-view-dialog" import type { PageInformation } from "@/db/schema/information" +import type { Notice } from "@/db/schema/notice" import { useSession } from "next-auth/react" +import { formatDate } from "@/lib/utils" interface InformationButtonProps { - pageCode: string + pagePath: string className?: string variant?: "default" | "outline" | "ghost" | "secondary" size?: "default" | "sm" | "lg" | "icon" } +type NoticeWithAuthor = Notice & { + authorName: string | null + authorEmail: string | null +} + export function InformationButton({ - pageCode, + pagePath, className, variant = "ghost", size = "icon" }: InformationButtonProps) { const { data: session } = useSession() - const [information, setInformation] = useState(null) - const [isLoading, setIsLoading] = useState(false) const [isOpen, setIsOpen] = useState(false) + const [information, setInformation] = useState(null) + const [notices, setNotices] = useState([]) const [hasEditPermission, setHasEditPermission] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) + const [selectedNotice, setSelectedNotice] = useState(null) + const [isNoticeViewDialogOpen, setIsNoticeViewDialogOpen] = useState(false) + const [dataLoaded, setDataLoaded] = useState(false) - useEffect(() => { - if (isOpen && !information) { - loadInformation() - } - }, [isOpen, information]) - - // 편집 권한 확인 - useEffect(() => { - const checkEditPermission = async () => { - if (session?.user?.id) { - try { - const permission = await getCachedEditPermission(pageCode, session.user.id) - setHasEditPermission(permission) - } catch (error) { - console.error("Failed to check edit permission:", error) - setHasEditPermission(false) - } - } - } + // 데이터 로드 함수 (단순화) + const loadData = React.useCallback(async () => { + if (dataLoaded) return // 이미 로드되었으면 중복 방지 - checkEditPermission() - }, [pageCode, session?.user?.id]) - - const loadInformation = async () => { - setIsLoading(true) try { - const data = await getCachedPageInformation(pageCode) - setInformation(data) + // pagePath 정규화 (앞의 / 제거) + const normalizedPath = pagePath.startsWith('/') ? pagePath.slice(1) : pagePath + + // 병렬로 데이터 조회 + const [infoResult, noticesResult] = await Promise.all([ + getCachedPageInformation(normalizedPath), + getCachedPageNotices(normalizedPath) + ]) + + setInformation(infoResult) + setNotices(noticesResult) + setDataLoaded(true) + + // 권한 확인 + if (session?.user?.id) { + const hasPermission = await getCachedEditPermission(normalizedPath, session.user.id) + setHasEditPermission(hasPermission) + } } catch (error) { - console.error("Failed to load information:", error) - } finally { - setIsLoading(false) + console.error("데이터 로딩 중 오류:", error) } - } + }, [pagePath, session?.user?.id, dataLoaded]) - const handleDownload = () => { - if (information?.attachmentFilePath && information?.attachmentFileName) { - const link = document.createElement('a') - link.href = information.attachmentFilePath - link.download = information.attachmentFileName - document.body.appendChild(link) - link.click() - document.body.removeChild(link) + // 다이얼로그 열기 + const handleDialogOpen = (open: boolean) => { + setIsOpen(open) + + if (open && !dataLoaded) { + loadData() } } + // 편집 관련 핸들러 const handleEditClick = () => { setIsEditDialogOpen(true) } - const handleEditClose = () => { + const handleEditSuccess = () => { setIsEditDialogOpen(false) - refreshInformation() + // 편집 후 데이터 다시 로드 + setDataLoaded(false) + loadData() + } + + // 공지사항 클릭 핸들러 + const handleNoticeClick = (notice: NoticeWithAuthor) => { + setSelectedNotice(notice) + setIsNoticeViewDialogOpen(true) } - const refreshInformation = () => { - // 편집 후 정보 다시 로드 - setInformation(null) - if (isOpen) { - loadInformation() + // 파일 다운로드 핸들러 + const handleDownload = () => { + if (information?.attachmentFilePath) { + window.open(information.attachmentFilePath, '_blank') } - // 캐시 무효화를 위해 다시 확인 - setTimeout(() => { - loadInformation() - }, 500) } - // 인포메이션이 없으면 버튼을 숨김 - const [hasInformation, setHasInformation] = useState(null) - useEffect(() => { - const checkInformation = async () => { - try { - const data = await getCachedPageInformation(pageCode) - setHasInformation(!!data) - } catch { - setHasInformation(false) - } - } - checkInformation() - }, [pageCode]) - // 인포메이션이 없으면 버튼을 숨김 - if (hasInformation === false) { - return null - } + return ( <> -

+
- - -
- {isLoading ? ( -
-
- ) : information ? ( - <> - {/* 공지사항 */} - {(information.noticeTitle || information.noticeContent) && ( -
-
-

공지사항

-
-
- {information.noticeTitle && ( -
- 제목: {information.noticeTitle} -
- )} - {information.noticeContent && ( -
-
- {information.noticeContent} -
-
- )} -
-
- )} - - {/* 페이지 정보 */} -
-

도움말

-
-
- {information.description || "페이지 설명이 없습니다."} -
+
+
+ {information.informationContent}
+
+ )} - {/* 첨부파일 */} -
-

첨부파일

- {information.attachmentFileName ? ( -
-
-
-
- {information.attachmentFileName} -
- {information.attachmentFileSize && ( -
- {information.attachmentFileSize} -
- )} -
- + {/* 첨부파일 */} + {information?.attachmentFileName && ( +
+

첨부파일

+
+
+
+
+ {information.attachmentFileName}
+ {information.attachmentFileSize && ( +
+ {information.attachmentFileSize} +
+ )}
- ) : ( -
- -

첨부된 파일이 없습니다.

-
- )} + +
- - ) : ( +
+ )} + + {!information && notices.length === 0 && (
- 이 페이지에 대한 정보가 없습니다. +

이 페이지에 대한 정보가 없습니다.

)}
+ {/* 공지사항 보기 다이얼로그 */} + + {/* 편집 다이얼로그 */} {information && ( )} diff --git a/components/information/information-client.tsx b/components/information/information-client.tsx new file mode 100644 index 00000000..513b8f20 --- /dev/null +++ b/components/information/information-client.tsx @@ -0,0 +1,340 @@ +"use client" + +import { useState, useEffect, useTransition } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { + Search, + Edit, + FileText, + ChevronUp, + ChevronDown, + Download +} from "lucide-react" +import { toast } from "sonner" +import { formatDate } from "@/lib/utils" +import { getInformationLists } from "@/lib/information/service" +import type { PageInformation } from "@/db/schema/information" +import { UpdateInformationDialog } from "@/lib/information/table/update-information-dialog" + +interface InformationClientProps { + initialData?: PageInformation[] +} + +type SortField = "pageName" | "pagePath" | "createdAt" +type SortDirection = "asc" | "desc" + +export function InformationClient({ initialData = [] }: InformationClientProps) { + const [informations, setInformations] = useState(initialData) + const [loading, setLoading] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [sortField, setSortField] = useState("createdAt") + const [sortDirection, setSortDirection] = useState("desc") + const [editingInformation, setEditingInformation] = useState(null) + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) + const [, startTransition] = useTransition() + + // 정보 목록 조회 + const fetchInformations = async () => { + try { + setLoading(true) + const search = searchQuery || undefined + + startTransition(async () => { + const result = await getInformationLists({ + page: 1, + perPage: 50, + search: search, + sort: [{ id: sortField, desc: sortDirection === "desc" }], + flags: [], + filters: [], + joinOperator: "and", + pagePath: "", + pageName: "", + informationContent: "", + isActive: null, + }) + + if (result?.data) { + setInformations(result.data) + } else { + toast.error("정보 목록을 가져오는데 실패했습니다.") + } + setLoading(false) + }) + } catch (error) { + console.error("Error fetching informations:", error) + toast.error("정보 목록을 가져오는데 실패했습니다.") + setLoading(false) + } + } + + // 검색 핸들러 + const handleSearch = () => { + fetchInformations() + } + + // 정렬 함수 + const sortInformations = (informations: PageInformation[]) => { + return [...informations].sort((a, b) => { + let aValue: string | Date + let bValue: string | Date + + if (sortField === "pageName") { + aValue = a.pageName + bValue = b.pageName + } else if (sortField === "pagePath") { + aValue = a.pagePath + bValue = b.pagePath + } else { + aValue = new Date(a.createdAt) + bValue = new Date(b.createdAt) + } + + if (aValue < bValue) { + return sortDirection === "asc" ? -1 : 1 + } + if (aValue > bValue) { + return sortDirection === "asc" ? 1 : -1 + } + return 0 + }) + } + + // 정렬 핸들러 + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc") + } else { + setSortField(field) + setSortDirection("asc") + } + } + + // 편집 핸들러 + const handleEdit = (information: PageInformation) => { + setEditingInformation(information) + setIsEditDialogOpen(true) + } + + // 편집 완료 핸들러 + const handleEditClose = () => { + setIsEditDialogOpen(false) + setEditingInformation(null) + // 데이터 새로고침 + fetchInformations() + } + + // 다운로드 핸들러 + const handleDownload = (information: PageInformation) => { + if (information.attachmentFilePath && information.attachmentFileName) { + const link = document.createElement('a') + link.href = information.attachmentFilePath + link.download = information.attachmentFileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + } + + // 정렬된 정보 목록 + const sortedInformations = sortInformations(informations) + + useEffect(() => { + if (initialData.length > 0) { + setInformations(initialData) + } else { + fetchInformations() + } + }, []) + + useEffect(() => { + if (searchQuery !== "") { + fetchInformations() + } else if (initialData.length > 0) { + setInformations(initialData) + } + }, [searchQuery]) + + return ( +
+ {/* 검색 */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + onKeyPress={(e) => e.key === "Enter" && handleSearch()} + /> +
+ + +
+
+ + {/* 정보 테이블 */} +
+ + + + + + + + + + 정보 내용 + 첨부파일 + 상태 + + + + 작업 + + + + {loading ? ( + + + 로딩 중... + + + ) : informations.length === 0 ? ( + + + 정보가 없습니다. + + + ) : ( + sortedInformations.map((information) => ( + + +
+ + + {information.pageName} + +
+
+ + + {information.pagePath} + + + +
+ + + {information.attachmentFileName ? ( + + ) : ( + 없음 + )} + + + + {information.isActive ? "활성" : "비활성"} + + + + {formatDate(information.createdAt)} + + + + + + )) + )} + +
+
+ + {/* 편집 다이얼로그 */} + {editingInformation && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/components/items-tech/item-tech-container.tsx b/components/items-tech/item-tech-container.tsx index c09f684a..260f3e64 100644 --- a/components/items-tech/item-tech-container.tsx +++ b/components/items-tech/item-tech-container.tsx @@ -11,7 +11,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" - +import { InformationButton } from "@/components/information/information-button" interface ItemType { id: string name: string @@ -56,7 +56,10 @@ export function ItemTechContainer({
{/* 왼쪽: 타이틀 & 설명 */}
-

자재 관리

+
+

자재 관리

+ +

조선 및 해양 자재를 등록하고 관리할 수 있습니다.

diff --git a/components/notice/notice-client.tsx b/components/notice/notice-client.tsx new file mode 100644 index 00000000..fab0d758 --- /dev/null +++ b/components/notice/notice-client.tsx @@ -0,0 +1,438 @@ +"use client" + +import { useState, useEffect, useTransition } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { + Search, + Edit, + FileText, + ChevronUp, + ChevronDown, + Plus, + Eye, + Trash2 +} from "lucide-react" +import { toast } from "sonner" +import { formatDate } from "@/lib/utils" +import { getNoticeLists, deleteNotice, getPagePathList } from "@/lib/notice/service" +import type { Notice } from "@/db/schema/notice" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { UpdateNoticeSheet } from "./notice-edit-sheet" +import { NoticeCreateDialog } from "./notice-create-dialog" +import { NoticeViewDialog } from "./notice-view-dialog" + +type NoticeWithAuthor = Notice & { + authorName: string | null + authorEmail: string | null +} + +interface NoticeClientProps { + initialData?: NoticeWithAuthor[] + currentUserId?: number +} + +type SortField = "title" | "pagePath" | "createdAt" +type SortDirection = "asc" | "desc" + +export function NoticeClient({ initialData = [], currentUserId }: NoticeClientProps) { + const [notices, setNotices] = useState(initialData) + const [loading, setLoading] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [sortField, setSortField] = useState("createdAt") + const [sortDirection, setSortDirection] = useState("desc") + const [, startTransition] = useTransition() + const [isEditSheetOpen, setIsEditSheetOpen] = useState(false) + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [isViewDialogOpen, setIsViewDialogOpen] = useState(false) + const [selectedNotice, setSelectedNotice] = useState(null) + const [pagePathOptions, setPagePathOptions] = useState>([]) + // 공지사항 목록 조회 + const fetchNotices = async () => { + try { + setLoading(true) + const search = searchQuery || undefined + + startTransition(async () => { + const result = await getNoticeLists({ + page: 1, + perPage: 50, + search: search, + sort: [{ id: sortField, desc: sortDirection === "desc" }], + flags: [], + filters: [], + joinOperator: "and", + pagePath: "", + title: "", + content: "", + authorId: null, + isActive: null, + from: "", + to: "", + }) + + if (result?.data) { + setNotices(result.data) + } else { + toast.error("공지사항 목록을 가져오는데 실패했습니다.") + } + setLoading(false) + }) + } catch (error) { + console.error("Error fetching notices:", error) + toast.error("공지사항 목록을 가져오는데 실패했습니다.") + setLoading(false) + } + } + + // 검색 핸들러 + const handleSearch = () => { + fetchNotices() + } + + // 정렬 함수 + const sortNotices = (notices: NoticeWithAuthor[]) => { + return [...notices].sort((a, b) => { + let aValue: string | Date + let bValue: string | Date + + if (sortField === "title") { + aValue = a.title + bValue = b.title + } else if (sortField === "pagePath") { + aValue = a.pagePath + bValue = b.pagePath + } else { + aValue = new Date(a.createdAt) + bValue = new Date(b.createdAt) + } + + if (aValue < bValue) { + return sortDirection === "asc" ? -1 : 1 + } + if (aValue > bValue) { + return sortDirection === "asc" ? 1 : -1 + } + return 0 + }) + } + + // 정렬 핸들러 + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc") + } else { + setSortField(field) + setSortDirection("asc") + } + } + + // 삭제 핸들러 + const handleDelete = async (notice: NoticeWithAuthor) => { + try { + const result = await deleteNotice(notice.id) + + if (result.success) { + toast.success(result.message) + setNotices(notices.filter(n => n.id !== notice.id)) + } else { + toast.error(result.message) + } + } catch (error) { + console.error("Error deleting notice:", error) + toast.error("공지사항 삭제에 실패했습니다.") + } + } + + // 정렬된 공지사항 목록 + const sortedNotices = sortNotices(notices) + + // 페이지 경로 옵션 로딩 + const loadPagePathOptions = async () => { + try { + const paths = await getPagePathList() + const options = paths.map(path => ({ + value: path.pagePath, + label: `${path.pageName} (${path.pagePath})` + })) + setPagePathOptions(options) + } catch (error) { + console.error("페이지 경로 로딩 실패:", error) + } + } + + // View 다이얼로그 열기 + const handleViewNotice = (notice: NoticeWithAuthor) => { + setSelectedNotice(notice) + setIsViewDialogOpen(true) + } + + // Edit Sheet 열기 + const handleEditNotice = (notice: NoticeWithAuthor) => { + setSelectedNotice(notice) + setIsEditSheetOpen(true) + } + + // Create Dialog 열기 + const handleCreateNotice = () => { + setIsCreateDialogOpen(true) + } + + useEffect(() => { + if (initialData.length > 0) { + setNotices(initialData) + } else { + fetchNotices() + } + loadPagePathOptions() + }, []) + + useEffect(() => { + if (searchQuery !== "") { + fetchNotices() + } else if (initialData.length > 0) { + setNotices(initialData) + } + }, [searchQuery]) + + return ( +
+ {/* 검색 및 추가 버튼 */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + onKeyPress={(e) => e.key === "Enter" && handleSearch()} + /> +
+ + +
+ +
+ + {/* 공지사항 테이블 */} +
+ + + + + + + + + + 작성자 + 상태 + + + + 작업 + + + + {loading ? ( + + + 로딩 중... + + + ) : notices.length === 0 ? ( + + + 공지사항이 없습니다. + + + ) : ( + sortedNotices.map((notice) => ( + + +
+ + + {notice.title} + +
+
+ + + {notice.pagePath} + + + +
+ + {notice.authorName || "알 수 없음"} + + {notice.authorEmail && ( + + {notice.authorEmail} + + )} +
+
+ + + {notice.isActive ? "활성" : "비활성"} + + + + {formatDate(notice.createdAt)} + + +
+ {/* View 버튼 - 다이얼로그 방식 */} + + + {/* Edit 버튼 - 다이얼로그 방식 */} + + + {/* 기존 페이지 방식 (비교용) + + + */} + + + + + + + + 공지사항 삭제 + + 이 공지사항을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + handleDelete(notice)} + className="bg-red-600 hover:bg-red-700" + > + 삭제 + + + + +
+
+
+ )) + )} +
+
+
+ + {/* 다이얼로그들과 시트 - 테이블 밖에서 단일 렌더링 */} + + + + + +
+ ) +} \ No newline at end of file diff --git a/components/notice/notice-create-dialog.tsx b/components/notice/notice-create-dialog.tsx new file mode 100644 index 00000000..e3ce16a1 --- /dev/null +++ b/components/notice/notice-create-dialog.tsx @@ -0,0 +1,216 @@ +"use client" + +import * as React from "react" +import { useState } from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { Loader } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import TiptapEditor from "@/components/qna/tiptap-editor" +import { createNotice } from "@/lib/notice/service" +import { createNoticeSchema, type CreateNoticeSchema } from "@/lib/notice/validations" + +interface NoticeCreateDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + pagePathOptions: Array<{ value: string; label: string }> + currentUserId?: number + onSuccess?: () => void +} + +export function NoticeCreateDialog({ + open, + onOpenChange, + pagePathOptions, + currentUserId, + onSuccess, +}: NoticeCreateDialogProps) { + const [isLoading, setIsLoading] = useState(false) + + const form = useForm({ + resolver: zodResolver(createNoticeSchema), + defaultValues: { + pagePath: "", + title: "", + content: "", + authorId: currentUserId, + isActive: true, + }, + }) + + React.useEffect(() => { + if (open) { + // 다이얼로그가 열릴 때마다 폼 초기화 + form.reset({ + pagePath: "", + title: "", + content: "", + authorId: currentUserId, + isActive: true, + }) + } + }, [open, currentUserId, form]) + + const onSubmit = async (values: CreateNoticeSchema) => { + setIsLoading(true) + console.log("Form values:", values) // 디버깅용 + try { + const result = await createNotice(values) + console.log("Create result:", result) // 디버깅용 + + if (result.success) { + toast.success(result.message || "공지사항이 성공적으로 생성되었습니다.") + if (onSuccess) onSuccess() + onOpenChange(false) + } else { + toast.error(result.message || "공지사항 생성에 실패했습니다.") + console.error("Create failed:", result.message) + } + } catch (error) { + toast.error("공지사항 생성에 실패했습니다.") + console.error("Create error:", error) + } finally { + setIsLoading(false) + } + } + + + + return ( + + + + + 새 공지사항 작성 + + + +
+ +
+ ( + + 페이지 경로 * + + + + )} + /> + + ( + +
+ 활성 상태 +
+ 활성화하면 해당 페이지에서 공지사항이 표시됩니다. +
+
+ + + +
+ )} + /> +
+ + ( + + 제목 * + + + + + + )} + /> + + ( + + 내용 * + +
+ +
+
+ +
+ )} + /> + +
+ + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/components/notice/notice-edit-sheet.tsx b/components/notice/notice-edit-sheet.tsx new file mode 100644 index 00000000..91bcae3b --- /dev/null +++ b/components/notice/notice-edit-sheet.tsx @@ -0,0 +1,246 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" + +import { updateNoticeSchema, type UpdateNoticeSchema } from "@/lib/notice/validations" +import type { Notice } from "@/db/schema/notice" +import { updateNoticeData } from "@/lib/notice/service" +import TiptapEditor from "@/components/qna/tiptap-editor" + +type NoticeWithAuthor = Notice & { + authorName: string | null + authorEmail: string | null +} + +interface UpdateNoticeSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + notice: NoticeWithAuthor | null + pagePathOptions: Array<{ value: string; label: string }> + onSuccess?: () => void +} + +export function UpdateNoticeSheet({ + open, + onOpenChange, + notice, + pagePathOptions, + onSuccess +}: UpdateNoticeSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm({ + resolver: zodResolver(updateNoticeSchema), + defaultValues: { + id: 0, + pagePath: "", + title: "", + content: "", + isActive: true, + }, + }) + + // notice 데이터가 변경될 때 폼 초기화 + React.useEffect(() => { + if (notice) { + form.reset({ + id: notice.id, + pagePath: notice.pagePath, + title: notice.title, + content: notice.content, + isActive: notice.isActive, + }) + } + }, [notice, form]) + + function onSubmit(input: UpdateNoticeSchema) { + if (!notice) return + + startUpdateTransition(async () => { + try { + const result = await updateNoticeData(input) + + if (result.success) { + toast.success(result.message || "공지사항이 성공적으로 수정되었습니다.") + if (onSuccess) onSuccess() + onOpenChange(false) + } else { + toast.error(result.message || "공지사항 수정에 실패했습니다.") + } + } catch (error) { + toast.error("예기치 못한 오류가 발생했습니다.") + console.error("공지사항 수정 오류:", error) + } + }) + } + + return ( + + + + 공지사항 수정 + + 공지사항의 제목과 내용을 수정할 수 있습니다. 수정된 내용은 즉시 반영됩니다. + + + +
+ + {/* 공지사항 정보 표시 */} + {notice && ( +
+
공지사항 정보
+
+
작성자: {notice.authorName} ({notice.authorEmail})
+
작성일: {new Date(notice.createdAt).toLocaleDateString("ko-KR")}
+
수정일: {new Date(notice.updatedAt).toLocaleDateString("ko-KR")}
+
상태: {notice.isActive ? "활성" : "비활성"}
+
+
+ )} + + {/* 페이지 경로 선택 */} + ( + + 페이지 경로 * + + + + )} + /> + + {/* 제목 입력 */} + ( + + 제목 * + + + + + + )} + /> + + {/* 활성 상태 */} + ( + +
+ 활성 상태 +
+ 활성화하면 해당 페이지에서 공지사항이 표시됩니다. +
+
+ + + +
+ )} + /> + + {/* 내용 입력 (리치텍스트 에디터) */} + ( + + 내용 * + + + + + + )} + /> + + + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/components/notice/notice-view-dialog.tsx b/components/notice/notice-view-dialog.tsx new file mode 100644 index 00000000..9b42311a --- /dev/null +++ b/components/notice/notice-view-dialog.tsx @@ -0,0 +1,56 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import type { Notice } from "@/db/schema/notice" + +type NoticeWithAuthor = Notice & { + authorName: string | null + authorEmail: string | null +} + +interface NoticeViewDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + notice: NoticeWithAuthor | null +} + +export function NoticeViewDialog({ + open, + onOpenChange, + notice, +}: NoticeViewDialogProps) { + + if (!notice) return null + + return ( + + + +
+
+
+ + 제목: {notice.title} + +
+
+
+
+ +
+
+
+ + +
+ ) +} \ No newline at end of file diff --git a/lib/notice/repository.ts b/lib/notice/repository.ts new file mode 100644 index 00000000..84e64f00 --- /dev/null +++ b/lib/notice/repository.ts @@ -0,0 +1,244 @@ +import { asc, desc, eq, ilike, and, count, sql } from "drizzle-orm" +import db from "@/db/db" +import { notice, users, type Notice, type NewNotice } from "@/db/schema" + +// 최신 패턴: 트랜잭션을 지원하는 공지사항 조회 +export async function selectNoticeLists( + tx: typeof db, + params: { + where?: ReturnType + orderBy?: (ReturnType | ReturnType)[] + offset?: number + limit?: number + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params + + return tx + .select({ + id: notice.id, + pagePath: notice.pagePath, + title: notice.title, + content: notice.content, + authorId: notice.authorId, + isActive: notice.isActive, + createdAt: notice.createdAt, + updatedAt: notice.updatedAt, + authorName: users.name, + authorEmail: users.email, + }) + .from(notice) + .leftJoin(users, eq(notice.authorId, users.id)) + .where(where) + .orderBy(...(orderBy ?? [desc(notice.createdAt)])) + .offset(offset) + .limit(limit) +} + +// 최신 패턴: 트랜잭션을 지원하는 카운트 조회 +export async function countNoticeLists( + tx: typeof db, + where?: ReturnType +) { + const res = await tx + .select({ count: count() }) + .from(notice) + .where(where) + + return res[0]?.count ?? 0 +} + +// 기존 패턴 (하위 호환성을 위해 유지) +export async function selectNotice(input: { page: number; per_page: number; sort?: string; pagePath?: string; title?: string; authorId?: number; isActive?: boolean; from?: string; to?: string }) { + const { page, per_page = 50, sort, pagePath, title, authorId, isActive, from, to } = input + + const conditions = [] + + if (pagePath) { + conditions.push(ilike(notice.pagePath, `%${pagePath}%`)) + } + + if (title) { + conditions.push(ilike(notice.title, `%${title}%`)) + } + + if (authorId) { + conditions.push(eq(notice.authorId, authorId)) + } + + if (isActive !== null && isActive !== undefined) { + conditions.push(eq(notice.isActive, isActive)) + } + + if (from) { + conditions.push(sql`${notice.createdAt} >= ${from}`) + } + + if (to) { + conditions.push(sql`${notice.createdAt} <= ${to}`) + } + + const offset = (page - 1) * per_page + + // 정렬 설정 + let orderBy = desc(notice.createdAt); + + if (sort && Array.isArray(sort) && sort.length > 0) { + const sortItem = sort[0]; + if (sortItem.id === "createdAt") { + orderBy = sortItem.desc ? desc(notice.createdAt) : asc(notice.createdAt); + } + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + const data = await db + .select({ + id: notice.id, + pagePath: notice.pagePath, + title: notice.title, + content: notice.content, + authorId: notice.authorId, + isActive: notice.isActive, + createdAt: notice.createdAt, + updatedAt: notice.updatedAt, + authorName: users.name, + authorEmail: users.email, + }) + .from(notice) + .leftJoin(users, eq(notice.authorId, users.id)) + .where(whereClause) + .orderBy(orderBy) + .limit(per_page) + .offset(offset) + + return data +} + +// 기존 패턴: 공지사항 총 개수 조회 +export async function countNotice(input: { pagePath?: string; title?: string; authorId?: number; isActive?: boolean; from?: string; to?: string }) { + const { pagePath, title, authorId, isActive, from, to } = input + + const conditions = [] + + if (pagePath) { + conditions.push(ilike(notice.pagePath, `%${pagePath}%`)) + } + + if (title) { + conditions.push(ilike(notice.title, `%${title}%`)) + } + + if (authorId) { + conditions.push(eq(notice.authorId, authorId)) + } + + if (isActive !== null && isActive !== undefined) { + conditions.push(eq(notice.isActive, isActive)) + } + + if (from) { + conditions.push(sql`${notice.createdAt} >= ${from}`) + } + + if (to) { + conditions.push(sql`${notice.createdAt} <= ${to}`) + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + const result = await db + .select({ count: count() }) + .from(notice) + .where(whereClause) + + return result[0]?.count ?? 0 +} + +// 페이지 경로별 공지사항 조회 (활성화된 것만, 작성자 정보 포함) +export async function getNoticesByPagePath(pagePath: string): Promise> { + const result = await db + .select({ + id: notice.id, + pagePath: notice.pagePath, + title: notice.title, + content: notice.content, + authorId: notice.authorId, + isActive: notice.isActive, + createdAt: notice.createdAt, + updatedAt: notice.updatedAt, + authorName: users.name, + authorEmail: users.email, + }) + .from(notice) + .leftJoin(users, eq(notice.authorId, users.id)) + .where(and( + eq(notice.pagePath, pagePath), + eq(notice.isActive, true) + )) + .orderBy(desc(notice.createdAt)) + + return result +} + +// 공지사항 생성 +export async function insertNotice(data: NewNotice): Promise { + const result = await db + .insert(notice) + .values(data) + .returning() + + return result[0] +} + +// 공지사항 수정 +export async function updateNotice(id: number, data: Partial): Promise { + const result = await db + .update(notice) + .set({ ...data, updatedAt: new Date() }) + .where(eq(notice.id, id)) + .returning() + + return result[0] || null +} + +// 공지사항 삭제 +export async function deleteNoticeById(id: number): Promise { + const result = await db + .delete(notice) + .where(eq(notice.id, id)) + + return (result.rowCount ?? 0) > 0 +} + +// 공지사항 다중 삭제 +export async function deleteNoticeByIds(ids: number[]): Promise { + const result = await db + .delete(notice) + .where(sql`${notice.id} = ANY(${ids})`) + + return result.rowCount ?? 0 +} + +// ID로 공지사항 조회 (작성자 정보 포함) +export async function getNoticeById(id: number): Promise<(Notice & { authorName: string | null; authorEmail: string | null }) | null> { + const result = await db + .select({ + id: notice.id, + pagePath: notice.pagePath, + title: notice.title, + content: notice.content, + authorId: notice.authorId, + isActive: notice.isActive, + createdAt: notice.createdAt, + updatedAt: notice.updatedAt, + authorName: users.name, + authorEmail: users.email, + }) + .from(notice) + .leftJoin(users, eq(notice.authorId, users.id)) + .where(eq(notice.id, id)) + .limit(1) + + return result[0] || null +} \ No newline at end of file diff --git a/lib/notice/service.ts b/lib/notice/service.ts new file mode 100644 index 00000000..24b03fe9 --- /dev/null +++ b/lib/notice/service.ts @@ -0,0 +1,324 @@ +"use server" + +import { revalidateTag, unstable_noStore } from "next/cache" +import { getErrorMessage } from "@/lib/handle-error" +import { unstable_cache } from "@/lib/unstable-cache" +import { filterColumns } from "@/lib/filter-columns" +import { asc, desc, ilike, and, or, eq } from "drizzle-orm" +import db from "@/db/db" +import { notice, pageInformation } from "@/db/schema" + +import type { + CreateNoticeSchema, + UpdateNoticeSchema, + GetNoticeSchema +} from "./validations" + +import { + selectNotice, + countNotice, + getNoticesByPagePath, + insertNotice, + updateNotice, + deleteNoticeById, + deleteNoticeByIds, + getNoticeById, + selectNoticeLists, + countNoticeLists +} from "./repository" + +import type { Notice } from "@/db/schema/notice" + +export async function getNoticeLists(input: GetNoticeSchema) { + return unstable_cache( + async () => { + try { + // 고급 검색 로직 + const { page, perPage, search, filters, joinOperator, pagePath, title, content, authorId, isActive } = input + + // 기본 검색 조건들 + const conditions = [] + + // 검색어가 있으면 여러 필드에서 검색 + if (search && search.trim()) { + const searchConditions = [ + ilike(notice.pagePath, `%${search}%`), + ilike(notice.title, `%${search}%`), + ilike(notice.content, `%${search}%`) + ] + conditions.push(or(...searchConditions)) + } + + // 개별 필드 조건들 + if (pagePath && pagePath.trim()) { + conditions.push(ilike(notice.pagePath, `%${pagePath}%`)) + } + + if (title && title.trim()) { + conditions.push(ilike(notice.title, `%${title}%`)) + } + + if (content && content.trim()) { + conditions.push(ilike(notice.content, `%${content}%`)) + } + + if (authorId !== null && authorId !== undefined) { + conditions.push(eq(notice.authorId, authorId)) + } + + if (isActive !== null && isActive !== undefined) { + conditions.push(eq(notice.isActive, isActive)) + } + // 고급 필터 처리 + if (filters && filters.length > 0) { + const advancedConditions = filters.map(() => + filterColumns({ + table: notice, + filters: filters, + joinOperator: joinOperator, + }) + ) + + if (advancedConditions.length > 0) { + if (joinOperator === "or") { + conditions.push(or(...advancedConditions)) + } else { + conditions.push(and(...advancedConditions)) + } + } + } + + // 전체 WHERE 조건 조합 + const finalWhere = conditions.length > 0 + ? (joinOperator === "or" ? or(...conditions) : and(...conditions)) + : undefined + + // 페이지네이션 + const offset = (page - 1) * perPage + + // 정렬 처리 + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + if (item.id === "createdAt") { + return item.desc ? desc(notice.createdAt) : asc(notice.createdAt) + } else if (item.id === "updatedAt") { + return item.desc ? desc(notice.updatedAt) : asc(notice.updatedAt) + } else if (item.id === "pagePath") { + return item.desc ? desc(notice.pagePath) : asc(notice.pagePath) + } else if (item.id === "title") { + return item.desc ? desc(notice.title) : asc(notice.title) + } else if (item.id === "authorId") { + return item.desc ? desc(notice.authorId) : asc(notice.authorId) + } else if (item.id === "isActive") { + return item.desc ? desc(notice.isActive) : asc(notice.isActive) + } else { + return desc(notice.createdAt) // 기본값 + } + }) + : [desc(notice.createdAt)] + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectNoticeLists(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }) + + const total = await countNoticeLists(tx, finalWhere) + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount, total } + } catch (err) { + console.error("Failed to get notice lists:", err) + // 에러 발생 시 기본값 반환 + return { data: [], pageCount: 0, total: 0 } + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["notice-lists"], + } + )() +} + +// 기존 패턴 (하위 호환성을 위해 유지) +export async function getNoticeList(input: Partial<{ page: number; per_page: number; sort?: string; pagePath?: string; title?: string; authorId?: number; isActive?: boolean; from?: string; to?: string }> & { page: number; per_page: number }) { + unstable_noStore() + + try { + const [data, total] = await Promise.all([ + selectNotice(input), + countNotice(input) + ]) + + const pageCount = Math.ceil(total / input.per_page) + + return { + data, + pageCount, + total + } + } catch (error) { + console.error("Failed to get notice list:", error) + throw new Error(getErrorMessage(error)) + } +} + +// 페이지별 공지사항 조회 (일반 사용자용) +export async function getPageNotices(pagePath: string): Promise> { + try { + return await getNoticesByPagePath(pagePath) + } catch (error) { + console.error(`Failed to get notices for page ${pagePath}:`, error) + return [] + } +} + +// 캐시된 페이지별 공지사항 조회 +export const getCachedPageNotices = unstable_cache( + async (pagePath: string) => getPageNotices(pagePath), + ["page-notices"], + { + tags: ["page-notices"], + revalidate: 3600, // 1시간 캐시 + } +) + +// 공지사항 생성 +export async function createNotice(input: CreateNoticeSchema) { + try { + const result = await insertNotice(input) + + revalidateTag("page-notices") + revalidateTag("notice-lists") + + return { + success: true, + data: result, + message: "공지사항이 성공적으로 생성되었습니다." + } + } catch (error) { + console.error("Failed to create notice:", error) + return { + success: false, + message: getErrorMessage(error) + } + } +} + +// 공지사항 수정 +export async function updateNoticeData(input: UpdateNoticeSchema) { + try { + const { id, ...updateData } = input + const result = await updateNotice(id, updateData) + + if (!result) { + return { + success: false, + message: "공지사항을 찾을 수 없거나 수정에 실패했습니다." + } + } + + revalidateTag("page-notices") + revalidateTag("notice-lists") + + return { + success: true, + message: "공지사항이 성공적으로 수정되었습니다." + } + } catch (error) { + console.error("Failed to update notice:", error) + return { + success: false, + message: getErrorMessage(error) + } + } +} + +// 공지사항 삭제 +export async function deleteNotice(id: number) { + try { + const success = await deleteNoticeById(id) + + if (!success) { + return { + success: false, + message: "공지사항을 찾을 수 없거나 삭제에 실패했습니다." + } + } + + revalidateTag("page-notices") + revalidateTag("notice-lists") + + return { + success: true, + message: "공지사항이 성공적으로 삭제되었습니다." + } + } catch (error) { + console.error("Failed to delete notice:", error) + return { + success: false, + message: getErrorMessage(error) + } + } +} + +// 공지사항 다중 삭제 +export async function deleteMultipleNotices(ids: number[]) { + try { + const deletedCount = await deleteNoticeByIds(ids) + + revalidateTag("page-notices") + revalidateTag("notice-lists") + + return { + success: true, + deletedCount, + message: `${deletedCount}개의 공지사항이 성공적으로 삭제되었습니다.` + } + } catch (error) { + console.error("Failed to delete multiple notices:", error) + return { + success: false, + message: getErrorMessage(error) + } + } +} + +// ID로 공지사항 조회 +export async function getNoticeDetail(id: number): Promise<(Notice & { authorName: string | null; authorEmail: string | null }) | null> { + try { + return await getNoticeById(id) + } catch (error) { + console.error(`Failed to get notice detail for id ${id}:`, error) + return null + } +} + +// pagePath 목록 조회 (정보 시스템에서 사용) +export async function getPagePathList(): Promise> { + try { + const result = await db + .selectDistinct({ + pagePath: pageInformation.pagePath, + pageName: pageInformation.pageName + }) + .from(pageInformation) + .where(eq(pageInformation.isActive, true)) + .orderBy(asc(pageInformation.pagePath)) + + return result.map(item => ({ + pagePath: item.pagePath, + pageName: item.pageName || item.pagePath + })) + } catch (error) { + console.error("Failed to get page path list:", error) + return [] + } +} \ No newline at end of file diff --git a/lib/notice/validations.ts b/lib/notice/validations.ts new file mode 100644 index 00000000..05e84af9 --- /dev/null +++ b/lib/notice/validations.ts @@ -0,0 +1,80 @@ +import { z } from "zod" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + parseAsBoolean, +} from "nuqs/server" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { Notice } from "@/db/schema/notice" + +// 공지사항 생성 스키마 +export const createNoticeSchema = z.object({ + pagePath: z.string().min(1, "페이지 경로를 입력해주세요"), + title: z.string().min(1, "제목을 입력해주세요"), + content: z.string().min(1, "내용을 입력해주세요"), + authorId: z.number().min(1, "작성자를 선택해주세요"), + isActive: z.boolean().default(true), +}) + +// 공지사항 수정 스키마 +export const updateNoticeSchema = z.object({ + id: z.number(), + pagePath: z.string().min(1, "페이지 경로를 입력해주세요"), + title: z.string().min(1, "제목을 입력해주세요"), + content: z.string().min(1, "내용을 입력해주세요"), + isActive: z.boolean().default(true), +}) + +// 현대적인 검색 파라미터 캐시 +export const searchParamsNoticeCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 기본 검색 필드들 + pagePath: parseAsString.withDefault(""), + title: parseAsString.withDefault(""), + content: parseAsString.withDefault(""), + authorId: parseAsInteger, + isActive: parseAsBoolean, + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + + // 날짜 범위 + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), +}) + +// 타입 추출 +export type CreateNoticeSchema = z.infer +export type UpdateNoticeSchema = z.infer +export type GetNoticeSchema = Awaited> + +// 기존 스키마 (하위 호환성을 위해 유지) +export const getNoticeSchema = z.object({ + page: z.coerce.number().default(1), + per_page: z.coerce.number().default(10), + sort: z.string().optional(), + pagePath: z.string().optional(), + title: z.string().optional(), + authorId: z.coerce.number().optional(), + isActive: z.coerce.boolean().optional(), + from: z.string().optional(), + to: z.string().optional(), +}) + +// 페이지 경로별 공지사항 조회 스키마 +export const getPageNoticeSchema = z.object({ + pagePath: z.string().min(1, "페이지 경로를 입력해주세요"), +}) + +export type GetPageNoticeSchema = z.infer \ No newline at end of file -- cgit v1.2.3