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/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 ++++ 4 files changed, 956 insertions(+) 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/notice') 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