diff options
Diffstat (limited to 'lib/basic-contract')
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 |
