From 7dd2b9fc1856306652f311d19697d9880955bfab Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 22 Aug 2025 02:12:15 +0000 Subject: (최겸) 공지사항, 인포메이션 기능 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vendors/[id]/info/basic/basic-info-client.tsx | 15 +- app/api/files/[...path]/route.ts | 3 +- components/additional-info/join-form.tsx | 12 +- components/information/information-button.tsx | 113 +++++++---- components/notice/notice-client.tsx | 116 +++++------ components/notice/notice-create-dialog.tsx | 2 +- components/notice/notice-edit-sheet.tsx | 2 +- components/vendor-info/pq-simple-dialog.tsx | 5 +- .../document-status-dialog.tsx | 203 ++++++++++++++----- config/vendorRegularRegistrationsColumnsConfig.ts | 2 + lib/information/service.ts | 74 +++---- .../table/update-information-dialog.tsx | 4 +- lib/notice/repository.ts | 155 +-------------- lib/notice/service.ts | 215 ++++----------------- lib/notice/validations.ts | 57 +----- lib/vendor-regular-registrations/repository.ts | 35 +++- lib/vendor-regular-registrations/service.ts | 13 ++ 17 files changed, 447 insertions(+), 579 deletions(-) diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/basic-info-client.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/basic-info-client.tsx index 0e4dccf4..536bed95 100644 --- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/basic-info-client.tsx +++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/basic-info-client.tsx @@ -25,7 +25,7 @@ import { AdditionalInfoDialog } from "@/components/vendor-regular-registrations/ import { getSiteVisitRequestsByVendorId } from "@/lib/site-visit/service"; import { fetchVendorRegistrationStatus } from "@/lib/vendor-regular-registrations/service"; import { getVendorAttachmentsByType, getVendorPeriodicGrade, getVendorTypeInfo } from "@/lib/vendor-info/service"; -import { downloadFile } from "@/lib/file-download"; +// downloadFile은 동적으로 import import { Table, TableBody, @@ -440,6 +440,8 @@ export default function BasicInfoClient({ // DocumentStatusDialog가 기대하는 형태로 데이터 구성 const dialogData = { // 기본 정보 + id: result.data.registration?.id || 0, + vendorId: parseInt(vendorId), companyName: result.data.vendor.vendorName, businessNumber: result.data.vendor.taxId, representative: result.data.vendor.representativeName, @@ -449,6 +451,14 @@ export default function BasicInfoClient({ // 문서 제출 현황 - documentSubmissions 속성으로 매핑 documentSubmissions: result.data.documentStatus, + // 문서별 파일 정보 추가 + documentFiles: result.data.documentFiles || { + businessRegistration: [], + creditEvaluation: [], + bankCopy: [], + auditResult: [] + }, + // 기본계약 정보 basicContracts: result.data.basicContracts || [], @@ -507,6 +517,9 @@ export default function BasicInfoClient({ // 첨부파일 다운로드 핸들러 const handleAttachmentDownload = async (filePath: string, fileName: string) => { try { + // 동적으로 downloadFile 함수 import + const { downloadFile } = await import('@/lib/file-download') + const result = await downloadFile(filePath, fileName); if (result.success) { toast.success(`${fileName} 파일이 다운로드되었습니다.`); diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index 0bc2e22f..2b58ca43 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -49,7 +49,8 @@ const isAllowedPath = (requestedPath: string): boolean => { 'vendors/nda', 'vendors', 'pq', - 'pq/vendor' + 'pq/vendor', + 'information' ]; return allowedPaths.some(allowed => diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx index ca0c60d5..90effddb 100644 --- a/components/additional-info/join-form.tsx +++ b/components/additional-info/join-form.tsx @@ -87,7 +87,6 @@ import { InformationButton } from "@/components/information/information-button" // 보안 파일 다운로드 유틸리티 import import { - downloadFile, quickDownload, smartFileAction, getFileInfo, @@ -366,6 +365,7 @@ export function InfoForm() { const downloadUrl = `/api/vendors/attachments/download?id=${fileId}&vendorId=${Number(companyId)}`; // 보안 다운로드 유틸리티 사용 + const { downloadFile } = await import('@/lib/file-download') const result = await downloadFile(downloadUrl, fileName, { action: 'download', showToast: false, // 우리가 직접 토스트 관리 @@ -413,6 +413,8 @@ export function InfoForm() { const fileName = `vendor-${companyId}-files.zip`; // 보안 다운로드 유틸리티 사용 + + const { downloadFile } = await import('@/lib/file-download') const result = await downloadFile(downloadUrl, fileName, { action: 'download', showToast: false, // 우리가 직접 토스트 관리 @@ -708,7 +710,7 @@ export function InfoForm() { } // 보안 정보 가져오기 (선택적으로 사용자에게 표시) - const securityInfo = getSecurityInfo(); + // const securityInfo = getSecurityInfo(); // Render return ( @@ -746,9 +748,9 @@ export function InfoForm() { )} {/* 보안 정보 표시 (선택적) */} -
+ {/*

📁 허용 파일 크기: {securityInfo.maxFileSizeFormatted} | 남은 다운로드: {securityInfo.remainingDownloads}/분

-
+
*/} @@ -1860,7 +1862,7 @@ export function InfoForm() { domesticCredit: "not_submitted", }, basicContracts: registrationData.basicContracts || [], - documentFiles: { + documentFiles: registrationData.documentFiles || { businessRegistration: [], creditEvaluation: [], bankCopy: [], diff --git a/components/information/information-button.tsx b/components/information/information-button.tsx index 52079767..17f10502 100644 --- a/components/information/information-button.tsx +++ b/components/information/information-button.tsx @@ -12,14 +12,15 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { Info, Download, Edit, Loader2 } from "lucide-react" -import { getCachedPageInformation, getCachedEditPermission } from "@/lib/information/service" -import { getCachedPageNotices } from "@/lib/notice/service" +import { getPageInformationDirect, getEditPermissionDirect } from "@/lib/information/service" +import { getPageNotices } 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 { PageInformation, InformationAttachment } from "@/db/schema/information" import type { Notice } from "@/db/schema/notice" import { useSession } from "next-auth/react" import { formatDate } from "@/lib/utils" +// downloadFile은 동적으로 import interface InformationButtonProps { pagePath: string @@ -41,7 +42,7 @@ export function InformationButton({ }: InformationButtonProps) { const { data: session } = useSession() const [isOpen, setIsOpen] = useState(false) - const [information, setInformation] = useState(null) + const [information, setInformation] = useState<(PageInformation & { attachments: InformationAttachment[] }) | null>(null) const [notices, setNotices] = useState([]) const [hasEditPermission, setHasEditPermission] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) @@ -59,19 +60,35 @@ export function InformationButton({ // pagePath 정규화 (앞의 / 제거) const normalizedPath = pagePath.startsWith('/') ? pagePath.slice(1) : pagePath + console.log('🔍 Information Button - 데이터 로딩:', { + originalPath: pagePath, + normalizedPath: normalizedPath, + sessionUserId: session?.user?.id + }) + // 병렬로 데이터 조회 const [infoResult, noticesResult] = await Promise.all([ - getCachedPageInformation(normalizedPath), - getCachedPageNotices(normalizedPath) + getPageInformationDirect(normalizedPath), + getPageNotices(normalizedPath) ]) + console.log('📊 조회 결과:', { + infoResult: infoResult ? { + id: infoResult.id, + pagePath: infoResult.pagePath, + pageName: infoResult.pageName, + attachmentsCount: infoResult.attachments?.length || 0 + } : null, + noticesCount: noticesResult.length + }) + setInformation(infoResult) setNotices(noticesResult) setDataLoaded(true) // 권한 확인 if (session?.user?.id) { - const hasPermission = await getCachedEditPermission(normalizedPath, session.user.id) + const hasPermission = await getEditPermissionDirect(normalizedPath, session.user.id) setHasEditPermission(hasPermission) } } catch (error) { @@ -109,16 +126,22 @@ export function InformationButton({ } // 파일 다운로드 핸들러 - const handleDownload = () => { - if (information?.attachmentFilePath) { - // window.open 대신 link 요소 사용 - const link = document.createElement('a') - link.href = information.attachmentFilePath - link.target = '_blank' - link.rel = 'noopener noreferrer' - document.body.appendChild(link) - link.click() - document.body.removeChild(link) + const handleDownload = async (attachment: InformationAttachment) => { + try { + // 동적으로 downloadFile 함수 import + const { downloadFile } = await import('@/lib/file-download') + + await downloadFile( + attachment.filePath, + attachment.fileName, + { + action: 'download', + showToast: true, + showSuccessToast: true + } + ) + } catch (error) { + console.error('파일 다운로드 실패:', error) } } @@ -145,7 +168,7 @@ export function InformationButton({
- {information?.pageName} +
@@ -231,29 +254,41 @@ export function InformationButton({ {/* 첨부파일 */}
-

첨부파일

+
+

첨부파일

+ {information?.attachments && information.attachments.length > 0 && ( + {information.attachments.length}개 + )} +
- {information?.attachmentFileName ? ( -
-
-
- {information.attachmentFileName} -
- {information.attachmentFileSize && ( -
- {information.attachmentFileSize} + {information?.attachments && information.attachments.length > 0 ? ( +
+ {information.attachments.map((attachment) => ( +
+
+
+ {attachment.fileName} +
+ {attachment.fileSize && ( +
+ {attachment.fileSize} +
+ )}
- )} -
- + +
+ ))}
) : (
diff --git a/components/notice/notice-client.tsx b/components/notice/notice-client.tsx index e5c05d84..1eb6d75f 100644 --- a/components/notice/notice-client.tsx +++ b/components/notice/notice-client.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useTransition } from "react" +import React, { useState, useEffect, useTransition } from "react" import { useParams } from "next/navigation" import { useTranslation } from "@/i18n/client" import { Button } from "@/components/ui/button" @@ -91,28 +91,12 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr 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: "", - }) + const result = await getNoticeLists() if (result?.data) { - setNotices(result.data) + setNotices(result.data as NoticeWithAuthor[]) } else { toast.error("공지사항 목록을 가져오는데 실패했습니다.") } @@ -125,37 +109,12 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr } } - // 검색 핸들러 + // 검색 핸들러 (클라이언트 사이드에서 필터링하므로 별도 동작 불필요) 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) => { @@ -184,8 +143,49 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr } } - // 정렬된 공지사항 목록 - const sortedNotices = sortNotices(notices) + // 클라이언트 사이드 필터링 및 정렬 + const filteredAndSortedNotices = React.useMemo(() => { + let filtered = notices + + // 검색 필터 + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase() + filtered = filtered.filter(notice => + notice.title.toLowerCase().includes(query) || + notice.pagePath.toLowerCase().includes(query) || + notice.content.toLowerCase().includes(query) + ) + } + + // 정렬 + filtered = filtered.sort((a, b) => { + let aValue: string | Date + let bValue: string | Date + + switch (sortField) { + case "title": + aValue = a.title + bValue = b.title + break + case "pagePath": + aValue = a.pagePath + bValue = b.pagePath + break + case "createdAt": + aValue = new Date(a.createdAt) + bValue = new Date(b.createdAt) + break + default: + return 0 + } + + if (aValue < bValue) return sortDirection === "asc" ? -1 : 1 + if (aValue > bValue) return sortDirection === "asc" ? 1 : -1 + return 0 + }) + + return filtered + }, [notices, searchQuery, sortField, sortDirection]) // 페이지 경로 옵션 로딩 const loadPagePathOptions = async () => { @@ -227,13 +227,7 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr loadPagePathOptions() }, []) - useEffect(() => { - if (searchQuery !== "") { - fetchNotices() - } else if (initialData.length > 0) { - setNotices(initialData) - } - }, [searchQuery]) + // 검색은 클라이언트 사이드에서 실시간으로 처리됨 return (
@@ -243,16 +237,12 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr
setSearchQuery(e.target.value)} className="pl-10" - onKeyPress={(e) => e.key === "Enter" && handleSearch()} />
- - )} */} + )}
); @@ -262,7 +363,11 @@ export function DocumentStatusDialog({
{isCompleted && ( - diff --git a/config/vendorRegularRegistrationsColumnsConfig.ts b/config/vendorRegularRegistrationsColumnsConfig.ts index f9567883..44f02f96 100644 --- a/config/vendorRegularRegistrationsColumnsConfig.ts +++ b/config/vendorRegularRegistrationsColumnsConfig.ts @@ -39,6 +39,8 @@ export interface VendorRegularRegistration { templateName: string | null; status: string; createdAt: Date | null; + filePath: string | null; + fileName: string | null; }>; registrationRequestDate: string | null; assignedDepartment: string | null; diff --git a/lib/information/service.ts b/lib/information/service.ts index 2826c0e9..2d3ad079 100644 --- a/lib/information/service.ts +++ b/lib/information/service.ts @@ -1,8 +1,6 @@ "use server" -import { revalidateTag } from "next/cache" import { getErrorMessage } from "@/lib/handle-error" -import { unstable_cache } from "@/lib/unstable-cache" import { desc, or, eq } from "drizzle-orm" import db from "@/db/db" import { pageInformation, menuAssignments } from "@/db/schema" @@ -45,22 +43,29 @@ export async function getInformationLists() { // 페이지별 인포메이션 조회 (첨부파일 포함) export async function getPageInformation(pagePath: string) { try { - return await getInformationByPagePathWithAttachments(pagePath) + console.log('🔍 Information Service - 조회 시작:', { pagePath }) + const result = await getInformationByPagePathWithAttachments(pagePath) + console.log('📊 Information Service - 조회 결과:', { + pagePath, + found: !!result, + resultData: result ? { + id: result.id, + pagePath: result.pagePath, + pageName: result.pageName, + attachmentsCount: result.attachments?.length || 0 + } : null + }) + return result } catch (error) { console.error(`Failed to get information for page ${pagePath}:`, error) return null } } -// 캐시된 페이지별 인포메이션 조회 -export const getCachedPageInformation = unstable_cache( - async (pagePath: string) => getPageInformation(pagePath), - ["page-information"], - { - tags: ["page-information"], - revalidate: 3600, // 1시간 캐시 - } -) +// 페이지별 인포메이션 조회 (직접 호출용) +export async function getPageInformationDirect(pagePath: string) { + return await getPageInformation(pagePath) +} // 인포메이션 수정 (내용과 첨부파일만) export async function updateInformationData(input: UpdateInformationSchema) { @@ -83,9 +88,7 @@ export async function updateInformationData(input: UpdateInformationSchema) { } } - revalidateTag("page-information") - revalidateTag("information-lists") - revalidateTag("information-edit-permission") // 편집 권한 캐시 무효화 + // 캐시 무효화 제거됨 return { success: true, @@ -113,13 +116,18 @@ export async function getInformationDetail(id: number) { // 인포메이션 편집 권한 확인 export async function checkInformationEditPermission(pagePath: string, userId: string): Promise { try { + // pagePath 정규화 (앞의 / 제거) + const normalizedPagePath = pagePath.startsWith('/') ? pagePath.slice(1) : pagePath + // pagePath를 menuPath로 변환 (pagePath가 menuPath의 마지막 부분이라고 가정) // 예: pagePath "vendor-list" -> menuPath "/evcp/vendor-list" 또는 "/partners/vendor-list" const menuPathQueries = [ - `/evcp/${pagePath}`, - `/partners/${pagePath}`, - `/${pagePath}`, // 루트 경로 - pagePath // 정확한 매칭 + `/evcp/${normalizedPagePath}`, + `/partners/${normalizedPagePath}`, + `/${normalizedPagePath}`, // 루트 경로 + normalizedPagePath, // 정확한 매칭 + `/${pagePath}`, // 원본 경로도 체크 + pagePath // 원본 경로 정확한 매칭 ] // menu_assignments에서 해당 pagePath와 매칭되는 메뉴 찾기 @@ -149,15 +157,10 @@ export async function checkInformationEditPermission(pagePath: string, userId: s } } -// 캐시된 권한 확인 -export const getCachedEditPermission = unstable_cache( - async (pagePath: string, userId: string) => checkInformationEditPermission(pagePath, userId), - ["information-edit-permission"], - { - tags: ["information-edit-permission"], - revalidate: 300, // 5분 캐시 - } -) +// 권한 확인 (직접 호출용) +export async function getEditPermissionDirect(pagePath: string, userId: string) { + return await checkInformationEditPermission(pagePath, userId) +} // menu_assignments 기반으로 page_information 동기화 export async function syncInformationFromMenuAssignments() { @@ -170,9 +173,14 @@ export async function syncInformationFromMenuAssignments() { // upsert를 사용하여 각 메뉴 항목 처리 for (const menu of menuItems) { try { + // 맨 앞의 / 제거하여 pagePath 정규화 + const normalizedPagePath = menu.menuPath.startsWith('/') + ? menu.menuPath.slice(1) + : menu.menuPath; + await db.insert(pageInformation) .values({ - pagePath: menu.menuPath, + pagePath: normalizedPagePath, pageName: menu.menuTitle, informationContent: "", isActive: true // 기본값으로 활성화 @@ -191,7 +199,7 @@ export async function syncInformationFromMenuAssignments() { } } - revalidateTag("information"); + // 캐시 무효화 제거됨 return { success: true, @@ -249,8 +257,7 @@ export async function uploadInformationAttachment(formData: FormData) { } } - revalidateTag("page-information") - revalidateTag("information-lists") + // 캐시 무효화 제거됨 return { success: true, @@ -288,8 +295,7 @@ export async function deleteInformationAttachmentAction(attachmentId: number) { } } - revalidateTag("page-information") - revalidateTag("information-lists") + // 캐시 무효화 제거됨 return { success: true, diff --git a/lib/information/table/update-information-dialog.tsx b/lib/information/table/update-information-dialog.tsx index a02b6eb1..370eb763 100644 --- a/lib/information/table/update-information-dialog.tsx +++ b/lib/information/table/update-information-dialog.tsx @@ -44,7 +44,7 @@ import { downloadInformationAttachment } from "@/lib/information/service" import type { PageInformation, InformationAttachment } from "@/db/schema/information" -import { downloadFile } from "@/lib/file-download" +// downloadFile은 동적으로 import import prettyBytes from "pretty-bytes" const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB @@ -126,6 +126,8 @@ export function UpdateInformationDialog({ try { const result = await downloadInformationAttachment(attachment.id) if (result.success && result.data) { + // 동적으로 downloadFile 함수 import + const { downloadFile } = await import('@/lib/file-download') await downloadFile(result.data.filePath, result.data.fileName) toast.success("파일 다운로드가 시작되었습니다.") } else { diff --git a/lib/notice/repository.ts b/lib/notice/repository.ts index 84e64f00..fb941ac9 100644 --- a/lib/notice/repository.ts +++ b/lib/notice/repository.ts @@ -1,160 +1,7 @@ -import { asc, desc, eq, ilike, and, count, sql } from "drizzle-orm" +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 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 diff --git a/lib/notice/service.ts b/lib/notice/service.ts index c261cd2e..9c05b98f 100644 --- a/lib/notice/service.ts +++ b/lib/notice/service.ts @@ -1,203 +1,81 @@ "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 { desc, eq } from "drizzle-orm" import db from "@/db/db" -import { notice, pageInformation, menuAssignments } from "@/db/schema" +import { notice, pageInformation, menuAssignments, users } from "@/db/schema" import type { CreateNoticeSchema, - UpdateNoticeSchema, - GetNoticeSchema + UpdateNoticeSchema } from "./validations" import { - selectNotice, - countNotice, getNoticesByPagePath, insertNotice, updateNotice, deleteNoticeById, deleteNoticeByIds, - getNoticeById, - selectNoticeLists, - countNoticeLists + getNoticeById } 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() - +// 간단한 공지사항 목록 조회 (페이지네이션 없이 전체 조회) +export async function getNoticeLists(): Promise<{ data: Array }> { 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)) + // 전체 데이터 조회 (작성자 정보 포함, 클라이언트에서 검색 처리) + 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)) + .orderBy(desc(notice.createdAt)) + + return { data } + } catch (err) { + console.error("Failed to get notice lists:", err) + return { data: [] } } } // 페이지별 공지사항 조회 (일반 사용자용) export async function getPageNotices(pagePath: string): Promise> { try { - return await getNoticesByPagePath(pagePath) + 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 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 getPageNoticesDirect(pagePath: string) { + return await getPageNotices(pagePath) +} // 공지사항 생성 export async function createNotice(input: CreateNoticeSchema) { try { const result = await insertNotice(input) - revalidateTag("page-notices") - revalidateTag("notice-lists") - return { success: true, data: result, @@ -225,9 +103,6 @@ export async function updateNoticeData(input: UpdateNoticeSchema) { } } - revalidateTag("page-notices") - revalidateTag("notice-lists") - return { success: true, message: "공지사항이 성공적으로 수정되었습니다." @@ -253,9 +128,6 @@ export async function deleteNotice(id: number) { } } - revalidateTag("page-notices") - revalidateTag("notice-lists") - return { success: true, message: "공지사항이 성공적으로 삭제되었습니다." @@ -274,9 +146,6 @@ export async function deleteMultipleNotices(ids: number[]) { try { const deletedCount = await deleteNoticeByIds(ids) - revalidateTag("page-notices") - revalidateTag("notice-lists") - return { success: true, deletedCount, @@ -311,7 +180,7 @@ export async function getPagePathList(): Promise ({ pagePath: item.pagePath, @@ -349,8 +218,6 @@ export async function syncNoticeFromMenuAssignments() { processedCount++; } - revalidateTag("notice"); - return { success: true, message: `공지사항 경로 동기화 확인 완료: ${processedCount}개 확인, ${missingPaths.length}개 누락`, diff --git a/lib/notice/validations.ts b/lib/notice/validations.ts index 05e84af9..146f8e09 100644 --- a/lib/notice/validations.ts +++ b/lib/notice/validations.ts @@ -1,14 +1,4 @@ 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({ @@ -28,53 +18,12 @@ export const updateNoticeSchema = z.object({ 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 CreateNoticeSchema = z.infer +export type UpdateNoticeSchema = z.infer export type GetPageNoticeSchema = z.infer \ No newline at end of file diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts index 38bf4aaf..aec3d275 100644 --- a/lib/vendor-regular-registrations/repository.ts +++ b/lib/vendor-regular-registrations/repository.ts @@ -75,6 +75,8 @@ export async function getVendorRegularRegistrations( status: basicContract.status, templateName: basicContractTemplates.templateName, createdAt: basicContract.createdAt, + filePath: basicContract.filePath, + fileName: basicContract.fileName, }) .from(basicContract) .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id)) @@ -153,6 +155,35 @@ export async function getVendorRegularRegistrations( auditResult: investigationFiles, }; + // 디버깅용 로그 추가 + console.log(`🔍 벤더 ID ${registration.vendorId} documentFiles 구조:`, { + businessRegistration: documentFiles.businessRegistration.map(f => ({ + fileName: f.fileName, + filePath: f.filePath, + attachmentType: f.attachmentType, + allKeys: Object.keys(f) + })), + creditEvaluation: documentFiles.creditEvaluation.map(f => ({ + fileName: f.fileName, + filePath: f.filePath, + attachmentType: f.attachmentType, + allKeys: Object.keys(f) + })), + bankCopy: documentFiles.bankCopy.map(f => ({ + fileName: f.fileName, + filePath: f.filePath, + attachmentType: f.attachmentType, + allKeys: Object.keys(f) + })), + auditResult: documentFiles.auditResult.map(f => ({ + fileName: f.fileName, + attachmentType: f.attachmentType, + allKeys: Object.keys(f) + })), + totalVendorFiles: vendorFiles.length, + totalInvestigationFiles: investigationFiles.length + }); + // 문서 제출 현황 로그 console.log(`📊 벤더 ID ${registration.vendorId} 문서 제출 현황:`, { documentSubmissionsStatus, @@ -230,11 +261,13 @@ export async function getVendorRegularRegistrations( gtcSkipped: registration.gtcSkipped || false, additionalInfo: additionalInfoCompleted, // 기본계약 정보 - basicContracts: vendorContracts.map(contract => ({ + basicContracts: vendorContracts.map((contract: any) => ({ templateId: contract.templateId, templateName: contract.templateName, status: contract.status, createdAt: contract.createdAt, + filePath: contract.filePath, + fileName: contract.fileName, })), registrationRequestDate: registration.registrationRequestDate || null, assignedDepartment: registration.assignedDepartment, diff --git a/lib/vendor-regular-registrations/service.ts b/lib/vendor-regular-registrations/service.ts index d64c7b8b..7ec433b4 100644 --- a/lib/vendor-regular-registrations/service.ts +++ b/lib/vendor-regular-registrations/service.ts @@ -714,6 +714,8 @@ export async function fetchVendorRegistrationStatus(vendorId: number) { const investigationFiles = await db .select({ attachmentId: vendorInvestigationAttachments.id, + fileName: vendorInvestigationAttachments.fileName, + filePath: vendorInvestigationAttachments.filePath, createdAt: vendorInvestigationAttachments.createdAt, }) .from(vendorInvestigationAttachments) @@ -735,6 +737,8 @@ export async function fetchVendorRegistrationStatus(vendorId: number) { templateName: basicContractTemplates.templateName, status: basicContract.status, createdAt: basicContract.createdAt, + filePath: basicContract.filePath, + fileName: basicContract.fileName, }) .from(basicContract) .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id)) @@ -796,6 +800,14 @@ export async function fetchVendorRegistrationStatus(vendorId: number) { safetyQualification: investigationFiles.length > 0, } + // 문서별 파일 정보 (다운로드용) + const documentFiles = { + businessRegistration: vendorFiles.filter(f => f.attachmentType === "BUSINESS_REGISTRATION"), + creditEvaluation: vendorFiles.filter(f => f.attachmentType === "CREDIT_REPORT"), + bankCopy: vendorFiles.filter(f => f.attachmentType === "BANK_ACCOUNT_COPY"), + auditResult: investigationFiles, + } + // 미완성 항목 계산 const missingDocuments = Object.entries(documentStatus) .filter(([, value]) => !value) @@ -831,6 +843,7 @@ export async function fetchVendorRegistrationStatus(vendorId: number) { vendor: vendor[0], registration: registration[0] || null, documentStatus, + documentFiles, // 문서별 파일 정보 추가 missingDocuments, businessContacts, missingContactTypes, -- cgit v1.2.3