From ef4c533ebacc2cdc97e518f30e9a9350004fcdfb Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 28 Apr 2025 02:13:30 +0000 Subject: ~20250428 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/repository.ts | 167 ++++ lib/basic-contract/service.ts | 957 +++++++++++++++++++++ .../status/basic-contract-columns.tsx | 213 +++++ lib/basic-contract/status/basic-contract-table.tsx | 95 ++ .../status/basicContract-table-toolbar-actions.tsx | 40 + .../add-basic-contract-template-dialog.tsx | 359 ++++++++ .../template/basic-contract-template-columns.tsx | 245 ++++++ .../template/basic-contract-template.tsx | 104 +++ .../basicContract-table-toolbar-actions.tsx | 53 ++ .../template/delete-basicContract-dialog.tsx | 149 ++++ .../template/update-basicContract-sheet.tsx | 300 +++++++ lib/basic-contract/validations.ts | 87 ++ .../vendor-table/basic-contract-columns.tsx | 214 +++++ .../vendor-table/basic-contract-sign-dialog.tsx | 318 +++++++ .../vendor-table/basic-contract-table.tsx | 94 ++ .../basicContract-table-toolbar-actions.tsx | 56 ++ .../viewer/basic-contract-sign-viewer.tsx | 224 +++++ 17 files changed, 3675 insertions(+) create mode 100644 lib/basic-contract/repository.ts create mode 100644 lib/basic-contract/service.ts create mode 100644 lib/basic-contract/status/basic-contract-columns.tsx create mode 100644 lib/basic-contract/status/basic-contract-table.tsx create mode 100644 lib/basic-contract/status/basicContract-table-toolbar-actions.tsx create mode 100644 lib/basic-contract/template/add-basic-contract-template-dialog.tsx create mode 100644 lib/basic-contract/template/basic-contract-template-columns.tsx create mode 100644 lib/basic-contract/template/basic-contract-template.tsx create mode 100644 lib/basic-contract/template/basicContract-table-toolbar-actions.tsx create mode 100644 lib/basic-contract/template/delete-basicContract-dialog.tsx create mode 100644 lib/basic-contract/template/update-basicContract-sheet.tsx create mode 100644 lib/basic-contract/validations.ts create mode 100644 lib/basic-contract/vendor-table/basic-contract-columns.tsx create mode 100644 lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx create mode 100644 lib/basic-contract/vendor-table/basic-contract-table.tsx create mode 100644 lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx create mode 100644 lib/basic-contract/viewer/basic-contract-sign-viewer.tsx (limited to 'lib/basic-contract') diff --git a/lib/basic-contract/repository.ts b/lib/basic-contract/repository.ts new file mode 100644 index 00000000..aab70106 --- /dev/null +++ b/lib/basic-contract/repository.ts @@ -0,0 +1,167 @@ +"use server"; + +import { asc, count,inArray ,eq} from "drizzle-orm"; +import { basicContractTemplates, basicContractView, type BasicContractTemplate } from "@/db/schema"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import db from "@/db/db"; + +// 템플릿 목록 조회 +export async function selectBasicContractTemplates( + tx: PgTransaction, + options: { + where?: any; + orderBy?: any[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset, limit } = options; + + return tx + .select() + .from(basicContractTemplates) + .where(where || undefined) + .orderBy(...(orderBy || [asc(basicContractTemplates.createdAt)])) + .offset(offset || 0) + .limit(limit || 50); +} + +export async function selectBasicContracts( + tx: PgTransaction, + options: { + where?: any; + orderBy?: any[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset, limit } = options; + + return tx + .select() + .from(basicContractView) + .where(where || undefined) + .orderBy(...(orderBy || [asc(basicContractView.createdAt)])) + .offset(offset || 0) + .limit(limit || 50); +} + +// 템플릿 개수 조회 +export async function countBasicContractTemplates( + tx: PgTransaction, + where?: any +) { + const result = await tx + .select({ count: count() }) + .from(basicContractTemplates) + .where(where || undefined); + + return result[0]?.count || 0; +} + +export async function countBasicContracts( + tx: PgTransaction, + where?: any +) { + const result = await tx + .select({ count: count() }) + .from(basicContractView) + .where(where || undefined); + + return result[0]?.count || 0; +} + + +// 템플릿 생성 +export async function insertBasicContractTemplate( + tx: PgTransaction, + data: Omit +) { + return tx + .insert(basicContractTemplates) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); +} + +/** + * ID로 특정 기본 계약서 템플릿을 조회합니다. + * @param tx 데이터베이스 트랜잭션 + * @param id 조회할 템플릿 ID (문자열로 받아서 숫자로 변환) + * @returns 조회된 템플릿 또는 에러가 있는 경우 null + */ +export async function getBasicContractTemplateById( + tx: PgTransaction, + id: number +): Promise<{ data: BasicContractTemplate | null; error: string | null }> { + try { + + const templates = await tx + .select() + .from(basicContractTemplates) + .where(eq(basicContractTemplates.id, id)); + + if (!templates || templates.length === 0) { + return { data: null, error: null }; + } + + return { data: templates[0], error: null }; + } catch (error) { + console.error(`템플릿 조회 중 오류 발생 (ID: ${id}):`, error); + return { + data: null, + error: error instanceof Error ? error.message : "템플릿 조회 중 오류가 발생했습니다." + }; + } +} + +/** + * 여러 기본 계약서 템플릿을 ID 배열 기반으로 삭제합니다. + * @param tx 데이터베이스 트랜잭션 + * @param ids 삭제할 템플릿 ID 배열 (문자열로 받아서 숫자로 변환) + * @returns 삭제된 템플릿 배열 또는 에러 정보 + */ +export async function deleteBasicContractTemplates( + tx: PgTransaction, + ids: number[] +): Promise<{ data: BasicContractTemplate[] | null; error: string | null }> { + if (!ids || ids.length === 0) { + return { data: [], error: null }; + } + + try { + + + // 삭제될 템플릿 정보를 반환하기 위해 먼저 조회 + const templatesBeforeDelete = await tx + .select() + .from(basicContractTemplates) + .where(inArray(basicContractTemplates.id, ids)); + + // 삭제 실행 + const deletedTemplates = await tx + .delete(basicContractTemplates) + .where(inArray(basicContractTemplates.id, ids)) + .returning(); + + return { + data: templatesBeforeDelete.length > 0 ? templatesBeforeDelete : deletedTemplates, + error: null + }; + } catch (error) { + console.error("템플릿 삭제 중 오류 발생:", error); + return { + data: null, + error: error instanceof Error ? error.message : "템플릿 삭제 중 오류가 발생했습니다." + }; + } +} + + + +export async function findAllTemplates(): Promise { + return db.select().from(basicContractTemplates).orderBy(asc(basicContractTemplates.id)); +} \ No newline at end of file diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts new file mode 100644 index 00000000..09f8f119 --- /dev/null +++ b/lib/basic-contract/service.ts @@ -0,0 +1,957 @@ +"use server"; + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { getErrorMessage } from "@/lib/handle-error"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; +import { + basicContract, + BasicContractTemplate, + basicContractTemplates, + basicContractView, + vendors, + type BasicContractTemplate as DBBasicContractTemplate, +} from "@/db/schema"; +import { toast } from "sonner"; +import { promises as fs } from "fs"; +import path from "path"; +import crypto from "crypto"; + +import { + GetBasicContractTemplatesSchema, + CreateBasicContractTemplateSchema, + GetBasciContractsSchema, +} from "./validations"; + +import { + insertBasicContractTemplate, + selectBasicContractTemplates, + countBasicContractTemplates, + deleteBasicContractTemplates, + getBasicContractTemplateById, + selectBasicContracts, + countBasicContracts, + findAllTemplates +} from "./repository"; +import { revalidatePath } from 'next/cache'; +import { sendEmail } from "../mail/sendEmail"; +import { headers } from 'next/headers'; +import { filterColumns } from "@/lib/filter-columns"; +import { differenceInDays, addYears, isBefore } from "date-fns"; + + + +// 템플릿 추가 +export async function addTemplate( + templateData: FormData | Omit +): Promise<{ success: boolean; data?: BasicContractTemplate; error?: string }> { + try { + // FormData인 경우 파일 추출 및 저장 처리 + if (templateData instanceof FormData) { + const templateName = templateData.get("templateName") as string; + // 문자열을 숫자로 변환 (FormData의 get은 항상 string|File을 반환) + const validityPeriodStr = templateData.get("validityPeriod") as string; + const validityPeriod = validityPeriodStr ? parseInt(validityPeriodStr, 10) : 12; // 기본값 12개월 + const status = templateData.get("status") as "ACTIVE" | "INACTIVE"; + const file = templateData.get("file") as File; + + // 유효성 검사 + if (!templateName) { + return { success: false, error: "템플릿 이름은 필수입니다." }; + } + + if (!file) { + return { success: false, error: "파일은 필수입니다." }; + } + + if (isNaN(validityPeriod) || validityPeriod < 1 || validityPeriod > 120) { + return { + success: false, + error: "유효기간은 1~120개월 사이의 유효한 값이어야 합니다." + }; + } + + // 원본 파일 이름과 확장자 분리 + const originalFileName = file.name; + const fileExtension = path.extname(originalFileName); + const fileNameWithoutExt = path.basename(originalFileName, fileExtension); + + // 해시된 파일 이름 생성 (타임스탬프 + 랜덤 해시 + 확장자) + const timestamp = Date.now(); + const randomHash = crypto.createHash('md5') + .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`) + .digest('hex') + .substring(0, 8); + + const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`; + + // 저장 디렉토리 설정 (uploads/contracts 폴더 사용) + const uploadDir = path.join(process.cwd(), "public", "basicContract", "template"); + + // 디렉토리가 없으면 생성 + try { + await fs.mkdir(uploadDir, { recursive: true }); + } catch (err) { + console.log("Directory already exists or creation failed:", err); + } + + // 파일 경로 설정 + const filePath = path.join(uploadDir, hashedFileName); + const publicFilePath = `/basicContract/template/${hashedFileName}`; + + // 파일을 ArrayBuffer로 변환 + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // 파일 저장 + await fs.writeFile(filePath, buffer); + + // DB에 저장할 데이터 구성 + const formattedData = { + templateName, + status, + validityPeriod, // 숫자로 변환된 유효기간 + fileName: originalFileName, // 원본 파일 이름 + filePath: publicFilePath, // 공개 접근 가능한 경로 + }; + + // DB에 저장 + const { data, error } = await createBasicContractTemplate(formattedData); + + if (error) { + // 파일 저장 후 DB 저장 실패 시 저장된 파일 삭제 + try { + await fs.unlink(filePath); + } catch (unlinkError) { + console.error("파일 삭제 실패:", unlinkError); + } + return { success: false, error }; + } + + return { success: true, data: data || undefined }; + + } + // 기존 객체 형태인 경우 (호환성 유지) + else { + const formattedData = { + ...templateData, + status: templateData.status as "ACTIVE" | "INACTIVE", + // validityPeriod가 없으면 기본값 12개월 사용 + validityPeriod: templateData.validityPeriod || 12, + }; + + const { data, error } = await createBasicContractTemplate(formattedData); + + if (error) { + return { success: false, error }; + } + + return { success: true, data: data || undefined }; + + } + } catch (error) { + console.error("Template add error:", error); + return { + success: false, + error: error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다." + }; + } +} +// 기본 계약서 템플릿 목록 조회 (서버 액션) +export async function getBasicContractTemplates( + input: GetBasicContractTemplatesSchema +) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + const { data, total } = await db.transaction(async (tx) => { + // 간소화된 구현 - 실제 filterColumns 함수는 더 복잡할 수 있습니다 + let whereCondition = undefined; + + if (input.search) { + const s = `%${input.search}%`; + whereCondition = or( + ilike(basicContractTemplates.templateName, s), + ilike(basicContractTemplates.fileName, s), + ilike(basicContractTemplates.status, s) + ); + } + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + basicContractTemplates[ + item.id as keyof typeof basicContractTemplates + ] as any + ) + : asc( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + basicContractTemplates[ + item.id as keyof typeof basicContractTemplates + ] as any + ) + ) + : [desc(basicContractTemplates.createdAt)]; + + const dataResult = await selectBasicContractTemplates(tx, { + where: whereCondition, + orderBy, + offset, + limit: input.perPage, + }); + + + const totalCount = await countBasicContractTemplates( + tx, + whereCondition + ); + return { data: dataResult, total: totalCount }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (error) { + console.error("getBasicContractTemplates 에러:", error); + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["basic-contract-templates"], + } + )(); +} + + +// 템플릿 생성 (서버 액션) +export async function createBasicContractTemplate( + input: CreateBasicContractTemplateSchema +) { + unstable_noStore(); + try { + const newTemplate = await db.transaction(async (tx) => { + const [newTemplate] = await insertBasicContractTemplate(tx, { + templateName: input.templateName, + validityPeriod: input.validityPeriod, + status: input.status, + fileName: input.fileName, + filePath: input.filePath, + }); + return newTemplate; + }); + + // 캐시 무효화 + revalidateTag("basic-contract-templates"); + revalidateTag("template-status-counts"); + + return { data: newTemplate, error: null }; + } catch (error) { + return { data: null, error: getErrorMessage(error) }; + } +} + +//서명 계약서 저장, 김기만 프로님 추가 코드 +export const saveSignedContract = async ( + fileBuffer: ArrayBuffer, + templateName: string, + tableRowId: number +): Promise<{ result: true } | { result: false; error: string }> => { + try { + const originalName = `${tableRowId}_${templateName}`; + const ext = path.extname(originalName); + const uniqueName = uuidv4() + ext; + + const publicDir = path.join(process.cwd(), "public", "basicContract"); + const relativePath = `/basicContract/${uniqueName}`; + const absolutePath = path.join(publicDir, uniqueName); + const buffer = Buffer.from(fileBuffer); + + await fs.mkdir(publicDir, { recursive: true }); + await fs.writeFile(absolutePath, buffer); + + await db.transaction(async (tx) => { + await tx + .update(basicContract) + .set({ + status: "COMPLETED", + fileName: originalName, + filePath: relativePath, + }) + .where(eq(basicContract.id, tableRowId)); + }); + // 캐시 무효화 + revalidateTag("basic-contract-requests"); + revalidateTag("template-status-counts"); + + return { result: true }; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류"; + return { result: false, error: errorMessage }; + } +}; + +interface RemoveTemplatesProps { + ids: number[]; +} + + +interface TemplateFile { + id: number; + filePath: string; +} + +export async function removeTemplates({ + ids +}: RemoveTemplatesProps): Promise<{ success?: boolean; error?: string }> { + if (!ids || ids.length === 0) { + return { error: "삭제할 템플릿이 선택되지 않았습니다." }; + } + + // unstable_noStore를 최상단에 배치 + unstable_noStore(); + + try { + // 파일 삭제를 위한 템플릿 정보 조회 및 DB 삭제를 직접 트랜잭션으로 처리 + // withTransaction 대신 db.transaction 직접 사용 (createBasicContractTemplate와 일관성 유지) + const templateFiles: TemplateFile[] = []; + + const result = await db.transaction(async (tx) => { + // 각 템플릿의 파일 경로 가져오기 + for (const id of ids) { + const { data: template, error } = await getBasicContractTemplateById(tx, id); + if (template && template.filePath) { + templateFiles.push({ + id: template.id, + filePath: template.filePath + }); + } + } + + // DB에서 템플릿 삭제 + const { data, error } = await deleteBasicContractTemplates(tx, ids); + + if (error) { + throw new Error(`템플릿 DB 삭제 실패: ${error}`); + } + + return { data }; + }); + + // 파일 시스템 삭제는 트랜잭션 성공 후 수행 + for (const template of templateFiles) { + if (template.filePath) { + const absoluteFilePath = path.join(process.cwd(), 'public', template.filePath); + + try { + await fs.access(absoluteFilePath); + await fs.unlink(absoluteFilePath); + } catch (fileError) { + console.log(`파일 없음 또는 삭제 실패: ${template.filePath}`, fileError); + // 파일 삭제 실패는 전체 작업 성공에 영향 없음 + } + } + } + revalidateTag("basic-contract-templates"); + revalidateTag("template-status-counts"); + + + + // 디버깅을 위한 로그 + console.log("캐시 무효화 완료:", ids); + + return { success: true }; + } catch (error) { + console.error("템플릿 삭제 중 오류 발생:", error); + return { + error: error instanceof Error + ? error.message + : "템플릿 삭제 중 오류가 발생했습니다." + }; + } +} + + +interface UpdateTemplateParams { + id: number; + formData: FormData; +} + +export async function updateTemplate({ + id, + formData +}: UpdateTemplateParams): Promise<{ success?: boolean; error?: string }> { + unstable_noStore(); + + try { + const templateName = formData.get("templateName") as string; + const validityPeriodStr = formData.get("validityPeriod") as string; + const validityPeriod = validityPeriodStr ? parseInt(validityPeriodStr, 10) : 12; + const status = formData.get("status") as "ACTIVE" | "INACTIVE"; + const file = formData.get("file") as File | null; + + if (!templateName) { + return { error: "템플릿 이름은 필수입니다." }; + } + + // 기본 업데이트 데이터 + const updateData: Record = { + templateName, + status, + validityPeriod, + updatedAt: new Date(), + }; + + + // 파일이 있는 경우 처리 + if (file) { + // 원본 파일 이름과 확장자 분리 + const originalFileName = file.name; + const fileExtension = path.extname(originalFileName); + const fileNameWithoutExt = path.basename(originalFileName, fileExtension); + + // 해시된 파일 이름 생성 + const timestamp = Date.now(); + const randomHash = crypto.createHash('md5') + .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`) + .digest('hex') + .substring(0, 8); + + const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`; + + // 저장 디렉토리 설정 + const uploadDir = path.join(process.cwd(), "public", "basicContract", "template"); + + // 디렉토리가 없으면 생성 + try { + await fs.mkdir(uploadDir, { recursive: true }); + } catch (err) { + console.log("Directory already exists or creation failed:", err); + } + + // 파일 경로 설정 + const filePath = path.join(uploadDir, hashedFileName); + const publicFilePath = `/basicContract/template/${hashedFileName}`; + + // 파일을 ArrayBuffer로 변환 + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // 파일 저장 + await fs.writeFile(filePath, buffer); + + // 기존 파일 정보 가져오기 + const existingTemplate = await db.query.basicContractTemplates.findFirst({ + where: eq(basicContractTemplates.id, id) + }); + + // 기존 파일이 있다면 삭제 + if (existingTemplate?.filePath) { + try { + const existingFilePath = path.join(process.cwd(), "public", existingTemplate.filePath); + await fs.access(existingFilePath); // 파일 존재 확인 + await fs.unlink(existingFilePath); // 파일 삭제 + } catch (error) { + console.log("기존 파일 삭제 실패 또는 파일이 없음:", error); + } + } + + // 업데이트 데이터에 파일 정보 추가 + updateData.fileName = originalFileName; + updateData.filePath = publicFilePath; + } + + // DB 업데이트 + await db.transaction(async (tx) => { + await tx + .update(basicContractTemplates) + .set(updateData) + .where(eq(basicContractTemplates.id, id)); + }); + + // 캐시 무효화 (다양한 방법 시도) + revalidateTag("basic-contract-templates"); + revalidateTag("template-status-counts"); + revalidateTag("templates"); + + return { success: true }; + } catch (error) { + console.error("템플릿 업데이트 오류:", error); + return { + error: error instanceof Error + ? error.message + : "템플릿 업데이트 중 오류가 발생했습니다." + }; + } +} + +interface RequestBasicContractInfoProps { + vendorIds: number[]; + requestedBy: number; + templateId: number; +} + + +export async function requestBasicContractInfo({ + vendorIds, + requestedBy, + templateId +}: RequestBasicContractInfoProps): Promise<{ success?: boolean; error?: string }> { + unstable_noStore(); + + if (!vendorIds || vendorIds.length === 0) { + return { error: "요청할 협력업체가 선택되지 않았습니다." }; + } + + if (!templateId) { + return { error: "계약서 템플릿이 선택되지 않았습니다." }; + } + + try { + // 1. 선택된 템플릿 정보 가져오기 + const template = await db.query.basicContractTemplates.findFirst({ + where: eq(basicContractTemplates.id, templateId) + }); + + if (!template) { + return { error: "선택한 템플릿을 찾을 수 없습니다." }; + } + + // 2. 협력업체 정보 가져오기 + const vendorList = await db + .select() + .from(vendors) + .where(inArray(vendors.id, vendorIds)); + + if (!vendorList || vendorList.length === 0) { + return { error: "선택한 협력업체 정보를 찾을 수 없습니다." }; + } + + // 3. 각 협력업체에 대해 기본계약 레코드 생성 및 이메일 발송 + const results = await Promise.all( + vendorList.map(async (vendor) => { + if (!vendor.email) return; // 이메일이 없으면 스킵 + + try { + // 3-1. basic_contract 테이블에 레코드 추가 + const [newContract] = await db + .insert(basicContract) + .values({ + templateId: template.id, + vendorId: vendor.id, + requestedBy: requestedBy, + status: "PENDING", + fileName: template.fileName, // 템플릿 파일 이름 사용 + filePath: template.filePath, // 템플릿 파일 경로 사용 + }) + .returning(); + + // 3-2. 협력업체에 이메일 발송 + const subject = `[${process.env.COMPANY_NAME || '회사명'}] 기본계약서 서명 요청`; + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + // 로그인 또는 서명 페이지 URL 생성 + const baseUrl = `http://${host}` + const loginUrl = `${baseUrl}/partners/basic-contract`; + + // 사용자 언어 설정 (기본값은 한국어) + const userLang = "ko"; + + // 이메일 발송 + await sendEmail({ + to: vendor.email, + subject, + template: "contract-sign-request", // 이메일 템플릿 이름 + context: { + vendorName: vendor.vendorName, + contractId: newContract.id, + templateName: template.templateName, + loginUrl, + language: userLang, + }, + }); + + return { vendorId: vendor.id, success: true }; + } catch (err) { + console.error(`협력업체 ${vendor.id} 처리 중 오류:`, err); + return { vendorId: vendor.id, success: false, error: getErrorMessage(err) }; + } + }) + ); + + // 4. 실패한 케이스가 있는지 확인 + const failedVendors = results.filter(r => r && !r.success); + + if (failedVendors.length > 0) { + console.error("일부 협력업체 처리 실패:", failedVendors); + if (failedVendors.length === vendorIds.length) { + // 모든 협력업체 처리 실패 + return { error: "모든 협력업체에 대한 처리가 실패했습니다." }; + } else { + // 일부 협력업체만 처리 실패 + return { + success: true, + error: `${results.length - failedVendors.length}개 협력업체 처리 성공, ${failedVendors.length}개 처리 실패` + }; + } + } + + // 5. 캐시 무효화 + revalidateTag("basic-contract-requests"); + + return { success: true }; + } catch (error) { + console.error("기본계약서 요청 중 오류 발생:", error); + return { + error: error instanceof Error + ? error.message + : "기본계약서 요청 처리 중 오류가 발생했습니다." + }; + } +} + + +export async function getBasicContracts(input: GetBasciContractsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: basicContractView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(basicContractView.templateName, s), + ilike(basicContractView.vendorName, s) + , ilike(basicContractView.vendorCode, s) + , ilike(basicContractView.vendorEmail, s) + , ilike(basicContractView.status, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere // and()함수로 결합 or or() 등으로 결합 + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id]) + ) + : [asc(basicContractView.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectBasicContracts(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countBasicContracts(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["basicContractView"], // revalidateTag("basicContractView") 호출 시 무효화 + } + )(); +} + + +export async function getBasicContractsByVendorId( + input: GetBasciContractsSchema, + vendorId: number +) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: basicContractView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(basicContractView.templateName, s), + ilike(basicContractView.vendorName, s), + ilike(basicContractView.vendorCode, s), + ilike(basicContractView.vendorEmail, s), + ilike(basicContractView.status, s) + ); + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + // 벤더 ID 필터링 조건 추가 + const vendorCondition = eq(basicContractView.vendorId, vendorId); + + const finalWhere = and( + // 항상 벤더 ID 조건을 포함 + vendorCondition, + // 기존 조건들 + advancedWhere, + globalWhere + ); + + const where = finalWhere; + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id]) + ) + : [asc(basicContractView.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectBasicContracts(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countBasicContracts(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가 + { + revalidate: 3600, + tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화 + } + )(); +} + +export async function getAllTemplates(): Promise { + try { + return await findAllTemplates(); + } catch (err) { + throw new Error("Failed to get templates"); + } +} + + +interface VendorTemplateStatus { + vendorId: number; + vendorName: string; + templateId: number; + templateName: string; + status: string; + createdAt: Date; + isExpired: boolean; // 요청이 오래되었는지 (예: 30일 이상) + isUpdated: boolean; // 템플릿이 업데이트되었는지 +} + +/** + * 협력업체와 템플릿 조합에 대한 계약 요청 상태를 확인합니다. + */ +// 계약 상태 확인 API 함수 +export async function checkContractRequestStatus( + vendorIds: number[], + templateIds: number[] +) { + try { + // 각 협력업체-템플릿 조합에 대한 최신 계약 요청 상태 확인 + const requests = await db + .select({ + id: basicContract.id, + vendorId: basicContract.vendorId, + templateId: basicContract.templateId, + status: basicContract.status, + createdAt: basicContract.createdAt, + updatedAt: basicContract.updatedAt, + // completedAt 필드 추가 필요 + completedAt: basicContract.completedAt, // 계약 완료 날짜 + }) + .from(basicContract) + .where( + and( + inArray(basicContract.vendorId, vendorIds), + inArray(basicContract.templateId, templateIds) + ) + ) + .orderBy(desc(basicContract.createdAt)); + + // 협력업체 정보 가져오기 + const vendorData = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + }) + .from(vendors) + .where(inArray(vendors.id, vendorIds)); + + // 템플릿 정보 가져오기 + const templateData = await db + .select({ + id: basicContractTemplates.id, + templateName: basicContractTemplates.templateName, + updatedAt: basicContractTemplates.updatedAt, + validityPeriod: basicContractTemplates.validityPeriod, // 템플릿별 유효기간(개월) + }) + .from(basicContractTemplates) + .where(inArray(basicContractTemplates.id, templateIds)); + + // 데이터 가공 - 협력업체별, 템플릿별로 상태 매핑 + const vendorMap = new Map(vendorData.map(v => [v.id, v])); + const templateMap = new Map(templateData.map(t => [t.id, t])); + + const uniqueRequests = new Map(); + + // 각 협력업체-템플릿 조합에 대해 가장 최근 요청만 사용 + requests.forEach(req => { + const key = `${req.vendorId}-${req.templateId}`; + if (!uniqueRequests.has(key)) { + uniqueRequests.set(key, req); + } + }); + + // 인터페이스를 임포트하거나 이 함수 내에서/위에서 재정의 + interface VendorTemplateStatus { + vendorId: number; + vendorName: string; + templateId: number; + templateName: string; + status: string; + createdAt: Date; + completedAt?: Date; + isExpired: boolean; + isUpdated: boolean; + isContractExpired: boolean; + } + + // 명시적 타입 지정 + const statusData: VendorTemplateStatus[] = []; + + // 요청 만료 기준 - 30일 + const REQUEST_EXPIRATION_DAYS = 30; + + // 기본 계약 유효기간 - 12개월 (템플릿별로 다르게 설정 가능) + const DEFAULT_CONTRACT_VALIDITY_MONTHS = 12; + + const now = new Date(); + + // 모든 협력업체-템플릿 조합에 대해 상태 확인 + vendorIds.forEach(vendorId => { + templateIds.forEach(templateId => { + const key = `${vendorId}-${templateId}`; + const request = uniqueRequests.get(key); + const vendor = vendorMap.get(vendorId); + const template = templateMap.get(templateId); + + if (!vendor || !template) return; + + let status = "NONE"; // 기본 상태: 요청 없음 + let createdAt = new Date(); + let completedAt = null; + let isExpired = false; + let isUpdated = false; + let isContractExpired = false; + + if (request) { + status = request.status; + createdAt = request.createdAt; + completedAt = request.completedAt; + + // 요청이 오래되었는지 확인 (PENDING 상태일 때만 적용) + if (status === "PENDING") { + isExpired = differenceInDays(now, createdAt) > REQUEST_EXPIRATION_DAYS; + } + + // 요청 이후 템플릿이 업데이트되었는지 확인 + if (template.updatedAt && request.createdAt) { + isUpdated = template.updatedAt > request.createdAt; + } + + // 계약 유효기간 만료 확인 (COMPLETED 상태이고 completedAt이 있는 경우) + if (status === "COMPLETED" && completedAt) { + // 템플릿별 유효기간 또는 기본값 사용 + const validityMonths = template.validityPeriod || DEFAULT_CONTRACT_VALIDITY_MONTHS; + + // 계약 만료일 계산 (완료일 + 유효기간) + const expiryDate = addYears(completedAt, validityMonths / 12); + + // 현재 날짜가 만료일 이후인지 확인 + isContractExpired = isBefore(expiryDate, now); + } + } + + statusData.push({ + vendorId, + vendorName: vendor.vendorName, + templateId, + templateName: template.templateName, + status, + createdAt, + completedAt, + isExpired, + isUpdated, + isContractExpired, + }); + }); + }); + + return { data: statusData }; + } catch (error) { + console.error("계약 상태 확인 중 오류:", error); + return { + data: [], + error: error instanceof Error ? error.message : "계약 상태 확인 중 오류가 발생했습니다." + }; + } +} \ No newline at end of file diff --git a/lib/basic-contract/status/basic-contract-columns.tsx b/lib/basic-contract/status/basic-contract-columns.tsx new file mode 100644 index 00000000..6ca4a096 --- /dev/null +++ b/lib/basic-contract/status/basic-contract-columns.tsx @@ -0,0 +1,213 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Paperclip } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { basicContractColumnsConfig } from "@/config/basicContractColumnsConfig" +import { BasicContractView } from "@/db/schema" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +/** + * 파일 다운로드 함수 + */ +/** + * 파일 다운로드 함수 + */ +const handleFileDownload = (filePath: string | null, fileName: string | null) => { + if (!filePath || !fileName) { + toast.error("파일 정보가 없습니다."); + return; + } + + try { + // 전체 URL 생성 + const fullUrl = `${window.location.origin}${filePath}`; + + // a 태그를 생성하여 다운로드 실행 + const link = document.createElement('a'); + link.href = fullUrl; + link.download = fileName; // 다운로드될 파일명 설정 + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success("파일 다운로드를 시작합니다."); + } catch (error) { + console.error("파일 다운로드 오류:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } +}; + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 파일 다운로드 컬럼 (아이콘) + // ---------------------------------------------------------------- + const downloadColumn: ColumnDef = { + id: "download", + header: "", + cell: ({ row }) => { + const template = row.original; + + return ( + + ); + }, + maxSize: 30, + enableSorting: false, + } + + + // ---------------------------------------------------------------- + // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 4-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + basicContractColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + // 날짜 형식 처리 + if (cfg.id === "createdAt" || cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDateTime(dateVal) + } + + // Status 컬럼에 Badge 적용 + if (cfg.id === "status") { + const status = row.getValue(cfg.id) as string + const isActive = status === "ACTIVE" + + return ( + + {isActive ? "활성" : "비활성"} + + ) + } + + // 나머지 컬럼은 그대로 값 표시 + return row.getValue(cfg.id) ?? "" + }, + minSize: 80, + + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 5) 최종 컬럼 배열: select, download, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + downloadColumn, // 다운로드 컬럼 추가 + ...nestedColumns, + ] +} \ No newline at end of file diff --git a/lib/basic-contract/status/basic-contract-table.tsx b/lib/basic-contract/status/basic-contract-table.tsx new file mode 100644 index 00000000..22845144 --- /dev/null +++ b/lib/basic-contract/status/basic-contract-table.tsx @@ -0,0 +1,95 @@ +"use client"; + +import * as React from "react"; +import { DataTable } from "@/components/data-table/data-table"; +import { Button } from "@/components/ui/button"; +import { Plus, Loader2 } from "lucide-react"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { toast } from "sonner"; +import { getColumns } from "./basic-contract-columns"; +import { getBasicContracts } from "../service"; +import { BasicContractView } from "@/db/schema"; +import { BasicContractTableToolbarActions } from "./basicContract-table-toolbar-actions"; + + +interface BasicTemplateTableProps { + promises: Promise< + [ + Awaited>, + ] + > +} + + +export function BasicContractsTable({ promises }: BasicTemplateTableProps) { + + + const [rowAction, setRowAction] = + React.useState | null>(null) + + + const [{ data, pageCount }] = + React.use(promises) + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // config 기반으로 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "templateName", label: "템플릿명", type: "text" }, + { + id: "status", label: "상태", type: "select", options: [ + { label: "활성", value: "ACTIVE" }, + { label: "비활성", value: "INACTIVE" }, + ] + }, + { id: "userName", label: "요청자", type: "text" }, + { id: "vendorName", label: "업체명", type: "text" }, + { id: "vendorCode", label: "업체코드", type: "text" }, + { id: "vendorEmail", label: "업체대표이메일", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + // filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx new file mode 100644 index 00000000..cee94790 --- /dev/null +++ b/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx @@ -0,0 +1,40 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { BasicContractView } from "@/db/schema" + +interface TemplateTableToolbarActionsProps { + table: Table +} + +export function BasicContractTableToolbarActions({ table }: TemplateTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + + + return ( +
+ + {/** 4) Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx new file mode 100644 index 00000000..cf0986f0 --- /dev/null +++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx @@ -0,0 +1,359 @@ +"use client"; + +import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { toast } from "sonner"; +import { v4 as uuidv4 } from 'uuid'; +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone"; +import { Progress } from "@/components/ui/progress"; +import { useRouter } from "next/navigation" + +// 유효기간 필드가 추가된 계약서 템플릿 스키마 정의 +const templateFormSchema = z.object({ + templateName: z.string().min(1, "템플릿 이름은 필수입니다."), + validityPeriod: z.coerce + .number({ invalid_type_error: "유효기간은 숫자여야 합니다." }) + .int("유효기간은 정수여야 합니다.") + .min(1, "유효기간은 최소 1개월 이상이어야 합니다.") + .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.") + .default(12), + file: z + .instanceof(File, { message: "파일을 업로드해주세요." }) + .refine((file) => file.size <= 100 * 1024 * 1024, { + message: "파일 크기는 100MB 이하여야 합니다.", + }) + .refine( + (file) => file.type === 'application/pdf', + { message: "PDF 파일만 업로드 가능합니다." } + ), + status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"), +}); + +type TemplateFormValues = z.infer; + +export function AddTemplateDialog() { + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const [selectedFile, setSelectedFile] = React.useState(null); + const [uploadProgress, setUploadProgress] = React.useState(0); + const [showProgress, setShowProgress] = React.useState(false); + const router = useRouter() + + // 기본값 설정 + const defaultValues: Partial = { + templateName: "", + validityPeriod: 12, // 기본값 1년 + status: "ACTIVE", + }; + + // 폼 초기화 + const form = useForm({ + resolver: zodResolver(templateFormSchema), + defaultValues, + mode: "onChange", + }); + + // 폼 값 감시 + const templateName = form.watch("templateName"); + const validityPeriod = form.watch("validityPeriod"); + const file = form.watch("file"); + + // 파일 선택 핸들러 + const handleFileChange = (files: File[]) => { + if (files.length > 0) { + const file = files[0]; + setSelectedFile(file); + form.setValue("file", file); + } + }; + + // 청크 크기 설정 (1MB) + const CHUNK_SIZE = 1 * 1024 * 1024; + + // 파일을 청크로 분할하여 업로드하는 함수 + const uploadFileInChunks = async (file: File, fileId: string) => { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + setShowProgress(true); + setUploadProgress(0); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + const formData = new FormData(); + formData.append('chunk', chunk); + formData.append('filename', file.name); + formData.append('chunkIndex', chunkIndex.toString()); + formData.append('totalChunks', totalChunks.toString()); + formData.append('fileId', fileId); + + try { + const response = await fetch('/api/upload/basicContract/chunk', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`청크 업로드 실패: ${response.statusText}`); + } + + // 진행률 업데이트 + const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100); + setUploadProgress(progress); + + const result = await response.json(); + + // 마지막 청크인 경우 파일 경로 반환 + if (chunkIndex === totalChunks - 1) { + return result; + } + } catch (error) { + console.error(`청크 ${chunkIndex} 업로드 오류:`, error); + throw error; + } + } + }; + + // 폼 제출 핸들러 + async function onSubmit(formData: TemplateFormValues) { + setIsLoading(true); + try { + if (!formData.file) { + throw new Error("파일이 선택되지 않았습니다."); + } + + // 고유 파일 ID 생성 + const fileId = uuidv4(); + + // 파일 청크 업로드 + const uploadResult = await uploadFileInChunks(formData.file, fileId); + + if (!uploadResult.success) { + throw new Error("파일 업로드에 실패했습니다."); + } + + // 메타데이터 저장 + const saveResponse = await fetch('/api/upload/basicContract/complete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + templateName: formData.templateName, + validityPeriod: formData.validityPeriod, // 유효기간 추가 + status: formData.status, + fileName: uploadResult.fileName, + filePath: uploadResult.filePath, + }), + next: { tags: ["basic-contract-templates"] }, + }); + + const saveResult = await saveResponse.json(); + + if (!saveResult.success) { + throw new Error("템플릿 정보 저장에 실패했습니다."); + } + + toast.success('템플릿이 성공적으로 추가되었습니다.'); + form.reset(); + setSelectedFile(null); + setOpen(false); + setShowProgress(false); + + router.refresh(); + } catch (error) { + console.error("Submit error:", error); + toast.error("템플릿 추가 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } + + // 모달이 닫힐 때 폼 초기화 + React.useEffect(() => { + if (!open) { + form.reset(); + setSelectedFile(null); + setShowProgress(false); + setUploadProgress(0); + } + }, [open, form]); + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset(); + } + setOpen(nextOpen); + } + + // 유효기간 선택 옵션 + const validityOptions = [ + { value: "3", label: "3개월" }, + { value: "6", label: "6개월" }, + { value: "12", label: "1년" }, + { value: "24", label: "2년" }, + { value: "36", label: "3년" }, + { value: "60", label: "5년" }, + ]; + + return ( + + + + + + + 새 기본계약서 템플릿 추가 + + 템플릿 이름을 입력하고 계약서 파일을 업로드하세요. + * 표시된 항목은 필수 입력사항입니다. + + +
+ + ( + + + 템플릿 이름 * + + + + + + + )} + /> + + ( + + + 계약 유효기간 * + + + + 계약서의 유효 기간을 설정합니다. 이 기간이 지나면 재계약이 필요합니다. + + + + )} + /> + + ( + + + 계약서 파일 * + + + + + + + {selectedFile ? selectedFile.name : "PDF 파일을 여기에 드래그하세요"} + + + {selectedFile + ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB` + : "또는 클릭하여 PDF 파일을 선택하세요 (최대 100MB)"} + + + + + + + + )} + /> + + {showProgress && ( +
+
+ 업로드 진행률 + {uploadProgress}% +
+ +
+ )} + + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/lib/basic-contract/template/basic-contract-template-columns.tsx b/lib/basic-contract/template/basic-contract-template-columns.tsx new file mode 100644 index 00000000..b0486fe4 --- /dev/null +++ b/lib/basic-contract/template/basic-contract-template-columns.tsx @@ -0,0 +1,245 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Download, Ellipsis, Paperclip } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { basicContractTemplateColumnsConfig } from "@/config/basicContractColumnsConfig" +import { BasicContractTemplate } from "@/db/schema" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +/** + * 파일 다운로드 함수 + */ +const handleFileDownload = (filePath: string, fileName: string) => { + try { + // 전체 URL 생성 + const fullUrl = `${window.location.origin}${filePath}`; + + // a 태그를 생성하여 다운로드 실행 + const link = document.createElement('a'); + link.href = fullUrl; + link.download = fileName; // 다운로드될 파일명 설정 + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success("파일 다운로드를 시작합니다."); + } catch (error) { + console.error("파일 다운로드 오류:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } +}; + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 파일 다운로드 컬럼 (아이콘) + // ---------------------------------------------------------------- + const downloadColumn: ColumnDef = { + id: "download", + header: "", + cell: ({ row }) => { + const template = row.original; + + return ( + + ); + }, + maxSize: 30, + enableSorting: false, + } + + // ---------------------------------------------------------------- + // 3) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + + + + + + setRowAction({ row, type: "update" })} + > + Edit + + + + setRowAction({ row, type: "delete" })} + > + Delete + ⌘⌫ + + + + ) + }, + maxSize: 30, + } + + // ---------------------------------------------------------------- + // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 4-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + basicContractTemplateColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + // 날짜 형식 처리 + if (cfg.id === "createdAt" || cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDateTime(dateVal) + } + + // Status 컬럼에 Badge 적용 + if (cfg.id === "status") { + const status = row.getValue(cfg.id) as string + const isActive = status === "ACTIVE" + + return ( + + {isActive ? "활성" : "비활성"} + + ) + } + + // 나머지 컬럼은 그대로 값 표시 + return row.getValue(cfg.id) ?? "" + }, + minSize:80 + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 5) 최종 컬럼 배열: select, download, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + downloadColumn, // 다운로드 컬럼 추가 + ...nestedColumns, + actionsColumn, + ] +} \ No newline at end of file diff --git a/lib/basic-contract/template/basic-contract-template.tsx b/lib/basic-contract/template/basic-contract-template.tsx new file mode 100644 index 00000000..0cca3a41 --- /dev/null +++ b/lib/basic-contract/template/basic-contract-template.tsx @@ -0,0 +1,104 @@ +"use client"; + +import * as React from "react"; +import { DataTable } from "@/components/data-table/data-table"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table" +import { getBasicContractTemplates} from "../service"; +import { getColumns } from "./basic-contract-template-columns"; +import { DeleteTemplatesDialog } from "./delete-basicContract-dialog"; +import { UpdateTemplateSheet } from "./update-basicContract-sheet"; +import { TemplateTableToolbarActions } from "./basicContract-table-toolbar-actions"; +import { BasicContractTemplate } from "@/db/schema"; + + +interface BasicTemplateTableProps { + promises: Promise< + [ + Awaited>, + ] + > +} + + +export function BasicContractTemplateTable({ promises }: BasicTemplateTableProps) { + + + const [rowAction, setRowAction] = + React.useState | null>(null) + + + const [{ data, pageCount }] = + React.use(promises) + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // config 기반으로 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "templateName", label: "템플릿명", type: "text" }, + { + id: "status", label: "상태", type: "select", options: [ + { label: "활성", value: "ACTIVE" }, + { label: "비활성", value: "INACTIVE" }, + ] + }, + { id: "fileName", label: "파일명", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + // filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + + + + + + + + setRowAction(null)} + templates={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + setRowAction(null)} + template={rowAction?.row.original ?? null} + /> + + + + ); +} \ No newline at end of file diff --git a/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx new file mode 100644 index 00000000..439fea26 --- /dev/null +++ b/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { DeleteTemplatesDialog } from "./delete-basicContract-dialog" +import { AddTemplateDialog } from "./add-basic-contract-template-dialog" +import { BasicContractTemplate } from "@/db/schema" + +interface TemplateTableToolbarActionsProps { + table: Table +} + +export function TemplateTableToolbarActions({ table }: TemplateTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + + + return ( +
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + + + {/** 4) Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/basic-contract/template/delete-basicContract-dialog.tsx b/lib/basic-contract/template/delete-basicContract-dialog.tsx new file mode 100644 index 00000000..307bd9aa --- /dev/null +++ b/lib/basic-contract/template/delete-basicContract-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { removeTemplates } from "../service" +import { BasicContractTemplate } from "@/db/schema" + +interface DeleteBasicContractsDialogProps + extends React.ComponentPropsWithoutRef { + templates: Row["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteTemplatesDialog({ + templates, + showTrigger = true, + onSuccess, + ...props +}: DeleteBasicContractsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeTemplates({ + ids: templates.map((template) => template.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Templates deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + + {showTrigger ? ( + + + + ) : null} + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your{" "} + {templates.length} + {templates.length === 1 ? " template" : " templates"} from our servers. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your{" "} + {templates.length} + {templates.length === 1 ? " template" : " templates"} from our servers. + + + + + + + + + + + ) +} diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx new file mode 100644 index 00000000..2c6efc9b --- /dev/null +++ b/lib/basic-contract/template/update-basicContract-sheet.tsx @@ -0,0 +1,300 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" +import { updateTemplate } from "../service" +import { BasicContractTemplate } from "@/db/schema" + +// 업데이트 템플릿 스키마 정의 (유효기간 필드 추가) +export const updateTemplateSchema = z.object({ + templateName: z.string().min(1, "템플릿 이름은 필수입니다."), + validityPeriod: z.coerce + .number({ invalid_type_error: "유효기간은 숫자여야 합니다." }) + .int("유효기간은 정수여야 합니다.") + .min(1, "유효기간은 최소 1개월 이상이어야 합니다.") + .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.") + .default(12), + status: z.enum(["ACTIVE", "INACTIVE"], { + required_error: "상태는 필수 선택사항입니다.", + }), + file: z.instanceof(File, { message: "파일을 업로드해주세요." }).optional(), +}) + +export type UpdateTemplateSchema = z.infer + +interface UpdateTemplateSheetProps + extends React.ComponentPropsWithRef { + template: BasicContractTemplate | null + onSuccess?: () => void +} + +export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTemplateSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [selectedFile, setSelectedFile] = React.useState(null) + + // 템플릿 데이터 확인을 위한 로그 + console.log(template) + + const form = useForm({ + resolver: zodResolver(updateTemplateSchema), + defaultValues: { + templateName: template?.templateName ?? "", + validityPeriod: template?.validityPeriod ?? 12, // 기본값 12개월 + status: (template?.status as "ACTIVE" | "INACTIVE") || "ACTIVE" + }, + mode: "onChange" + }) + + // 파일 선택 핸들러 + const handleFileChange = (files: File[]) => { + if (files.length > 0) { + const file = files[0]; + setSelectedFile(file); + form.setValue("file", file); + } + }; + + // 템플릿 변경 시 폼 값 업데이트 + React.useEffect(() => { + if (template) { + form.reset({ + templateName: template.templateName, + validityPeriod: template.validityPeriod ?? 12, // 기존 값이 없으면 기본값 12개월 + status: template.status as "ACTIVE" | "INACTIVE", + }); + } + }, [template, form]); + + // 유효기간 선택 옵션 + const validityOptions = [ + { value: "3", label: "3개월" }, + { value: "6", label: "6개월" }, + { value: "12", label: "1년" }, + { value: "24", label: "2년" }, + { value: "36", label: "3년" }, + { value: "60", label: "5년" }, + ]; + + function onSubmit(input: UpdateTemplateSchema) { + startUpdateTransition(async () => { + if (!template) return + + // FormData 객체 생성하여 파일과 데이터를 함께 전송 + const formData = new FormData(); + formData.append("templateName", input.templateName); + formData.append("validityPeriod", input.validityPeriod.toString()); // 유효기간 추가 + formData.append("status", input.status); + + if (input.file) { + formData.append("file", input.file); + } + + try { + // 서비스 함수 호출 + const { error } = await updateTemplate({ + id: template.id, + formData, + }); + + if (error) { + toast.error(error); + return; + } + + form.reset(); + setSelectedFile(null); + props.onOpenChange?.(false); + toast.success("템플릿이 성공적으로 업데이트되었습니다."); + onSuccess?.(); + } catch (error) { + console.error("Update error:", error); + toast.error("템플릿 업데이트 중 오류가 발생했습니다."); + } + }); + } + + return ( + + + + 템플릿 업데이트 + + 템플릿 정보를 수정하고 변경사항을 저장하세요 + + +
+ + ( + + 템플릿 이름 + + + + + + )} + /> + + ( + + 계약 유효기간 + + + 계약서의 유효 기간을 설정합니다. 이 기간이 지나면 재계약이 필요합니다. + + + + )} + /> + + ( + + 상태 + + + + )} + /> + + ( + + 템플릿 파일 (선택사항) + + + + + + {selectedFile + ? selectedFile.name + : template?.fileName + ? `현재 파일: ${template.fileName}` + : "새 파일을 드래그하세요"} + + + {selectedFile + ? `파일 크기: ${(selectedFile.size / 1024).toFixed(2)} KB` + : "또는 클릭하여 파일을 선택하세요 (선택사항)"} + + + + + + + + )} + /> + + + + + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/lib/basic-contract/validations.ts b/lib/basic-contract/validations.ts new file mode 100644 index 00000000..5a5bf5b8 --- /dev/null +++ b/lib/basic-contract/validations.ts @@ -0,0 +1,87 @@ +import * as z from "zod"; +import { createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum,parseAsBoolean +} from "nuqs/server" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { BasicContractTemplate, BasicContractView } from "@/db/schema"; + +export const basicContractTemplateSchema = z.object({ + templateName: z.string().min(1, "템플릿 이름은 필수입니다."), + + // 유효기간을 숫자로 변경하고 적절한 검증 추가 + validityPeriod: z.coerce + .number({ + required_error: "유효기간은 필수입니다.", + invalid_type_error: "유효기간은 숫자여야 합니다." + }) + .int("유효기간은 정수여야 합니다.") + .min(1, "유효기간은 최소 1개월 이상이어야 합니다.") + .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.") + .default(12) // 기본값 1년(12개월) + .describe("계약 유효기간(개월)"), + + status: z.enum(["ACTIVE", "INACTIVE"], { + required_error: "상태는 필수 선택사항입니다.", + invalid_type_error: "올바른 상태 값이 아닙니다." + }).default("ACTIVE"), + + fileName: z.string().min(1, "파일 이름은 필수입니다."), + filePath: z.string().min(1, "파일 경로는 필수입니다."), +}); + +export const searchParamsTemplatesCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + +export const createBasicContractTemplateSchema = basicContractTemplateSchema.extend({}); + +export const updateBasicContractTemplateSchema = basicContractTemplateSchema.partial().extend({ + id: z.number(), +}); + +export const deleteBasicContractTemplateSchema = z.object({ + id: z.number(), +}); + +export type GetBasicContractTemplatesSchema = Awaited> + + +export type CreateBasicContractTemplateSchema = z.infer; +export type UpdateBasicContractTemplateSchema = z.infer; +export type DeleteBasicContractTemplateSchema = z.infer; + + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}); + +export type GetBasciContractsSchema = Awaited>; diff --git a/lib/basic-contract/vendor-table/basic-contract-columns.tsx b/lib/basic-contract/vendor-table/basic-contract-columns.tsx new file mode 100644 index 00000000..b79487d7 --- /dev/null +++ b/lib/basic-contract/vendor-table/basic-contract-columns.tsx @@ -0,0 +1,214 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Paperclip } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { basicContractColumnsConfig, basicContractVendorColumnsConfig } from "@/config/basicContractColumnsConfig" +import { BasicContractView } from "@/db/schema" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +/** + * 파일 다운로드 함수 + */ +/** + * 파일 다운로드 함수 + */ +const handleFileDownload = (filePath: string | null, fileName: string | null) => { + if (!filePath || !fileName) { + toast.error("파일 정보가 없습니다."); + return; + } + + try { + // 전체 URL 생성 + const fullUrl = `${window.location.origin}${filePath}`; + + // a 태그를 생성하여 다운로드 실행 + const link = document.createElement('a'); + link.href = fullUrl; + link.download = fileName; // 다운로드될 파일명 설정 + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success("파일 다운로드를 시작합니다."); + } catch (error) { + console.error("파일 다운로드 오류:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } +}; + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 파일 다운로드 컬럼 (아이콘) + // ---------------------------------------------------------------- + const downloadColumn: ColumnDef = { + id: "download", + header: "", + cell: ({ row }) => { + const template = row.original; + const filePath = template.status === "PENDING" ? template.filePath : template.signedFilePath + + return ( + + ); + }, + maxSize: 30, + enableSorting: false, + } + + + // ---------------------------------------------------------------- + // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 4-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + basicContractVendorColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + // 날짜 형식 처리 + if (cfg.id === "createdAt" || cfg.id === "updatedAt" || cfg.id === "completedAt") { + const dateVal = cell.getValue() as Date + return formatDateTime(dateVal) + } + + // Status 컬럼에 Badge 적용 + if (cfg.id === "status") { + const status = row.getValue(cfg.id) as string + const isPending = status === "PENDING" + + return ( + + {status} + + ) + } + + // 나머지 컬럼은 그대로 값 표시 + return row.getValue(cfg.id) ?? "" + }, + minSize: 80, + + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 5) 최종 컬럼 배열: select, download, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + downloadColumn, // 다운로드 컬럼 추가 + ...nestedColumns, + ] +} \ No newline at end of file diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx new file mode 100644 index 00000000..28a4fd71 --- /dev/null +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -0,0 +1,318 @@ +"use client"; + +import * as React from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { formatDate } from "@/lib/utils"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { BasicContractSignViewer } from "@/lib/basic-contract/viewer/basic-contract-sign-viewer"; +import type { WebViewerInstance } from "@pdftron/webviewer"; +import type { BasicContractView } from "@/db/schema"; +import { + Upload, + FileSignature, + CheckCircle2, + Search, + Clock, + FileText, + User, + AlertCircle, + Calendar +} from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { useRouter } from "next/navigation" + +// 수정된 props 인터페이스 +interface BasicContractSignDialogProps { + contracts: BasicContractView[]; + onSuccess?: () => void; +} + +export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractSignDialogProps) { + const [open, setOpen] = React.useState(false); + const [selectedContract, setSelectedContract] = React.useState(null); + const [instance, setInstance] = React.useState(null); + const [searchTerm, setSearchTerm] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const router = useRouter() + + // 다이얼로그 열기/닫기 핸들러 + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + + // 다이얼로그가 열릴 때 첫 번째 계약서 자동 선택 + if (isOpen && contracts.length > 0 && !selectedContract) { + setSelectedContract(contracts[0]); + } + + if (!isOpen) { + setSelectedContract(null); + setSearchTerm(""); + } + }; + + // 계약서 선택 핸들러 + const handleSelectContract = (contract: BasicContractView) => { + setSelectedContract(contract); + }; + + // 검색된 계약서 필터링 + const filteredContracts = React.useMemo(() => { + if (!searchTerm.trim()) return contracts; + + const term = searchTerm.toLowerCase(); + return contracts.filter(contract => + (contract.templateName || '').toLowerCase().includes(term) || + (contract.userName || '').toLowerCase().includes(term) + ); + }, [contracts, searchTerm]); + + // 다이얼로그가 열릴 때 첫 번째 계약서 자동 선택 + React.useEffect(() => { + if (open && contracts.length > 0 && !selectedContract) { + setSelectedContract(contracts[0]); + } + }, [open, contracts, selectedContract]); + + // 서명 완료 핸들러 + const completeSign = async () => { + if (!instance || !selectedContract) return; + + setIsSubmitting(true); + try { + const { documentViewer, annotationManager } = instance.Core; + const doc = documentViewer.getDocument(); + const xfdfString = await annotationManager.exportAnnotations(); + + const data = await doc.getFileData({ + xfdfString, + downloadType: "pdf", + }); + + // FormData 생성 및 파일 추가 + const formData = new FormData(); + formData.append('file', new Blob([data], { type: 'application/pdf' })); + formData.append('tableRowId', selectedContract.id.toString()); + formData.append('templateName', selectedContract.fileName || ''); + + // API 호출 + const response = await fetch('/api/upload/signed-contract', { + method: 'POST', + body: formData, + next: { tags: ["basicContractView-vendor"] }, + }); + + const result = await response.json(); + + if (result.result) { + toast.success("서명이 성공적으로 완료되었습니다.", { + description: "문서가 성공적으로 처리되었습니다.", + icon: + }); + router.refresh(); + setOpen(false); + if (onSuccess) { + onSuccess(); + } + } else { + toast.error("서명 처리 중 오류가 발생했습니다.", { + description: result.error, + icon: + }); + } + } catch (error) { + console.error("서명 완료 중 오류:", error); + toast.error("서명 처리 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + // 서명 대기중(PENDING) 계약서가 있는지 확인 + const hasPendingContracts = contracts.length > 0; + + return ( + <> + {/* 서명 버튼 */} + + + {/* 서명 다이얼로그 - 고정 높이 유지 */} + + + + + + 기본계약서 및 관련문서 서명 + + + +
+ {/* 왼쪽 영역 - 계약서 목록 */} +
+
+
+
+ +
+ setSearchTerm(e.target.value)} + /> +
+ + + 전체 ({contracts.length}) + 계약서 + 관련문서 + + +
+ + +
+ {filteredContracts.length === 0 ? ( +
+ +

서명 요청된 문서가 없습니다.

+

나중에 다시 확인해주세요.

+
+ ) : ( +
+ {filteredContracts.map((contract) => ( + + ))} +
+ )} +
+
+
+ + {/* 오른쪽 영역 - 문서 뷰어 */} +
+ {selectedContract ? ( + <> +
+

+ + {selectedContract.templateName || '문서'} +

+
+ + + 요청자: {selectedContract.userName || '알 수 없음'} + + + + {formatDate(selectedContract.createdAt)} + +
+
+
+ +
+
+

+ + 서명 후에는 변경할 수 없습니다. +

+ +
+ + ) : ( +
+
+ +
+

문서를 선택해주세요

+

+ 왼쪽 목록에서 서명할 문서를 선택하면 여기에 문서 내용이 표시됩니다. +

+
+ )} +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/lib/basic-contract/vendor-table/basic-contract-table.tsx b/lib/basic-contract/vendor-table/basic-contract-table.tsx new file mode 100644 index 00000000..34e15ae3 --- /dev/null +++ b/lib/basic-contract/vendor-table/basic-contract-table.tsx @@ -0,0 +1,94 @@ +"use client"; + +import * as React from "react"; +import { DataTable } from "@/components/data-table/data-table"; +import { Button } from "@/components/ui/button"; +import { Plus, Loader2 } from "lucide-react"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { toast } from "sonner"; +import { getColumns } from "./basic-contract-columns"; +import { getBasicContracts, getBasicContractsByVendorId } from "../service"; +import { BasicContractView } from "@/db/schema"; +import { BasicContractTableToolbarActions } from "./basicContract-table-toolbar-actions"; + + +interface BasicTemplateTableProps { + promises: Promise< + [ + Awaited>, + ] + > +} + + +export function BasicContractsVendorTable({ promises }: BasicTemplateTableProps) { + + + const [rowAction, setRowAction] = + React.useState | null>(null) + + + const [{ data, pageCount }] = + React.use(promises) + + // console.log(data) + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // config 기반으로 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "templateName", label: "템플릿명", type: "text" }, + { + id: "status", label: "상태", type: "select", options: [ + { label: "서명대기", value: "PENDING" }, + { label: "서명완료", value: "COMPLETED" }, + ] + }, + { id: "userName", label: "요청자", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + // filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx new file mode 100644 index 00000000..2e5e4471 --- /dev/null +++ b/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx @@ -0,0 +1,56 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { BasicContractView } from "@/db/schema" +import { BasicContractSignDialog } from "./basic-contract-sign-dialog" + +interface TemplateTableToolbarActionsProps { + table: Table +} + +export function BasicContractTableToolbarActions({ table }: TemplateTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + + const inPendingContracts = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(contract => contract.status === "PENDING"); + }, [table.getFilteredSelectedRowModel().rows]); + + + return ( +
+ + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/** 4) Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx new file mode 100644 index 00000000..0409151e --- /dev/null +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -0,0 +1,224 @@ +"use client"; + +import React, { + useState, + useEffect, + useRef, + SetStateAction, + Dispatch, +} from "react"; +import { WebViewerInstance } from "@pdftron/webviewer"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +interface BasicContractSignViewerProps { + contractId?: number; + filePath?: string; + isOpen?: boolean; + onClose?: () => void; + onSign?: (documentData: ArrayBuffer) => Promise; + instance: WebViewerInstance | null; + setInstance: Dispatch>; +} + +export function BasicContractSignViewer({ + contractId, + filePath, + isOpen = false, + onClose, + onSign, + instance, + setInstance, +}: BasicContractSignViewerProps) { + const [fileLoading, setFileLoading] = useState(true); + const viewer = useRef(null); + const initialized = useRef(false); + const isCancelled = useRef(false); + const [showDialog, setShowDialog] = useState(isOpen); + + // 다이얼로그 상태 동기화 + useEffect(() => { + setShowDialog(isOpen); + }, [isOpen]); + + // WebViewer 초기화 + useEffect(() => { + if (!initialized.current && viewer.current) { + initialized.current = true; + isCancelled.current = false; + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨"); + return; + } + + // viewerElement이 확실히 존재함을 확인 + const viewerElement = viewer.current; + if (!viewerElement) return; + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + viewerElement + ).then((instance: WebViewerInstance) => { + setInstance(instance); + setFileLoading(false); + + const { disableElements, setToolbarGroup } = instance.UI; + + disableElements([ + "toolbarGroup-Annotate", + "toolbarGroup-Shapes", + "toolbarGroup-Insert", + "toolbarGroup-Edit", + // "toolbarGroup-FillAndSign", + "toolbarGroup-Forms", + ]); + setToolbarGroup("toolbarGroup-View"); + }); + }); + } + }); + } + + return () => { + if (instance) { + instance.UI.dispose(); + } + isCancelled.current = true; + setTimeout(() => cleanupHtmlStyle(), 500); + }; + }, []); + + // 문서 로드 + useEffect(() => { + if (!instance || !filePath) return; + + loadDocument(instance, filePath); + }, [instance, filePath]); + + // 간소화된 문서 로드 함수 + const loadDocument = async (instance: WebViewerInstance, documentPath: string) => { + setFileLoading(true); + try { + const { documentViewer } = instance.Core; + + await documentViewer.loadDocument(documentPath, { extension: 'pdf' }); + + } catch (err) { + console.error("문서 로딩 중 오류 발생:", err); + toast.error("문서를 불러오는데 실패했습니다."); + } finally { + setFileLoading(false); + } + }; + + // 서명 저장 핸들러 + const handleSave = async () => { + if (!instance) return; + + try { + const { documentViewer } = instance.Core; + const doc = documentViewer.getDocument(); + + // 서명된 문서 데이터 가져오기 + const documentData = await doc.getFileData({ + includeAnnotations: true, + }); + + // 외부에서 제공된 onSign 핸들러가 있으면 호출 + if (onSign) { + await onSign(documentData); + } else { + // 기본 동작 - 서명 성공 메시지 표시 + toast.success("계약서가 성공적으로 서명되었습니다."); + } + + handleClose(); + } catch (err) { + console.error("서명 저장 중 오류 발생:", err); + toast.error("서명을 저장하는데 실패했습니다."); + } + }; + + // 다이얼로그 닫기 핸들러 + const handleClose = () => { + if (onClose) { + onClose(); + } else { + setShowDialog(false); + } + }; + + // 인라인 뷰어 렌더링 (다이얼로그 모드가 아닐 때) + if (!isOpen && !onClose) { + return ( +
+
+ {fileLoading && ( +
+ +

문서 로딩 중...

+
+ )} +
+
+ ); + } + + // 다이얼로그 뷰어 렌더링 + return ( + + + + 기본계약서 서명 + + 계약서를 확인하고 서명을 진행해주세요. + + +
+
+ {fileLoading && ( +
+ +

문서 로딩 중...

+
+ )} +
+
+ + + + +
+
+ ); +} + +// WebViewer 정리 함수 +const cleanupHtmlStyle = () => { + // iframe 스타일 정리 (WebViewer가 추가한 스타일) + const elements = document.querySelectorAll('.Document_container'); + elements.forEach((elem) => { + elem.remove(); + }); +}; \ No newline at end of file -- cgit v1.2.3