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 +++ 11 files changed, 1473 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 (limited to 'components') 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 -- cgit v1.2.3