summaryrefslogtreecommitdiff
path: root/lib/basic-contract
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
commitef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch)
tree345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib/basic-contract
parent9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff)
~20250428 작업사항
Diffstat (limited to 'lib/basic-contract')
-rw-r--r--lib/basic-contract/repository.ts167
-rw-r--r--lib/basic-contract/service.ts957
-rw-r--r--lib/basic-contract/status/basic-contract-columns.tsx213
-rw-r--r--lib/basic-contract/status/basic-contract-table.tsx95
-rw-r--r--lib/basic-contract/status/basicContract-table-toolbar-actions.tsx40
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx359
-rw-r--r--lib/basic-contract/template/basic-contract-template-columns.tsx245
-rw-r--r--lib/basic-contract/template/basic-contract-template.tsx104
-rw-r--r--lib/basic-contract/template/basicContract-table-toolbar-actions.tsx53
-rw-r--r--lib/basic-contract/template/delete-basicContract-dialog.tsx149
-rw-r--r--lib/basic-contract/template/update-basicContract-sheet.tsx300
-rw-r--r--lib/basic-contract/validations.ts87
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-columns.tsx214
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx318
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-table.tsx94
-rw-r--r--lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx56
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx224
17 files changed, 3675 insertions, 0 deletions
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<any, any, any>,
+ 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<any, any, any>,
+ 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<any, any, any>,
+ 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<any, any, any>,
+ 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<any, any, any>,
+ data: Omit<BasicContractTemplate, "id" | "createdAt" | "updatedAt">
+) {
+ 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<any, any, any>,
+ 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<any, any, any>,
+ 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<BasicContractTemplate[]> {
+ 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<BasicContractTemplate, "id" | "createdAt" | "updatedAt">
+): 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<string, any> = {
+ 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<BasicContractTemplate[]> {
+ 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<React.SetStateAction<DataTableRowAction<BasicContractView> | 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<BasicContractView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<BasicContractView> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 파일 다운로드 컬럼 (아이콘)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<BasicContractView> = {
+ id: "download",
+ header: "",
+ cell: ({ row }) => {
+ const template = row.original;
+
+ return (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => handleFileDownload(template.filePath, template.fileName)}
+ title={`${template.fileName} 다운로드`}
+ className="hover:bg-muted"
+ >
+ <Paperclip className="h-4 w-4" />
+ <span className="sr-only">다운로드</span>
+ </Button>
+ );
+ },
+ maxSize: 30,
+ enableSorting: false,
+ }
+
+
+ // ----------------------------------------------------------------
+ // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractView>[] }
+ const groupMap: Record<string, ColumnDef<BasicContractView>[]> = {}
+
+ basicContractColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<BasicContractView> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ 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 (
+ <Badge
+ variant={isActive ? "default" : "secondary"}
+ >
+ {isActive ? "활성" : "비활성"}
+ </Badge>
+ )
+ }
+
+ // 나머지 컬럼은 그대로 값 표시
+ return row.getValue(cfg.id) ?? ""
+ },
+ minSize: 80,
+
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<BasicContractView>[] = []
+
+ // 순서를 고정하고 싶다면 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<ReturnType<typeof getBasicContracts>>,
+ ]
+ >
+}
+
+
+export function BasicContractsTable({ promises }: BasicTemplateTableProps) {
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<BasicContractView> | null>(null)
+
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ // 컬럼 설정 - 외부 파일에서 가져옴
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // config 기반으로 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [
+ { 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 (
+ <>
+
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ >
+ <BasicContractTableToolbarActions table={table} />
+
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ </>
+
+ );
+} \ 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<BasicContractView>
+}
+
+export function BasicContractTableToolbarActions({ table }: TemplateTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "basci-contract",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ 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<typeof templateFormSchema>;
+
+export function AddTemplateDialog() {
+ const [open, setOpen] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
+ const [uploadProgress, setUploadProgress] = React.useState(0);
+ const [showProgress, setShowProgress] = React.useState(false);
+ const router = useRouter()
+
+ // 기본값 설정
+ const defaultValues: Partial<TemplateFormValues> = {
+ templateName: "",
+ validityPeriod: 12, // 기본값 1년
+ status: "ACTIVE",
+ };
+
+ // 폼 초기화
+ const form = useForm<TemplateFormValues>({
+ 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 (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ 템플릿 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>새 기본계약서 템플릿 추가</DialogTitle>
+ <DialogDescription>
+ 템플릿 이름을 입력하고 계약서 파일을 업로드하세요.
+ <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="templateName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 템플릿 이름 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="템플릿 이름을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validityPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 계약 유효기간 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ value={field.value?.toString()}
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="유효기간을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {validityOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ 계약서의 유효 기간을 설정합니다. 이 기간이 지나면 재계약이 필요합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormLabel>
+ 계약서 파일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Dropzone
+ onDrop={handleFileChange}
+ accept={{
+ 'application/pdf': ['.pdf']
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>
+ {selectedFile ? selectedFile.name : "PDF 파일을 여기에 드래그하세요"}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
+ : "또는 클릭하여 PDF 파일을 선택하세요 (최대 100MB)"}
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {showProgress && (
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>업로드 진행률</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} />
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isLoading || !templateName || !validityPeriod || !file}
+ >
+ {isLoading ? "처리 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+} \ 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<React.SetStateAction<DataTableRowAction<BasicContractTemplate> | 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<BasicContractTemplate>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<BasicContractTemplate> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 파일 다운로드 컬럼 (아이콘)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<BasicContractTemplate> = {
+ id: "download",
+ header: "",
+ cell: ({ row }) => {
+ const template = row.original;
+
+ return (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => handleFileDownload(template.filePath, template.fileName)}
+ title={`${template.fileName} 다운로드`}
+ className="hover:bg-muted"
+ >
+ <Paperclip className="h-4 w-4" />
+ <span className="sr-only">다운로드</span>
+ </Button>
+ );
+ },
+ maxSize: 30,
+ enableSorting: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<BasicContractTemplate> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 30,
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractTemplate>[] }
+ const groupMap: Record<string, ColumnDef<BasicContractTemplate>[]> = {}
+
+ basicContractTemplateColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<BasicContractTemplate> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ 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 (
+ <Badge
+ variant={isActive ? "default" : "secondary"}
+ >
+ {isActive ? "활성" : "비활성"}
+ </Badge>
+ )
+ }
+
+ // 나머지 컬럼은 그대로 값 표시
+ return row.getValue(cfg.id) ?? ""
+ },
+ minSize:80
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<BasicContractTemplate>[] = []
+
+ // 순서를 고정하고 싶다면 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<ReturnType<typeof getBasicContractTemplates>>,
+ ]
+ >
+}
+
+
+export function BasicContractTemplateTable({ promises }: BasicTemplateTableProps) {
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<BasicContractTemplate> | null>(null)
+
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ // 컬럼 설정 - 외부 파일에서 가져옴
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // config 기반으로 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<BasicContractTemplate>[] = [
+ { 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 (
+ <>
+
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ >
+ <TemplateTableToolbarActions table={table} />
+
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <DeleteTemplatesDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ templates={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+ <UpdateTemplateSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => 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<BasicContractTemplate>
+}
+
+export function TemplateTableToolbarActions({ table }: TemplateTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteTemplatesDialog
+ templates={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ <AddTemplateDialog/>
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "basic-contract-template-list",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ 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<typeof Dialog> {
+ templates: Row<BasicContractTemplate>["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 (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({templates.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{templates.length}</span>
+ {templates.length === 1 ? " template" : " templates"} from our servers.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({templates.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{templates.length}</span>
+ {templates.length === 1 ? " template" : " templates"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
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<typeof updateTemplateSchema>
+
+interface UpdateTemplateSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ template: BasicContractTemplate | null
+ onSuccess?: () => void
+}
+
+export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTemplateSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
+
+ // 템플릿 데이터 확인을 위한 로그
+ console.log(template)
+
+ const form = useForm<UpdateTemplateSchema>({
+ 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 (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>템플릿 업데이트</SheetTitle>
+ <SheetDescription>
+ 템플릿 정보를 수정하고 변경사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ <FormField
+ control={form.control}
+ name="templateName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>템플릿 이름</FormLabel>
+ <FormControl>
+ <Input placeholder="템플릿 이름을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validityPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약 유효기간</FormLabel>
+ <Select
+ value={field.value?.toString()}
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="유효기간을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {validityOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ 계약서의 유효 기간을 설정합니다. 이 기간이 지나면 재계약이 필요합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상태</FormLabel>
+ <Select
+ defaultValue={field.value}
+ onValueChange={field.onChange}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="템플릿 상태 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="ACTIVE">활성</SelectItem>
+ <SelectItem value="INACTIVE">비활성</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormLabel>템플릿 파일 (선택사항)</FormLabel>
+ <FormControl>
+ <Dropzone
+ onDrop={handleFileChange}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>
+ {selectedFile
+ ? selectedFile.name
+ : template?.fileName
+ ? `현재 파일: ${template.fileName}`
+ : "새 파일을 드래그하세요"}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / 1024).toFixed(2)} KB`
+ : "또는 클릭하여 파일을 선택하세요 (선택사항)"}
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ disabled={isUpdatePending || !form.formState.isValid}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ 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<BasicContractTemplate>().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<ReturnType<typeof searchParamsTemplatesCache.parse>>
+
+
+export type CreateBasicContractTemplateSchema = z.infer<typeof createBasicContractTemplateSchema>;
+export type UpdateBasicContractTemplateSchema = z.infer<typeof updateBasicContractTemplateSchema>;
+export type DeleteBasicContractTemplateSchema = z.infer<typeof deleteBasicContractTemplateSchema>;
+
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<BasicContractView>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+});
+
+export type GetBasciContractsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
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<React.SetStateAction<DataTableRowAction<BasicContractView> | 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<BasicContractView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<BasicContractView> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 파일 다운로드 컬럼 (아이콘)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<BasicContractView> = {
+ id: "download",
+ header: "",
+ cell: ({ row }) => {
+ const template = row.original;
+ const filePath = template.status === "PENDING" ? template.filePath : template.signedFilePath
+
+ return (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => handleFileDownload(filePath, template.fileName)}
+ title={`${template.fileName} 다운로드`}
+ className="hover:bg-muted"
+ >
+ <Paperclip className="h-4 w-4" />
+ <span className="sr-only">다운로드</span>
+ </Button>
+ );
+ },
+ maxSize: 30,
+ enableSorting: false,
+ }
+
+
+ // ----------------------------------------------------------------
+ // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractView>[] }
+ const groupMap: Record<string, ColumnDef<BasicContractView>[]> = {}
+
+ basicContractVendorColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<BasicContractView> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ 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 (
+ <Badge
+ variant={!isPending ? "default" : "secondary"}
+ >
+ {status}
+ </Badge>
+ )
+ }
+
+ // 나머지 컬럼은 그대로 값 표시
+ return row.getValue(cfg.id) ?? ""
+ },
+ minSize: 80,
+
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<BasicContractView>[] = []
+
+ // 순서를 고정하고 싶다면 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<BasicContractView | null>(null);
+ const [instance, setInstance] = React.useState<null | WebViewerInstance>(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: <CheckCircle2 className="h-5 w-5 text-green-500" />
+ });
+ router.refresh();
+ setOpen(false);
+ if (onSuccess) {
+ onSuccess();
+ }
+ } else {
+ toast.error("서명 처리 중 오류가 발생했습니다.", {
+ description: result.error,
+ icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ });
+ }
+ } catch (error) {
+ console.error("서명 완료 중 오류:", error);
+ toast.error("서명 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 서명 대기중(PENDING) 계약서가 있는지 확인
+ const hasPendingContracts = contracts.length > 0;
+
+ return (
+ <>
+ {/* 서명 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setOpen(true)}
+ disabled={!hasPendingContracts}
+ className="gap-2 transition-all hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200"
+ >
+ <Upload className="size-4 text-blue-500" aria-hidden="true" />
+ <span className="hidden sm:inline flex items-center">
+ 서명하기
+ {contracts.length > 0 && (
+ <Badge variant="secondary" className="ml-2 bg-blue-100 text-blue-700 hover:bg-blue-200">
+ {contracts.length}
+ </Badge>
+ )}
+ </span>
+ </Button>
+
+ {/* 서명 다이얼로그 - 고정 높이 유지 */}
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="max-w-5xl h-[650px] w-[90vw] p-0 overflow-hidden rounded-lg shadow-lg border border-gray-200">
+ <DialogHeader className="p-6 bg-gradient-to-r from-blue-50 to-purple-50 border-b">
+ <DialogTitle className="text-xl font-bold flex items-center text-gray-800">
+ <FileSignature className="mr-2 h-5 w-5 text-blue-500" />
+ 기본계약서 및 관련문서 서명
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="grid grid-cols-2 h-[calc(100%-4rem)] overflow-hidden">
+ {/* 왼쪽 영역 - 계약서 목록 */}
+ <div className="col-span-1 border-r border-gray-200 bg-gray-50">
+ <div className="p-4 border-b">
+ <div className="relative mb-10">
+ <div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
+ <Search className="h-4 w-8 text-gray-400" />
+ </div>
+ <Input
+ placeholder="문서명 또는 요청자 검색"
+ className="bg-white"
+ style={{paddingLeft:25}}
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ />
+ </div>
+ <Tabs defaultValue="all" className="w-full">
+ <TabsList className="w-full">
+ <TabsTrigger value="all" className="flex-1">전체 ({contracts.length})</TabsTrigger>
+ <TabsTrigger value="contracts" className="flex-1">계약서</TabsTrigger>
+ <TabsTrigger value="docs" className="flex-1">관련문서</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ </div>
+
+ <ScrollArea className="h-[calc(100%-6rem)]">
+ <div className="p-3">
+ {filteredContracts.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-40 text-center">
+ <FileText className="h-12 w-12 text-gray-300 mb-2" />
+ <p className="text-gray-500 font-medium">서명 요청된 문서가 없습니다.</p>
+ <p className="text-gray-400 text-sm mt-1">나중에 다시 확인해주세요.</p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredContracts.map((contract) => (
+ <Button
+ key={contract.id}
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left h-auto p-3 bg-white hover:bg-blue-50 transition-colors",
+ "border border-gray-200 hover:border-blue-200 rounded-md",
+ selectedContract?.id === contract.id && "border-blue-500 bg-blue-50 shadow-sm"
+ )}
+ onClick={() => handleSelectContract(contract)}
+ >
+ <div className="flex flex-col w-full">
+ <div className="flex items-center justify-between w-full">
+ <span className="font-semibold truncate text-gray-800 flex items-center">
+ <FileText className="h-4 w-4 mr-2 text-blue-500" />
+ {contract.templateName || '문서'}
+ </span>
+ <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
+ 대기중
+ </Badge>
+ </div>
+ <Separator className="my-2 bg-gray-100" />
+ <div className="grid grid-cols-2 gap-1 mt-1 text-xs text-gray-500">
+ <div className="flex items-center">
+ <User className="h-3 w-3 mr-1" />
+ <span className="truncate">{contract.userName || '알 수 없음'}</span>
+ </div>
+ <div className="flex items-center justify-end">
+ <Calendar className="h-3 w-3 mr-1" />
+ <span>{formatDate(contract.createdAt)}</span>
+ </div>
+ </div>
+ </div>
+ </Button>
+ ))}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+
+ {/* 오른쪽 영역 - 문서 뷰어 */}
+ <div className="col-span-1 bg-white flex flex-col h-full">
+ {selectedContract ? (
+ <>
+ <div className="p-3 border-b bg-gray-50">
+ <h3 className="font-semibold text-gray-800 flex items-center">
+ <FileText className="h-4 w-4 mr-2 text-blue-500" />
+ {selectedContract.templateName || '문서'}
+ </h3>
+ <div className="flex justify-between items-center mt-1 text-xs text-gray-500">
+ <span className="flex items-center">
+ <User className="h-3 w-3 mr-1" />
+ 요청자: {selectedContract.userName || '알 수 없음'}
+ </span>
+ <span className="flex items-center">
+ <Clock className="h-3 w-3 mr-1" />
+ {formatDate(selectedContract.createdAt)}
+ </span>
+ </div>
+ </div>
+ <div className="flex-grow overflow-hidden border-b">
+ <BasicContractSignViewer
+ contractId={selectedContract.id}
+ filePath={selectedContract.filePath || undefined}
+ instance={instance}
+ setInstance={setInstance}
+ />
+ </div>
+ <div className="p-3 flex justify-between items-center bg-gray-50">
+ <p className="text-sm text-gray-600">
+ <AlertCircle className="h-4 w-4 text-yellow-500 inline mr-1" />
+ 서명 후에는 변경할 수 없습니다.
+ </p>
+ <Button
+ className="gap-2 bg-blue-600 hover:bg-blue-700 transition-colors"
+ onClick={completeSign}
+ disabled={isSubmitting}
+ >
+ {isSubmitting ? (
+ <>
+ <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+ </svg>
+ 처리 중...
+ </>
+ ) : (
+ <>
+ <FileSignature className="h-4 w-4" />
+ 서명 완료
+ </>
+ )}
+ </Button>
+ </div>
+ </>
+ ) : (
+ <div className="flex flex-col items-center justify-center h-full text-center p-6">
+ <div className="bg-blue-50 p-6 rounded-full mb-4">
+ <FileSignature className="h-12 w-12 text-blue-500" />
+ </div>
+ <h3 className="text-xl font-medium text-gray-800 mb-2">문서를 선택해주세요</h3>
+ <p className="text-gray-500 max-w-md">
+ 왼쪽 목록에서 서명할 문서를 선택하면 여기에 문서 내용이 표시됩니다.
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+} \ 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<ReturnType<typeof getBasicContractsByVendorId>>,
+ ]
+ >
+}
+
+
+export function BasicContractsVendorTable({ promises }: BasicTemplateTableProps) {
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<BasicContractView> | null>(null)
+
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ // console.log(data)
+
+ // 컬럼 설정 - 외부 파일에서 가져옴
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // config 기반으로 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [
+ { 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 (
+ <>
+
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ >
+ <BasicContractTableToolbarActions table={table} />
+
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ </>
+
+ );
+} \ 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<BasicContractView>
+}
+
+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 (
+ <div className="flex items-center gap-2">
+
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <BasicContractSignDialog
+ contracts={inPendingContracts}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "basci-contract-requested-list",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ 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<void>;
+ instance: WebViewerInstance | null;
+ setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>;
+}
+
+export function BasicContractSignViewer({
+ contractId,
+ filePath,
+ isOpen = false,
+ onClose,
+ onSign,
+ instance,
+ setInstance,
+}: BasicContractSignViewerProps) {
+ const [fileLoading, setFileLoading] = useState<boolean>(true);
+ const viewer = useRef<HTMLDivElement>(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 (
+ <div className="border rounded-md overflow-hidden" style={{ height: '600px' }}>
+ <div ref={viewer} className="h-[100%]">
+ {fileLoading && (
+ <div className="flex flex-col items-center justify-center py-12">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ }
+
+ // 다이얼로그 뷰어 렌더링
+ return (
+ <Dialog open={showDialog} onOpenChange={handleClose}>
+ <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}>
+ <DialogHeader>
+ <DialogTitle>기본계약서 서명</DialogTitle>
+ <DialogDescription>
+ 계약서를 확인하고 서명을 진행해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="h-[calc(70vh-60px)]">
+ <div ref={viewer} className="h-[100%]">
+ {fileLoading && (
+ <div className="flex flex-col items-center justify-center py-12">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ </div>
+ )}
+ </div>
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={handleClose} disabled={fileLoading}>
+ 취소
+ </Button>
+ <Button onClick={handleSave} disabled={fileLoading}>
+ 서명 완료
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+// WebViewer 정리 함수
+const cleanupHtmlStyle = () => {
+ // iframe 스타일 정리 (WebViewer가 추가한 스타일)
+ const elements = document.querySelectorAll('.Document_container');
+ elements.forEach((elem) => {
+ elem.remove();
+ });
+}; \ No newline at end of file